[
  {
    "path": ".gitignore",
    "content": ".build/\n.swiftpm/\n.DS_Store\nartifacts/\nbuild/\nbuild-mas/\nlogs/\n.xcuserstate\n._*\nCodMate.app\n\n# Xcode per-user state and caches\n**/xcuserdata/**\n**/*.xcuserstate\n**/*.xcbkptlist\nCodMate.xcodeproj/project.xcworkspace/xcuserdata/\ncodmate.xcodeproj/project.xcworkspace/xcuserdata/\nCodMate.xcodeproj/xcuserdata/\ncodmate.xcodeproj/xcuserdata/\n\n# Vendored or local packages to exclude\nSwiftTerm/\ndist/\n.env\n.comate\n.serena\n# Project-level AI assistant configs (generated at runtime)\n.claude/\n.codex/\n.gemini/\n\n# Sisyphus (agent) workspace\n.sisyphus/\n\n# Local build artifacts\nlibghostty.a\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n  \"configurations\": [\n    {\n      \"type\": \"lldb\",\n      \"request\": \"launch\",\n      \"args\": [],\n      \"cwd\": \"${workspaceFolder:CodMate}\",\n      \"name\": \"Debug CodMate\",\n      \"program\": \"${workspaceFolder:CodMate}/.build/debug/CodMate\",\n      \"preLaunchTask\": \"swift: Build Debug CodMate\"\n    },\n    {\n      \"type\": \"lldb\",\n      \"request\": \"launch\",\n      \"args\": [],\n      \"cwd\": \"${workspaceFolder:CodMate}\",\n      \"name\": \"Release CodMate\",\n      \"program\": \"${workspaceFolder:CodMate}/.build/release/CodMate\",\n      \"preLaunchTask\": \"swift: Build Release CodMate\"\n    }\n  ]\n}"
  },
  {
    "path": "AGENTS.md",
    "content": "CodMate – AGENTS Guidelines\n\nPurpose\n- This document tells AI/code agents how to work inside the CodMate repository (macOS desktop GUI for Codex session management).\n- Scope: applies to the entire repo. Prefer macOS SwiftUI/AppKit APIs; avoid iOS‑only placements or components.\n\nArchitecture\n- App type: macOS SwiftUI app (min macOS 13.5). SwiftPM-only build (no Xcode project).\n- Layering (MVVM):\n  - Models: pure data structures (SessionSummary, SessionEvent, DateDimension, SessionLoadScope, …)\n  - Services: IO and side effects (SessionIndexer, SessionCacheStore, SessionActions, SessionTimelineLoader, LLMClient)\n  - ViewModels: async orchestration, filtering, state (SessionListViewModel)\n  - Views: SwiftUI views only (no business logic)\n\nUI Rules (macOS specific)\n- Use macOS SwiftUI and AppKit bridges; do NOT use iOS‑only placements such as `.navigationBarTrailing`.\n- 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).\n  - Tab content uniformly uses `SettingsTabContent` container (top-aligned, overall 8pt padding) to ensure consistent layout and spacing across pages.\n- 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.\n- 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).\n  - 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.\n  - 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.\n  - 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).\n  - 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.\n- Extensions page (aligned with Providers style):\n  - Settings › Extensions replaces the old MCP Server page (icon: puzzlepiece.extension).\n  - 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.\n  - 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.\n  - Commands tab includes Add and Import buttons (Import scans Home command folders into CodMate).\n  - 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.\n  - MCP Servers tab keeps: enable toggle on left, edit on right, fixed \"Add\" button, Uni‑Import preview and confirmation.\n  - Advanced capabilities (MCPMate download and instructions) remain as a footer/section in MCP Servers tab.\n- Search: prefer a toolbar `SearchField` in macOS, not `.searchable` when exact placement (far right) matters.\n- 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.\n- 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.\n- Sidebar (left):\n  - Top (fixed): \"All Sessions\" row showing total count and selection state.\n  - 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).\n  - Projects mode mirrors the compact list style; Cmd-click toggles multi-selection so users can filter sessions by several projects simultaneously (descendants remain included).\n  - 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).\n  - Only the middle path tree scrolls; top \"All Sessions\" and bottom calendar remain fixed.\n  - Sidebar width: min 220pt, max 25% of window width, ideal 260pt.\n- Content (middle):\n  - Default scope loads “today” only for speed.\n  - Sorting picker is left‑aligned with list content.\n  - Each row shows: title, timestamps/duration, snippet, and compact metrics (user/assistant/tool/reasoning).\n- Status bar (bottom console):\n  - Docked console bar spans the right-side area (list + detail); sidebar stays full height.\n  - Resizable via a drag handle; single-line header collapses/expands to multi-line log history.\n  - Auto mode collapses when idle (no interaction); View menu supports Always Show/Hide.\n- Detail (right):\n  - Sticky action bar at top: Resume, Reveal in Finder, Delete, Export Markdown.\n  - Add “New” button next to Resume to start a fresh Codex session using the current session’s working directory and model.\n  - 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.\n  - 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.\n  - 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:\n    - Auto-detects the Git repo at the session’s working directory (uses `/usr/bin/env git` and a robust PATH).\n    - 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.\n    - Provides a commit box. In full-area mode it uses a multi-line editor with more space.\n    - 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.\n  - “Task Instructions” uses a DisclosureGroup; load lazily when expanded.\n  - Conversation timeline uses LazyVStack; differentiate user/assistant/tool/info bubbles.\n- 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.\n  - Turn Context is surfaced in the Environment Context card and is not exposed as a separate toggle or timeline item.\n  - Context menu in list rows adds: “Generate Title & 100-char Summary” to run LLM on-demand for the selected session.\n- 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.\n  - 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.\n  - Terminal shortcuts: (none for now). Clearing via shortcut is not implemented.\n\nPerformance Contract\n- Fast path indexing: memory‑mapped reads; parse first ~400 lines + read tail ~64KB to correct `lastUpdatedAt`.\n- Background enrichment: full parse in a constrained task group; batch UI updates (≈10 items per flush).\n- Full‑text search: chunked stream scan (128 KB), case‑insensitive; avoid `lowercased()` on whole file.\n- Disk cache: `~/Library/Caches/CodMate/sessionIndex-v1.json` keyed by path+mtime; prefer cache hits before parsing.\n- Sidebar statistics (calendar/tree) must be global and computed independently of the current list scope to keep navigation usable.\n - 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.\n\nCoding Guidelines\n- Concurrency: use `actor` for services managing shared caches; UI updates on MainActor only.\n- Cancellation: cancel previous tasks on new search/scope changes. Name tasks (`fulltextTask`, `enrichmentTask`) and guard `Task.isCancelled` in loops.\n- File IO: prefer `Data(mappedIfSafe:)` or `FileHandle.read(upToCount:)`; never load huge files into Strings.\n- Error handling: surface user‑visible errors through `ViewModel.errorMessage` and macOS system notifications/alerts; do not crash the UI.\n- Testability: keep parsers and small helpers pure; avoid `Process()`/AppKit in ViewModel.\n- Provider Icon Theme Handling:\n  - Use `ProviderIconThemeHelper` (in `utils/ProviderIconThemeHelper.swift`) for consistent dark/light mode icon adaptation.\n  - Icons that are black or dark-colored (ChatGPTIcon/Codex, KimiIcon, ZaiIcon, OpenRouterIcon) must be inverted in dark mode for visibility.\n  - For SwiftUI views: use `.providerIconTheme(iconName:)` modifier or `ProviderIconDarkModeModifier` directly.\n  - For AppKit menus: use `ProviderIconThemeHelper.menuImage(named:)` which automatically handles resizing and dark mode inversion.\n  - Do NOT manually check dark mode and invert icons; always use the helper to ensure consistency across the app.\n\nCLI Integration (codex)\n- Prefer invoking via `/usr/bin/env codex` (or `claude`) so resolution happens on system `PATH`.\n- Allow optional user-specified command path overrides; use the override when valid, otherwise fall back to PATH resolution.\n- 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.\n- Always set `PATH` to include `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin` before launching for robustness.\n- `resume` runs with `currentDirectoryURL` = original session `cwd` when it exists (fallback: log file directory).\n- New command options exposed in Settings › Command:\n   - Sandbox policy (`-s/--sandbox`): `read-only`, `workspace-write`, `danger-full-access`.\n   - Approval policy (`-a/--ask-for-approval`): `untrusted`, `on-failure`, `on-request`, `never`.\n   - `--full-auto` convenience alias (maps to `-a on-failure` + `--sandbox workspace-write`).\n   - `--dangerously-bypass-approvals-and-sandbox` (overrides other flags; only for externally sandboxed envs).\n- UI adds a \"Copy real command\" button in the detail action bar when the embedded terminal is active; this copies the exact `codex resume <id>` invocation including flags.\n- 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`.\n\nCodex Settings\n- Settings › Codex only manages Codex CLI runtime-related configuration (Model & Reasoning, Sandbox & Approvals, Notifications, Privacy, Raw Config).\n- Providers page is independent: Settings › Providers (cross-application shared, for Codex and Claude Code selection/configuration, including OAuth).\n- Provider selection uses the unified provider picker (same list as Git Review), backed by CLI Proxy API when chosen; Auto keeps CLI defaults.\n- Provider tab includes a separate Model List setting under Active Provider with an editor to add/remove model IDs per provider (empty = default list).\n- Notifications: TUI notifications toggle; system notifications bridge via the bundled Swift `codmate-notify` helper (installed to `~/Library/Application Support/CodMate/bin/`).\n- Privacy: expose `shell_environment_policy`, reasoning visibility, OTEL exporter; do not surface history persistence in phase 1.\n- Projects auto‑create a same‑id Profile on creation; renaming a project synchronizes the profile name. Conflict prompts are required.\n\nClaude Settings\n- Settings › Claude splits into Provider, Runtime, Notifications, and Raw Config tabs.\n- Provider selection uses the unified provider picker (same list as Git Review), backed by CLI Proxy API when chosen; Auto keeps CLI defaults.\n- 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`.\n- Notifications tab mirrors Codex UX: single toggle to install/remove macOS notification hooks, health indicator, and self-test button.\n- 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=…\"`.\n- Always request Home directory access through `AuthorizationHub` before mutating the hooks file when sandboxed.\n\nGemini Settings\n- 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.\n- Provider tab includes a Model List setting for CLI Proxy providers; the Model tab remains the source of truth for Gemini CLI model selection.\n\nSession Metadata (Rename/Comment)\n- Users can rename any session and attach a short comment.\n- Trigger: click the title at the top-left of the detail pane to open the editor.\n- Persistence: stored per file under `~/.codmate/notes/<sessionId-sanitized>.json`. A first-run migration copies entries from the legacy Application Support JSON and migrates from the legacy `~/.codex/notes` directory when present.\n- Display: the name replaces the ID in the detail header and list; the comment is used as the row snippet when present.\n\nAbout Surface\n- Settings › About shows app version, build timestamp (derived from the app executable’s modification date), and project URL.\n- Settings › About includes update checking/downloading for non-App Store builds and guides manual install.\n- “About CodMate” menu item should open Settings pre-selecting the About tab.\n - Include an “Open Source Licenses” entry that displays `THIRD-PARTY-NOTICES.md` (bundled if present; falls back to repository URL if missing).\n\nDiagnostics\n- 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.\n  - Also probes Claude Code sessions (`~/.claude/projects`, `.jsonl`) for presence and counts.\n- When the current root has 0 sessions but the default has files, the UI suggests switching to the default path.\n- Users can “Save Report…” to export a JSON diagnostics file for troubleshooting.\n\nFile/Folder Layout\n- assets/             – Assets + Info.plist\n- CodMateApp.swift    – App entry point\n- models/             – data types\n- services/           – IO, indexing, cache, codex actions\n- utils/              – helpers\n- views/              – SwiftUI views only\n- payload/            – bundled presets (providers/terminals)\n- notify/             – Swift command-line helper (codmate-notify)\n- SwiftTerm/          – embedded terminal dependency (local package)\n- .github/workflows/  – CI + release pipelines\n- scripts/            – build/packaging scripts\n- docs/               – design notes and investigation docs\n\nAdvanced Page\n- Settings › Advanced (between MCP Server and About) uses a TabView with Path, CLI Proxy API, and Dialectics tabs.\n- Path tab:\n  - File paths (Projects/Notes) and CLI command path overrides (codex/claude/gemini)\n  - CLI environment snapshot (auto-detected paths + PATH)\n- CLI Proxy API tab:\n  - Binary location + install/reinstall\n  - Config/Auth/Logs paths (reveal in Finder)\n- Dialectics tab aggregates diagnostics:\n  - Codex sessions root probe (current vs default), counts and sample files, enumerator errors\n  - Claude sessions directory probe (default path), counts and samples\n  - Notes and Projects directories probes (current vs default), counts and sample files\n  - Does not mutate config automatically; changes only happen via explicit user actions\n\nBuild & Run\n- SwiftPM is the source of truth. Use `swift build` to validate compile.\n- Build the app bundle with `make app` or `BASE_VERSION=1.2.3 ./scripts/create-app-bundle.sh`.\n- Build a DMG with `make dmg` or `BASE_VERSION=1.2.3 ./scripts/macos-build-notarized-dmg.sh`.\n\nCommit Conventions\n\nFollow conventional commits pattern:\n\n- `feat:` - New feature\n- `fix:` - Bug fix\n- `docs:` - Documentation change\n- `style:` - Formatting, missing semicolons, etc.\n- `refactor:` - Code change that neither fixes a bug nor adds a feature\n- `perf:` - Performance improvement\n- `test:` - Adding or updating tests\n- `chore:` - Changes to build process or auxiliary tools\n\n> 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.\n\nCommit Subject Focus Principles\n\n- The commit subject (title) should concisely highlight the \"core focus\" or the most important substantive change of the commit.\n- 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.\n- If the change involves bilingual documentation, syncing with code implementation, or architectural adjustments, make this explicit in the title.\n- Recommended format: \"what was done + why/for what\". For example:\n  - `docs: sync EN/CN README and align config docs with codebase`\n  - `feat: support multi-suit config management for flexible scenarios`\n  - `fix: resolve SSE connection issue in bridge module`\n\nExample:\n```\nFeature: Expand MCP API documentation with detailed instance and system management\n\nWhere:\n- Updated README.md files across the API, handlers, models, and routes directories to include comprehensive details on new instance and system management functionalities.\n- Added specific sections for MCP handlers, models, and routes to clarify the operations available for managing servers and instances.\n\nWhy:\n- To enhance the clarity and usability of the API documentation, ensuring users can easily understand and utilize the new features.\n\nWhat:\n- Documented new API endpoints for instance management, including listing, retrieving, and managing instance health.\n- Provided detailed descriptions of the handlers and models associated with MCP server and instance management.\n- Updated routing information to reflect the new structure and capabilities of the API.\n\nIssues:\n- This documentation update supports ongoing development and user engagement by providing clear guidance on the API's capabilities.\n```\n\nPR / Change Policy for Agents\n- Keep changes minimal and focused; do not refactor broadly without need.\n- Maintain macOS compliance first; avoid iOS‑only modifiers/placements.\n- When changing UI structure, update this AGENTS.md and the in‑app Settings if applicable.\n- Validate performance: measure large session trees; ensure first paint is fast and enrichment is incremental.\n\nKnown Pitfalls\n- `.searchable` may hijack the trailing toolbar slot on macOS; use `SearchField` in a `ToolbarItem` to control placement.\n- OutlineGroup row height is affected by control size and insets; tighten with `.environment(\\.defaultMinListRowHeight, 18)` and `.listRowInsets(...)` inside the row content.\n- 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.\n- 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<ProvidersVM, String>`). This makes patches safer and reduces chances of accidental extra backslashes.\n- 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”.\n- 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'”).\n- 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.\n"
  },
  {
    "path": "CodMateApp.swift",
    "content": "import SwiftUI\nimport GhosttyKit\n\n#if os(macOS)\n  import AppKit\n#endif\n\n@main\nstruct CodMateApp: App {\n  #if os(macOS)\n    @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate\n  #endif\n  @StateObject private var listViewModel: SessionListViewModel\n  @StateObject private var preferences: SessionPreferencesStore\n  @State private var settingsSelection: SettingCategory = .general\n  @State private var extensionsTabSelection: ExtensionsSettingsTab = .commands\n  @Environment(\\.openWindow) private var openWindow\n\n  init() {\n    let prefs = SessionPreferencesStore()\n    let listVM = SessionListViewModel(preferences: prefs)\n    _preferences = StateObject(wrappedValue: prefs)\n    _listViewModel = StateObject(wrappedValue: listVM)\n    // Prepare user notifications early so banners can show while app is active\n    SystemNotifier.shared.bootstrap()\n    // Setup menu bar before windows appear\n    #if os(macOS)\n      MenuBarController.shared.configure(viewModel: listVM, preferences: prefs)\n    #endif\n    // In App Sandbox, restore security-scoped access to user-selected directories\n    SecurityScopedBookmarks.shared.restoreAndStartAccess()\n    // Restore all dynamic bookmarks (e.g., repository directories for Git Review)\n    SecurityScopedBookmarks.shared.restoreAllDynamicBookmarks()\n    // Restore and check sandbox permissions for critical directories\n    Task { @MainActor in\n      SandboxPermissionsManager.shared.restoreAccess()\n    }\n    // Sync launch at login state with system\n    Task { @MainActor in\n      LaunchAtLoginService.shared.syncWithPreferences(prefs)\n    }\n    // Daily update check (non-App Store builds only)\n    Task {\n      _ = await UpdateService.shared.checkIfNeeded(trigger: .appLaunch)\n    }\n    // Log startup info to Status Bar\n    Task { @MainActor in\n      let version = Bundle.main.infoDictionary?[\"CFBundleShortVersionString\"] as? String ?? \"unknown\"\n      AppLogger.shared.info(\"CodMate v\\(version) started\", source: \"App\")\n    }\n  }\n\n  var bodyCommands: some Commands {\n    Group {\n      CommandGroup(replacing: .appInfo) {\n        Button(\"About CodMate\") { presentSettings(for: .about) }\n      }\n      CommandGroup(replacing: .appSettings) {\n        Button(\"Settings…\") { presentSettings(for: .general) }\n          .keyboardShortcut(\",\", modifiers: [.command])\n      }\n      CommandGroup(after: .appSettings) {\n        Button(\"Global Search…\") {\n          NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil)\n        }\n        .keyboardShortcut(\"f\", modifiers: [.command])\n      }\n      // Integrate actions into the system View menu\n      CommandGroup(after: .sidebar) {\n        Button(action: {\n          NotificationCenter.default.post(\n            name: .codMateRefreshRequested,\n            object: nil,\n            userInfo: RefreshRequest.userInfo(for: .context)\n          )\n        }) {\n          Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n        }\n        .keyboardShortcut(\"r\", modifiers: [.command])\n\n        Button(action: {\n          NotificationCenter.default.post(\n            name: .codMateRefreshRequested,\n            object: nil,\n            userInfo: RefreshRequest.userInfo(for: .global)\n          )\n        }) {\n          Label(\"Full Refresh\", systemImage: \"arrow.triangle.2.circlepath\")\n        }\n        .keyboardShortcut(\"r\", modifiers: [.command, .option])\n\n        Button(action: {\n          NotificationCenter.default.post(name: .codMateToggleSidebar, object: nil)\n        }) {\n          Label(\"Toggle Sidebar\", systemImage: \"sidebar.left\")\n        }\n        .keyboardShortcut(\"1\", modifiers: [.command])\n\n        Button(action: {\n          NotificationCenter.default.post(name: .codMateToggleList, object: nil)\n        }) {\n          Label(\"Toggle Session List\", systemImage: \"sidebar.leading\")\n        }\n        .keyboardShortcut(\"2\", modifiers: [.command])\n\n        Divider()\n\n        Button(action: {\n          withAnimation {\n            if preferences.statusBarVisibility == .hidden {\n              preferences.statusBarVisibility = .auto\n            } else {\n              preferences.statusBarVisibility = .hidden\n            }\n          }\n        }) {\n          if preferences.statusBarVisibility == .hidden {\n            Label(\"Show Status Bar\", systemImage: \"rectangle.bottomthird.inset.filled\")\n          } else {\n            Label(\"Hide Status Bar\", systemImage: \"rectangle.bottomthird.inset.filled\")\n          }\n        }\n        .keyboardShortcut(\"3\", modifiers: [.command])\n      }\n      // Override Cmd+Q to use smart quit behavior\n      CommandGroup(replacing: .appTermination) {\n        Button(\"Quit CodMate\") {\n          MenuBarController.shared.handleQuit()\n        }\n        .keyboardShortcut(\"q\", modifiers: [.command])\n      }\n    }\n  }\n\n  var body: some Scene {\n    // Use Window instead of WindowGroup to enforce single instance\n    Window(\"CodMate\", id: \"main\") {\n      ContentView(viewModel: listViewModel)\n        .frame(minWidth: 880, minHeight: 600)\n        .onReceive(NotificationCenter.default.publisher(for: .codMateOpenSettings)) { note in\n          let raw = note.userInfo?[\"category\"] as? String\n          if let raw, let cat = SettingCategory(rawValue: raw) {\n            settingsSelection = cat\n            if cat == .mcpServer,\n               let tab = note.userInfo?[\"extensionsTab\"] as? String,\n               let parsed = ExtensionsSettingsTab(rawValue: tab) {\n              extensionsTabSelection = parsed\n            }\n          } else {\n            settingsSelection = .general\n          }\n          if !bringWindow(identifier: \"CodMateSettingsWindow\") {\n            openWindow(id: \"settings\")\n          }\n        }\n        .onReceive(NotificationCenter.default.publisher(for: .codMateOpenMainWindow)) { _ in\n          // Window is singleton, so openWindow is idempotent\n          if !bringWindow(identifier: \"CodMateMainWindow\") {\n            openWindow(id: \"main\")\n          }\n        }\n    }\n    .defaultSize(width: 1200, height: 780)\n    .windowToolbarStyle(.unified)  // Prevent toolbar KVO issues with Window singleton\n    .handlesExternalEvents(matching: [])  // Prevent URL scheme from triggering new window creation\n    .commands { bodyCommands }\n    #if os(macOS)\n      Window(\"Settings\", id: \"settings\") {\n        SettingsWindowContainer(\n          preferences: preferences,\n          listViewModel: listViewModel,\n          selection: $settingsSelection,\n          extensionsTab: $extensionsTabSelection\n        )\n      }\n      .defaultSize(width: 800, height: 640)\n      .windowStyle(.titleBar)\n      .windowToolbarStyle(.automatic)\n      .windowResizability(.contentMinSize)\n      .handlesExternalEvents(matching: [])  // Prevent URL scheme from triggering new window creation\n    #endif\n  }\n\n  private func presentSettings(for category: SettingCategory) {\n    settingsSelection = category\n    if category == .mcpServer {\n      extensionsTabSelection = .mcp\n    }\n    #if os(macOS)\n      NSApplication.shared.activate(ignoringOtherApps: true)\n    #endif\n    if !bringWindow(identifier: \"CodMateSettingsWindow\") {\n      openWindow(id: \"settings\")\n    }\n  }\n\n  private func bringWindow(identifier: String) -> Bool {\n    #if os(macOS)\n      let id = NSUserInterfaceItemIdentifier(identifier)\n      if let window = NSApplication.shared.windows.first(where: { $0.identifier == id }) {\n        window.makeKeyAndOrderFront(nil)\n        return true\n      }\n    #endif\n    return false\n  }\n}\n\nprivate struct SettingsWindowContainer: View {\n  let preferences: SessionPreferencesStore\n  let listViewModel: SessionListViewModel\n  @Binding var selection: SettingCategory\n  @Binding var extensionsTab: ExtensionsSettingsTab\n\n  var body: some View {\n    SettingsView(preferences: preferences, selection: $selection, extensionsTab: $extensionsTab)\n      .environmentObject(listViewModel)\n  }\n}\n\n#if os(macOS)\n  @MainActor\n  final class AppDelegate: NSObject, NSApplicationDelegate {\n    private var suppressNextReopenActivation = false\n    private var suppressResetTask: Task<Void, Never>? = nil\n\n    func applicationDidFinishLaunching(_ notification: Notification) {\n      // Set activation policy based on saved preference\n      // Default to .visible (show Dock icon) unless user explicitly chose \"Menu Bar Only\"\n      let defaults = UserDefaults.standard\n      let rawVisibility = defaults.string(forKey: \"codmate.systemMenu.visibility\") ?? \"visible\"\n      let visibility = SystemMenuVisibility(rawValue: rawVisibility) ?? .visible\n\n      switch visibility {\n      case .menuOnly:\n        // Menu bar only mode - hide Dock icon\n        NSApp.setActivationPolicy(.accessory)\n      case .hidden, .visible:\n        // Show Dock icon so user can access the app\n        NSApp.setActivationPolicy(.regular)\n      }\n\n      // Start CLI Proxy Service if available\n      Task { @MainActor in\n        if CLIProxyService.shared.isBinaryInstalled {\n          do {\n            try await CLIProxyService.shared.start()\n            AppLogger.shared.info(\"CLIProxyAPI started successfully\", source: \"AppDelegate\")\n          } catch {\n            // Get detailed logs from the service\n            let serviceLogs = CLIProxyService.shared.logs\n            let recentLogs = serviceLogs.split(separator: \"\\n\").suffix(10).joined(separator: \"\\n\")\n\n            AppLogger.shared.error(\"Failed to start CLIProxyAPI: \\(error.localizedDescription)\", source: \"AppDelegate\")\n            if !recentLogs.isEmpty {\n              AppLogger.shared.error(\"Recent service logs:\\n\\(recentLogs)\", source: \"AppDelegate\")\n            }\n            CLIProxyService.shared.lastError = error.localizedDescription\n          }\n        } else {\n          AppLogger.shared.warning(\"CLIProxyAPI binary not installed, service will not start\", source: \"AppDelegate\")\n        }\n      }\n    }\n\n    func application(_ application: NSApplication, open urls: [URL]) {\n      print(\"🔗 [AppDelegate] Received URLs: \\(urls)\")\n      print(\"🪟 [AppDelegate] Current windows count: \\(application.windows.count)\")\n      print(\"🪟 [AppDelegate] Visible windows: \\(application.windows.filter { $0.isVisible }.count)\")\n      let fileURLs = urls.filter { $0.isFileURL }\n      let nonFileURLs = urls.filter { !$0.isFileURL }\n      if let directoryURL = firstDirectoryURL(in: fileURLs) {\n        handleDockFolderDrop(directoryURL)\n      }\n      if nonFileURLs.contains(where: { $0.scheme?.lowercased() == \"codmate\" && ($0.host ?? \"\").lowercased() == \"notify\" }) {\n        suppressNextReopenActivation = true\n        suppressResetTask?.cancel()\n        suppressResetTask = Task { @MainActor [weak self] in\n          try? await Task.sleep(nanoseconds: 1_000_000_000)\n          self?.suppressNextReopenActivation = false\n        }\n      }\n      if !nonFileURLs.isEmpty {\n        ExternalURLRouter.handle(nonFileURLs)\n      }\n    }\n\n    func application(_ sender: NSApplication, openFile filename: String) -> Bool {\n      handleDockFileOpenPaths([filename])\n    }\n\n    func application(_ sender: NSApplication, openFiles filenames: [String]) {\n      let handled = handleDockFileOpenPaths(filenames)\n      sender.reply(toOpenOrPrint: handled ? .success : .failure)\n    }\n\n    func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool)\n      -> Bool\n    {\n      print(\"🔄 [AppDelegate] applicationShouldHandleReopen called, hasVisibleWindows: \\(flag)\")\n      if suppressNextReopenActivation {\n        suppressNextReopenActivation = false\n        return true\n      }\n      // Delegate to MenuBarController for unified window activation logic\n      // This ensures consistent behavior between Dock clicks and menu bar actions\n      MenuBarController.shared.handleDockIconClick()\n      //  Always return true to prevent the system from creating new windows\n      //  This is particularly important for notification forwarding triggered by URL scheme (codmate://)\n      return true\n    }\n\n    func applicationWillTerminate(_ notification: Notification) {\n      // Stop CLI Proxy Service\n      CLIProxyService.shared.stop()\n\n      // Clean up Ghostty sessions\n      // Note: Ghostty manages its own cleanup via deinit\n      // No explicit session termination needed here\n    }\n\n    func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {\n      // Ghostty sessions will be cleaned up automatically\n      // No need for confirmation dialog\n      return .terminateNow\n    }\n\n    private func firstDirectoryURL(in urls: [URL]) -> URL? {\n      for url in urls {\n        guard url.isFileURL else { continue }\n        var isDirectory: ObjCBool = false\n        if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory),\n           isDirectory.boolValue {\n          return url.standardizedFileURL\n        }\n      }\n      return nil\n    }\n\n    @MainActor\n    @discardableResult\n    private func handleDockFileOpenPaths(_ paths: [String]) -> Bool {\n      let urls = paths.map { URL(fileURLWithPath: $0) }\n      guard let directoryURL = firstDirectoryURL(in: urls) else { return false }\n      handleDockFolderDrop(directoryURL)\n      return true\n    }\n\n    @MainActor\n    private func handleDockFolderDrop(_ url: URL) {\n      let directory = url.path\n      let name = url.lastPathComponent\n      guard !directory.isEmpty else { return }\n      MenuBarController.shared.handleDockIconClick()\n      NotificationCenter.default.post(name: .codMateOpenMainWindow, object: nil)\n      Task {\n        await waitForMainWindow()\n        DockOpenCoordinator.shared.enqueueNewProject(directory: directory, name: name)\n      }\n    }\n\n    @MainActor\n    private func waitForMainWindow() async {\n      if MainWindowCoordinator.shared.hasAttachedWindow { return }\n      for _ in 0..<20 {\n        try? await Task.sleep(nanoseconds: 100_000_000)\n        if MainWindowCoordinator.shared.hasAttachedWindow { return }\n      }\n    }\n  }\n#endif\n"
  },
  {
    "path": "Ghostty-header.h",
    "content": "//\n//  Ghostty-header.h\n//  CodMate\n//\n//  Bridging header to expose Ghostty C API to Swift\n//\n\n#ifndef Ghostty_header_h\n#define Ghostty_header_h\n\n// Import the main Ghostty C API\n// Note: ghostty.h already includes all necessary definitions\n// Do NOT include ghostty/vt.h as it causes duplicate enum definitions\n// NOTE: This file is excluded from Package.swift and may not be in use.\n// The correct path is now ghostty/Vendor/include/ghostty.h\n#import \"ghostty/Vendor/include/ghostty.h\"\n\n#endif /* Ghostty_header_h */\n"
  },
  {
    "path": "LICENSE",
    "content": "                                 Apache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n      Copyright 2025 Loocor\n\n      Licensed under the Apache License, Version 2.0 (the \"License\");\n      you may not use this file except in compliance with the License.\n      You may obtain a copy of the License at\n\n          http://www.apache.org/licenses/LICENSE-2.0\n\n      Unless required by applicable law or agreed to in writing, software\n      distributed under the License is distributed on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n      See the License for the specific language governing permissions and\n      limitations under the License.\n\n"
  },
  {
    "path": "Makefile",
    "content": ".PHONY: help build release test app dmg zip clean run debug debug-logs debug-app debug-file notices\n\nAPP_NAME := CodMate\nVER ?= 0.1.0\nBUILD_NUMBER_STRATEGY ?= date\nAPP_DIR ?= build/CodMate.app\nOUTPUT_DIR ?= artifacts/release\n\n# Default arch for local builds\nARCH_NATIVE := $(shell uname -m)\nARCH ?= $(ARCH_NATIVE)\n\nhelp: ## Show this help message\n\t@echo \"CodMate - macOS SwiftPM App\"\n\t@echo \"\"\n\t@echo \"Available targets:\"\n\t@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = \":.*?## \"}; {printf \"  \\033[36m%-15s\\033[0m %s\\n\", $$1, $$2}'\n\nbuild: ## SwiftPM debug build\n\t@swift build\n\nrelease: ## SwiftPM release build\n\t@swift build -c release\n\ntest: ## Run SwiftPM tests (if any)\n\t@swift test\n\nnotices: ## Update THIRD-PARTY-NOTICES.md\n\t@python3 scripts/gen-third-party-notices.py\n\napp: ## Build CodMate.app (ARCH=arm64|x86_64|\"arm64 x86_64\")\n\t@if [ -z \"$(VER)\" ]; then echo \"error: VER is required (e.g., VER=1.2.3)\"; exit 1; fi\n\t@VER=$(VER) BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$(ARCH)\" APP_DIR=$(APP_DIR) \\\n\t./scripts/create-app-bundle.sh\n\nrun: ## Build and launch CodMate.app (native arch, inferred version)\n\t@VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \\\n\tARCH_NATIVE=$$(uname -m); \\\n\tVER=\"$$VER_RUN\" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$$ARCH_NATIVE\" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \\\n\tSIGN_ADHOC=1 \\\n\t./scripts/create-app-bundle.sh; \\\n\topen \"$(APP_DIR)\"\n\ndebug: ## Build and run with terminal output (prints to stdout/stderr)\n\t@echo \"Building CodMate.app for debug...\"\n\t@VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \\\n\tARCH_NATIVE=$$(uname -m); \\\n\tVER=\"$$VER_RUN\" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$$ARCH_NATIVE\" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \\\n\tSIGN_ADHOC=1 \\\n\t./scripts/create-app-bundle.sh\n\t@echo \"\"\n\t@echo \"Starting CodMate with terminal output...\"\n\t@echo \"Press Ctrl+C to stop\"\n\t@echo \"========================================\"\n\t@\"$(APP_DIR)/Contents/MacOS/CodMate\"\n\ndebug-app: ## Build, launch app in background, and stream logs in foreground\n\t@echo \"Building and launching CodMate.app...\"\n\t@VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \\\n\tARCH_NATIVE=$$(uname -m); \\\n\tVER=\"$$VER_RUN\" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$$ARCH_NATIVE\" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \\\n\tSIGN_ADHOC=1 \\\n\t./scripts/create-app-bundle.sh\n\t@open \"$(APP_DIR)\"\n\t@sleep 1\n\t@echo \"\"\n\t@echo \"Streaming logs from CodMate (Ctrl+C to stop)...\"\n\t@echo \"========================================\"\n\t@log stream --predicate 'processImagePath CONTAINS \"CodMate\"' --style compact\n\ndebug-logs: ## Stream live logs from running CodMate app (use with 'make run' in another terminal)\n\t@echo \"Streaming logs from CodMate (Ctrl+C to stop)...\"\n\t@echo \"========================================\"\n\t@log stream --predicate 'processImagePath CONTAINS \"CodMate\"' --style compact\n\ndebug-file: ## Build and run with output to both terminal and logs/debug.log\n\t@echo \"Building CodMate.app for debug...\"\n\t@VER_RUN=$${VER:-$$(git describe --tags --abbrev=0 2>/dev/null || echo 0.0.0)}; \\\n\tARCH_NATIVE=$$(uname -m); \\\n\tVER=\"$$VER_RUN\" BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$$ARCH_NATIVE\" APP_DIR=$(APP_DIR) STRIP=0 SWIFT_CONFIG=debug \\\n\tSIGN_ADHOC=1 \\\n\t./scripts/create-app-bundle.sh\n\t@mkdir -p logs\n\t@echo \"\"\n\t@echo \"Starting CodMate with output to terminal and file...\"\n\t@echo \"Log file: logs/debug.log\"\n\t@echo \"Press Ctrl+C to stop\"\n\t@echo \"========================================\"\n\t@\"$(APP_DIR)/Contents/MacOS/CodMate\" 2>&1 | tee logs/debug.log\n\ndmg: ## Build Developer ID DMG (ARCH=arm64|x86_64|\"arm64 x86_64\")\n\t@if [ -z \"$(VER)\" ]; then echo \"error: VER is required (e.g., VER=1.2.3)\"; exit 1; fi\n\t@VER=$(VER) BUILD_NUMBER_STRATEGY=$(BUILD_NUMBER_STRATEGY) \\\n\tARCH_MATRIX=\"$(ARCH)\" APP_DIR=$(APP_DIR) OUTPUT_DIR=$(OUTPUT_DIR) \\\n\t./scripts/macos-build-notarized-dmg.sh\n\nzip: ## Create zip archives from DMG files (one zip per arch, requires dmg first, VER=1.2.3)\n\t@if [ -z \"$(VER)\" ]; then echo \"error: VER is required (e.g., VER=1.2.3)\"; exit 1; fi\n\t@if [ ! -d \"$(OUTPUT_DIR)\" ]; then echo \"error: OUTPUT_DIR $(OUTPUT_DIR) does not exist. Run 'make dmg' first.\"; exit 1; fi\n\t@DMG_FILES=$$(find \"$(OUTPUT_DIR)\" -name \"codmate-*.dmg\" 2>/dev/null | sort); \\\n\tif [ -z \"$$DMG_FILES\" ]; then \\\n\t\techo \"error: No DMG files found in $(OUTPUT_DIR). Run 'make dmg' first.\"; \\\n\t\texit 1; \\\n\tfi; \\\n\techo \"Creating zip archives from DMG files...\"; \\\n\tcd \"$(OUTPUT_DIR)\" && \\\n\tfor dmg_file in $$DMG_FILES; do \\\n\t\tdmg_basename=$$(basename \"$$dmg_file\" .dmg); \\\n\t\tzip_name=\"$$dmg_basename.zip\"; \\\n\t\techo \"  Creating: $$zip_name\"; \\\n\t\tzip -q \"$$zip_name\" \"$$dmg_basename.dmg\"; \\\n\tdone; \\\n\techo \"Zip archives created in $(OUTPUT_DIR)\"\n\nclean: ## Clean build artifacts\n\t@rm -rf .build build $(APP_DIR) artifacts\n"
  },
  {
    "path": "NOTICE",
    "content": "CodMate\nCopyright (c) 2025 Loocor\n"
  },
  {
    "path": "Package.resolved",
    "content": "{\n  \"originHash\" : \"06ecd22962877ed07b043a2d3eeba48fbdcada27c6982dc3012cc48805daf65f\",\n  \"pins\" : [\n    {\n      \"identity\" : \"eventsource\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/mattt/eventsource.git\",\n      \"state\" : {\n        \"revision\" : \"a2965424a4babeb0c8e4b5ec9708c3939bc52449\",\n        \"version\" : \"1.2.0\"\n      }\n    },\n    {\n      \"identity\" : \"swift-log\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/apple/swift-log.git\",\n      \"state\" : {\n        \"revision\" : \"ce592ae52f982c847a4efc0dd881cc9eb32d29f2\",\n        \"version\" : \"1.6.4\"\n      }\n    },\n    {\n      \"identity\" : \"swift-sdk\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/modelcontextprotocol/swift-sdk.git\",\n      \"state\" : {\n        \"revision\" : \"c0407a0b52677cb395d824cac2879b963075ba8c\",\n        \"version\" : \"0.10.2\"\n      }\n    },\n    {\n      \"identity\" : \"swift-system\",\n      \"kind\" : \"remoteSourceControl\",\n      \"location\" : \"https://github.com/apple/swift-system\",\n      \"state\" : {\n        \"revision\" : \"395a77f0aa927f0ff73941d7ac35f2b46d47c9db\",\n        \"version\" : \"1.6.3\"\n      }\n    }\n  ],\n  \"version\" : 3\n}\n"
  },
  {
    "path": "Package.swift",
    "content": "// swift-tools-version: 6.0\n\nimport PackageDescription\n\nlet package = Package(\n  name: \"CodMate\",\n  defaultLocalization: \"en\",\n  platforms: [.macOS(.v13)],\n  products: [\n    .executable(\n      name: \"CodMate\",\n      targets: [\"CodMate\"]\n    ),\n    .executable(\n      name: \"notify\",\n      targets: [\"notify\"]\n    ),\n  ],\n  dependencies: [\n    // Ghostty GPU-accelerated terminal\n    .package(path: \"ghostty\"),\n    // MCP Swift SDK for real MCP client connections\n    .package(url: \"https://github.com/modelcontextprotocol/swift-sdk.git\", from: \"0.10.2\"),\n  ],\n  targets: [\n    .executableTarget(\n      name: \"CodMate\",\n      dependencies: [\n        .product(name: \"GhosttyKit\", package: \"ghostty\"),\n        .product(name: \"MCP\", package: \"swift-sdk\"),\n      ],\n      path: \".\",\n      exclude: [\n        \"ghostty\",\n        \"notify\",\n        \"build\",\n        \".build\",\n        \"scripts\",\n        \"docs\",\n        \"payload\",\n        \"Tests\",\n        \"AGENTS.md\",\n        \"LICENSE\",\n        \"NOTICE\",\n        \"README.md\",\n        \"THIRD-PARTY-NOTICES.md\",\n        \"Makefile\",\n        \"screenshot.png\",\n        \"PrivacyInfo.xcprivacy\",\n        \"assets/Assets.xcassets\",\n        \"assets/Info.plist\",\n        \"assets/CodMate.entitlements\",\n        \"assets/CodMate-Notify.entitlements\",\n        \"Ghostty-header.h\",\n      ],\n      sources: [\n        \"CodMateApp.swift\",\n        \"models\",\n        \"services\",\n        \"utils\",\n        \"views\",\n      ]\n    ),\n    .executableTarget(\n      name: \"notify\",\n      path: \"notify\",\n      sources: [\"NotifyMain.swift\"]\n    ),\n    .testTarget(\n      name: \"CodMateTests\",\n      dependencies: [\"CodMate\"]\n    ),\n  ],\n  swiftLanguageModes: [.v5]\n)\n"
  },
  {
    "path": "PrivacyInfo.xcprivacy",
    "content": "{\n  \"NSPrivacyTracking\": false,\n  \"NSPrivacyTrackingDomains\": [],\n  \"NSPrivacyCollectedDataTypes\": [],\n  \"NSPrivacyAccessedAPITypes\": []\n}\n\n"
  },
  {
    "path": "README.md",
    "content": "# CodMate\n\n![CodMate Screenshot](screenshot.png)\n\nCodMate 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**.\n\nIt 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**.\n\nStatus: **macOS 13.5+**, **Swift 6 toolchain**. Universal binary (arm64 + x86_64).\n\n## Project status (Archival note)\nI plan to **archive CodMate** after this update. Here is the reasoning that led me to this decision:\n\n- **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.\n- **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.\n- **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.\n- **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.\n- **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.\n\nThis 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.\n\n## Where this exploration continues\n\nWhile 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).\n\nMCPMate 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.\n\nAt 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.\n\nSo 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.\n\n## Download\n- **Latest release (DMG)**: [GitHub Releases](https://github.com/loocor/CodMate/releases/latest)\n\n## Why CodMate\n- **Find anything fast**: a global search panel (⌘F) with scoped search + progress/cancel, plus quick list filtering.\n- **Keep work organized**: Projects + Tasks let you group sessions by repo and by goal, with a shareable task context file.\n- **Continue instantly**: Resume/New into Terminal/iTerm2/Warp (or embedded terminal in non-sandbox builds), with copyable exact commands.\n- **Review & commit without leaving the app**: Project Review shows diffs, staging state, and supports commit (with optional AI commit message generation).\n\n## Features (organized by value)\n\n### Organize and understand sessions across CLIs\n- **Multi-source session browsing**:\n  - **Codex**: `~/.codex/sessions` (`.jsonl`)\n  - **Claude Code**: `~/.claude/projects` (`.jsonl`)\n  - **Gemini CLI**: `~/.gemini/tmp` (Gemini’s session storage)\n- **Sidebar navigation**:\n  - **Projects** list with counts, including “All” and an **unassigned/Other** bucket.\n  - **Calendar** (pinned bottom) with per-day counts and a Created/Updated toggle.\n  - Directory-based navigation built from session `cwd` statistics.\n- **Session list**:\n  - Default scope is **Today** for fast first paint.\n  - Sorting: Most Recent (created/updated aware), Duration, Activity, etc.\n  - Rows show title, timestamps/duration, snippet, and compact metrics (user/assistant/tool/reasoning), plus states like running/updating/awaiting follow-up.\n\n### Projects + Tasks (workspaces instead of loose logs)\n- **Projects**:\n  - Create/edit projects (name, directory, overview, trust level, optional runtime profile).\n  - Assign sessions to projects via row actions/context menus.\n  - Storage: `~/.codmate/projects/` (project metadata + memberships mapping).\n  - “New” sessions started inside a project can be **auto-assigned** to that project.\n- **Tasks (within projects)**:\n  - Create/edit/delete tasks, collapse/expand task groups, and assign/move sessions into tasks.\n  - **Task context sync**: generates/updates a shareable context file and prepares a prompt pointing to it.\n  - Storage: `~/.codmate/tasks/` (task metadata + relationships mapping).\n\n### Resume/New (local, remote, embedded)\n- **Resume**:\n  - Launch in **Terminal.app / iTerm2 / Warp**, or **embedded terminal** (non-App Store / non-sandbox builds).\n  - When embedded is active, CodMate can show a **Copy real command** action for reproducibility.\n- **New**:\n  - Start a fresh session from the focused session’s `cwd` (and project profile when available).\n  - Start sessions directly from a selected project, even without a focused session.\n- **Remote Hosts (SSH mirroring)**:\n  - Enable hosts from `~/.ssh/config`, then mirror remote sessions over SSH.\n  - Remote bases:\n    - Codex remote: `$HOME/.codex/sessions`\n    - Claude remote: `$HOME/.claude/projects`\n  - Mirror cache is stored under `~/Library/Caches/CodMate/remote/`.\n\n### Search, export, and metadata (make history useful)\n- **Global Search (⌘F)**:\n  - Floating window or toolbar popover style (configurable).\n  - Scope picker + progress/cancel for long searches.\n- **Rename/comment**:\n  - Click the session title in the detail pane to edit title/comment.\n  - Storage: `~/.codmate/notes/<sessionId-sanitized>.json` (with automatic migration from legacy locations).\n- **Conversation export**:\n  - Export Markdown from the detail pane.\n  - Settings allow choosing which message types appear in the timeline and which are included in Markdown export.\n\n### Project Review (Git Changes) + AI commit message generation\n- **Git Changes surface** (Project Review mode):\n  - Lists changed files, supports **stage/unstage**, and shows **unified diff** or raw preview.\n  - Commit UI with message editor and **Commit** action.\n  - Optional **AI generate commit message** (uses your selected Provider/Model and a prompt template from settings).\n  - Repo authorization is **on-demand** (especially relevant in sandboxed builds).\n- **Settings › Git Review**:\n  - Diff options (line numbers, soft wrap).\n  - Commit generation: choose Provider/Model and an optional prompt template.\n\n### Providers, MCP, notifications, diagnostics (make the ecosystem manageable)\n- **Providers (Settings › Providers)**:\n  - Add/edit providers with Codex + Claude endpoints, shared API key env var, wire API (Chat/Responses), and model catalog with capability flags.\n  - Built-in templates are bundled from `payload/providers.json`; user registry is stored at `~/.codmate/providers.json`.\n  - Built-in health check: **Test** endpoints before saving.\n- **MCP Servers (Settings › MCP Server)**:\n  - Uni-Import (paste/drag JSON), per-server enable toggle, per-target toggles (Codex/Claude/Gemini), and connectivity **Test**.\n  - Storage: `~/.codmate/mcp-servers.json`\n  - Exports enabled servers into `~/.claude/settings.json` (and writes a helper file `~/.codmate/mcp-enabled-claude.json`).\n- **Claude Code notifications (Settings › Claude Code › Notifications)**:\n  - Installs/removes hooks that forward permission/completion events via `codmate://notify` and provides a self-test.\n- **Dialectics (Settings › Dialectics)**:\n  - Deep diagnostics for session roots, notes/projects dirs, environment, and ripgrep indexes.\n  - One-click “Save Report…” plus rebuild actions for coverage/session index.\n\n## Keyboard shortcuts\n- **⌘,**: Settings\n- **⌘F**: Global Search\n- **⌘R**: Refresh (also recomputes global sidebar statistics)\n- **⌘1**: Toggle sidebar\n- **⌘2**: Toggle session list\n\n## Data locations (quick reference)\n- **Codex sessions**: `~/.codex/sessions`\n- **Claude sessions**: `~/.claude/projects`\n- **Gemini sessions**: `~/.gemini/tmp`\n- **Notes**: `~/.codmate/notes/`\n- **Projects**: `~/.codmate/projects/`\n- **Tasks**: `~/.codmate/tasks/`\n- **Providers registry**: `~/.codmate/providers.json`\n- **MCP servers**: `~/.codmate/mcp-servers.json`\n- **Session index cache (SQLite)**: `~/.codmate/sessionIndex-v4.db`\n- **Additional caches**: `~/Library/Caches/CodMate/` (includes remote mirrors and best-effort caches)\n\n## Performance\n- Fast path indexing: memory‑mapped reads; parse the first ~64 lines plus tail sampling (up to ~1 MB) to fix `lastUpdatedAt`.\n- Background enrichment: full parse in constrained task groups; batched UI updates.\n- Full‑text search: chunked scan (128 KB), case‑insensitive; avoids lowercasing the whole file.\n- Caching: persistent SQLite index + best-effort caches to keep subsequent launches fast.\n- Sidebar statistics are global and decoupled from the list scope to keep navigation snappy.\n\n## Architecture\n- App: macOS SwiftUI (min macOS 13.5). SwiftPM-only build.\n- MVVM layering\n  - Models: `SessionSummary`, `SessionEvent`, `DateDimension`, `SessionLoadScope`, …\n  - Services: `SessionIndexer`, `SessionCacheStore`, `SessionActions`, `SessionTimelineLoader`, `CodexConfigService`, `SessionsDiagnosticsService`\n  - ViewModel: `SessionListViewModel`\n  - Views: SwiftUI only (no business logic)\n- Concurrency & IO\n  - Services that share caches are `actor`s; UI updates on MainActor only.\n  - Cancel previous tasks on search/scope changes; guard `Task.isCancelled` in loops.\n  - File IO prefers `Data(mappedIfSafe:)` and chunked reads; avoids loading huge files into Strings.\n\n## Build\nPrerequisites\n- macOS 13.5+, Swift 6 toolchain, Xcode Command Line Tools (for `xcrun actool`).\n- Install the CLIs you use (Codex / Claude / Gemini) somewhere on your `PATH`.\n\nMakefile (recommended)\n```sh\nmake build   # SwiftPM debug build\nmake test    # SwiftPM tests (if any)\nmake run     # Build (debug, native arch) and launch for local testing\nmake app VER=1.2.3     # Create CodMate.app in build/ (ARCH defaults to host)\nmake app VER=1.2.3 ARCH=arm64\nmake app VER=1.2.3 ARCH=x86_64\nmake app VER=1.2.3 ARCH=\"arm64 x86_64\"\nmake dmg VER=1.2.3     # Create Developer ID DMG (ARCH defaults to host)\nmake dmg VER=1.2.3 ARCH=arm64\nmake dmg VER=1.2.3 ARCH=x86_64\nmake dmg VER=1.2.3 ARCH=\"arm64 x86_64\"  # produces codmate-arm64.dmg + codmate-x86_64.dmg\nmake notices # Regenerate THIRD-PARTY-NOTICES.md\n```\n\nDirect scripts\n```sh\nVER=1.2.3 ./scripts/create-app-bundle.sh\nVER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n```\n\n### Versioning strategy (build script)\n- Marketing version (CFBundleShortVersionString): set with `VER` (e.g., `1.4.0`).\n- Build number (CFBundleVersion): controlled by `BUILD_NUMBER_STRATEGY`:\n  - `date` (default): `yyyymmddHHMM` (e.g., `202510291430`).\n  - `git`: `git rev-list --count HEAD`.\n  - `counter`: monotonically increments a file counter at `$BUILD_DIR/build-number` (override path via `BUILD_COUNTER_FILE`).\n- DMG name: `codmate-<ARCH>.dmg`.\n- Override via environment variables when running the build script:\n```sh\nVER=1.4.0 BUILD_NUMBER_STRATEGY=date \\\n  ./scripts/macos-build-notarized-dmg.sh\n```\nThis sets CFBundleShortVersionString to `1.4.0`, CFBundleVersion to the computed build number, and names the DMG accordingly.\n\n## CLI Integration (Codex / Claude / Gemini)\n- Executable resolution: CodMate launches CLIs via `/usr/bin/env codex` (and `claude` / `gemini`) to respect your system `PATH` (no user-configurable CLI path).\n- PATH robustness: before launching, CodMate ensures `PATH` includes `/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin`.\n- Resume:\n  - Uses the original session `cwd` when it exists; otherwise falls back to the log file directory.\n  - Can launch into Terminal.app / iTerm2 / Warp, and (in non-sandbox builds) an embedded terminal.\n  - When embedded is active, CodMate can copy the **exact** invocation it used (e.g. `codex resume <id>`).\n- New:\n  - Starts a fresh session in the focused session’s working directory (or the selected project directory).\n  - When a Project Profile is present, its model/sandbox/approval defaults are applied when generating commands.\n- Command flags exposed by the UI:\n  - Codex: sandbox policy (`-s/--sandbox`), approval policy (`-a/--ask-for-approval`), `--full-auto`, `--dangerously-bypass-approvals-and-sandbox`.\n  - Claude: common runtime/permission flags plus MCP strict mode (see Settings › Claude Code and Settings › Command).\n- MCP integration:\n  - CodMate can export enabled MCP servers into `~/.claude/settings.json` and also writes `~/.codmate/mcp-enabled-claude.json` for explicit `--mcp-config` usage.\n\n## Project Layout\n```\nassets/                     # Assets and Info.plist (not in Copy Bundle Resources)\nCodMateApp.swift            # App entry point\nmodels/                     # Data models (pure types)\nservices/                   # IO + indexing + integrations\nutils/                      # Helpers (shell, sandbox, formatting, etc.)\nviews/                      # SwiftUI views\npayload/                    # Bundled presets (e.g. providers.json templates)\nnotify/                     # Swift command-line helper installed as `codmate-notify`\nSwiftTerm/                  # Embedded terminal dependency (local package)\nscripts/                    # Helper scripts (icons, build flows)\ndocs/                       # Design notes and investigation docs\n```\n\n## Known Pitfalls\n- Prefer a toolbar search field (far‑right aligned) over `.searchable` to avoid hijacking toolbar slots on macOS.\n- Outline row height needs explicit tightening (see `defaultMinListRowHeight` and insets in the row views).\n\n## Development Tips\n- Run tests: `swift test`.\n- Formatting: follow existing code style; keep changes minimal and focused.\n- Performance: measure large trees; first paint should be fast; enrichment is incremental.\n\n## License\n- Apache License 2.0. See `LICENSE` for full text.\n- `NOTICE` includes project attribution. SPDX: `Apache-2.0`.\n- Third-party attributions and license texts: see `THIRD-PARTY-NOTICES.md`.\n"
  },
  {
    "path": "THIRD-PARTY-NOTICES.md",
    "content": "Third-Party Notices\n\nThis document lists third-party components included in CodMate distributions, along with their licenses and attributions. The original license texts are reproduced or referenced below.\n\nIf you distribute CodMate binaries, keep this file together with `LICENSE`.\n\n---\n\nAizen (Ghostty embedding implementation reference)\nRepository: https://github.com/vivy-company/aizen\nLicense: GNU General Public License v3.0\n\nThe 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.\n\nCopyright (C) 2025 Vivy Technologies Co., Limited\n\nThis 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.\n\nFor the full GPL-3.0 license text, see: https://www.gnu.org/licenses/gpl-3.0.html\n\n---\n\neventsource (1.2.0)\nRepository: https://github.com/mattt/eventsource.git\nLicense file: LICENSE.md\n\nCopyright 2025 Mattt (https://mat.tt)\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of this software and associated documentation files (the \"Software\"),\nto deal in the Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, sublicense,\nand/or sell copies of the Software, and to permit persons to whom the\nSoftware is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS\nOR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING\nFROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n\n---\n\nGhostty\nRepository: https://github.com/ghostty-org/ghostty\nLicense: MIT License\n\nCodMate 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.\n\nCopyright (c) 2022-2025 Ghostty contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n---\n\nswift-argument-parser (1.6.2)\nRepository: https://github.com/apple/swift-argument-parser\nLicense file: LICENSE.txt\n\nApache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n    1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n    2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n    3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n    4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n    5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n    6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n    7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n    8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n    9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n    END OF TERMS AND CONDITIONS\n\n    APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n    Copyright [yyyy] [name of copyright owner]\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n\n\n## Runtime Library Exception to the Apache 2.0 License: ##\n\n\n    As an exception, if you use this Software to compile your source code and\n    portions of this Software are embedded into the binary product as a result,\n    you may redistribute such product without providing attribution as would\n    otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.\n\n---\n\nswift-log (1.6.4)\nRepository: https://github.com/apple/swift-log.git\nLicense file: LICENSE.txt\n\nApache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\nNOTICE (NOTICE.txt)\nThe SwiftLog Project\n                            ========================\n\nPlease visit the SwiftLog web site for more information:\n\n  * https://github.com/apple/swift-log\n\nCopyright 2018, 2019 The SwiftLog Project\n\nThe SwiftLog Project licenses this file to you under the Apache License,\nversion 2.0 (the \"License\"); you may not use this file except in compliance\nwith the License. You may obtain a copy of the License at:\n\n  https://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT\nWARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the\nLicense for the specific language governing permissions and limitations\nunder the License.\n\nAlso, please refer to each LICENSE.<component>.txt file, which is located in\nthe 'license' directory of the distribution file, for the license terms of the\ncomponents that this product depends on.\n\n---\n\nswift-sdk (0.10.2)\nRepository: https://github.com/modelcontextprotocol/swift-sdk.git\nLicense file: LICENSE\n\nMIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n---\n\nswift-subprocess (main@00b0496)\nRepository: https://github.com/swiftlang/swift-subprocess\nLicense file: LICENSE\n\nApache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n   1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n   2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n   3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n   4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n   5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n   6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n   7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n   8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n   9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n   END OF TERMS AND CONDITIONS\n\n   APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n   Copyright [yyyy] [name of copyright owner]\n\n   Licensed under the Apache License, Version 2.0 (the \"License\");\n   you may not use this file except in compliance with the License.\n   You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n   Unless required by applicable law or agreed to in writing, software\n   distributed under the License is distributed on an \"AS IS\" BASIS,\n   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n   See the License for the specific language governing permissions and\n   limitations under the License.\n\n---\n\nswift-system (1.6.3)\nRepository: https://github.com/apple/swift-system\nLicense file: LICENSE.txt\n\nApache License\n                           Version 2.0, January 2004\n                        http://www.apache.org/licenses/\n\n    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n    1. Definitions.\n\n      \"License\" shall mean the terms and conditions for use, reproduction,\n      and distribution as defined by Sections 1 through 9 of this document.\n\n      \"Licensor\" shall mean the copyright owner or entity authorized by\n      the copyright owner that is granting the License.\n\n      \"Legal Entity\" shall mean the union of the acting entity and all\n      other entities that control, are controlled by, or are under common\n      control with that entity. For the purposes of this definition,\n      \"control\" means (i) the power, direct or indirect, to cause the\n      direction or management of such entity, whether by contract or\n      otherwise, or (ii) ownership of fifty percent (50%) or more of the\n      outstanding shares, or (iii) beneficial ownership of such entity.\n\n      \"You\" (or \"Your\") shall mean an individual or Legal Entity\n      exercising permissions granted by this License.\n\n      \"Source\" form shall mean the preferred form for making modifications,\n      including but not limited to software source code, documentation\n      source, and configuration files.\n\n      \"Object\" form shall mean any form resulting from mechanical\n      transformation or translation of a Source form, including but\n      not limited to compiled object code, generated documentation,\n      and conversions to other media types.\n\n      \"Work\" shall mean the work of authorship, whether in Source or\n      Object form, made available under the License, as indicated by a\n      copyright notice that is included in or attached to the work\n      (an example is provided in the Appendix below).\n\n      \"Derivative Works\" shall mean any work, whether in Source or Object\n      form, that is based on (or derived from) the Work and for which the\n      editorial revisions, annotations, elaborations, or other modifications\n      represent, as a whole, an original work of authorship. For the purposes\n      of this License, Derivative Works shall not include works that remain\n      separable from, or merely link (or bind by name) to the interfaces of,\n      the Work and Derivative Works thereof.\n\n      \"Contribution\" shall mean any work of authorship, including\n      the original version of the Work and any modifications or additions\n      to that Work or Derivative Works thereof, that is intentionally\n      submitted to Licensor for inclusion in the Work by the copyright owner\n      or by an individual or Legal Entity authorized to submit on behalf of\n      the copyright owner. For the purposes of this definition, \"submitted\"\n      means any form of electronic, verbal, or written communication sent\n      to the Licensor or its representatives, including but not limited to\n      communication on electronic mailing lists, source code control systems,\n      and issue tracking systems that are managed by, or on behalf of, the\n      Licensor for the purpose of discussing and improving the Work, but\n      excluding communication that is conspicuously marked or otherwise\n      designated in writing by the copyright owner as \"Not a Contribution.\"\n\n      \"Contributor\" shall mean Licensor and any individual or Legal Entity\n      on behalf of whom a Contribution has been received by Licensor and\n      subsequently incorporated within the Work.\n\n    2. Grant of Copyright License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      copyright license to reproduce, prepare Derivative Works of,\n      publicly display, publicly perform, sublicense, and distribute the\n      Work and such Derivative Works in Source or Object form.\n\n    3. Grant of Patent License. Subject to the terms and conditions of\n      this License, each Contributor hereby grants to You a perpetual,\n      worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n      (except as stated in this section) patent license to make, have made,\n      use, offer to sell, sell, import, and otherwise transfer the Work,\n      where such license applies only to those patent claims licensable\n      by such Contributor that are necessarily infringed by their\n      Contribution(s) alone or by combination of their Contribution(s)\n      with the Work to which such Contribution(s) was submitted. If You\n      institute patent litigation against any entity (including a\n      cross-claim or counterclaim in a lawsuit) alleging that the Work\n      or a Contribution incorporated within the Work constitutes direct\n      or contributory patent infringement, then any patent licenses\n      granted to You under this License for that Work shall terminate\n      as of the date such litigation is filed.\n\n    4. Redistribution. You may reproduce and distribute copies of the\n      Work or Derivative Works thereof in any medium, with or without\n      modifications, and in Source or Object form, provided that You\n      meet the following conditions:\n\n      (a) You must give any other recipients of the Work or\n          Derivative Works a copy of this License; and\n\n      (b) You must cause any modified files to carry prominent notices\n          stating that You changed the files; and\n\n      (c) You must retain, in the Source form of any Derivative Works\n          that You distribute, all copyright, patent, trademark, and\n          attribution notices from the Source form of the Work,\n          excluding those notices that do not pertain to any part of\n          the Derivative Works; and\n\n      (d) If the Work includes a \"NOTICE\" text file as part of its\n          distribution, then any Derivative Works that You distribute must\n          include a readable copy of the attribution notices contained\n          within such NOTICE file, excluding those notices that do not\n          pertain to any part of the Derivative Works, in at least one\n          of the following places: within a NOTICE text file distributed\n          as part of the Derivative Works; within the Source form or\n          documentation, if provided along with the Derivative Works; or,\n          within a display generated by the Derivative Works, if and\n          wherever such third-party notices normally appear. The contents\n          of the NOTICE file are for informational purposes only and\n          do not modify the License. You may add Your own attribution\n          notices within Derivative Works that You distribute, alongside\n          or as an addendum to the NOTICE text from the Work, provided\n          that such additional attribution notices cannot be construed\n          as modifying the License.\n\n      You may add Your own copyright statement to Your modifications and\n      may provide additional or different license terms and conditions\n      for use, reproduction, or distribution of Your modifications, or\n      for any such Derivative Works as a whole, provided Your use,\n      reproduction, and distribution of the Work otherwise complies with\n      the conditions stated in this License.\n\n    5. Submission of Contributions. Unless You explicitly state otherwise,\n      any Contribution intentionally submitted for inclusion in the Work\n      by You to the Licensor shall be under the terms and conditions of\n      this License, without any additional terms or conditions.\n      Notwithstanding the above, nothing herein shall supersede or modify\n      the terms of any separate license agreement you may have executed\n      with Licensor regarding such Contributions.\n\n    6. Trademarks. This License does not grant permission to use the trade\n      names, trademarks, service marks, or product names of the Licensor,\n      except as required for reasonable and customary use in describing the\n      origin of the Work and reproducing the content of the NOTICE file.\n\n    7. Disclaimer of Warranty. Unless required by applicable law or\n      agreed to in writing, Licensor provides the Work (and each\n      Contributor provides its Contributions) on an \"AS IS\" BASIS,\n      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n      implied, including, without limitation, any warranties or conditions\n      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n      PARTICULAR PURPOSE. You are solely responsible for determining the\n      appropriateness of using or redistributing the Work and assume any\n      risks associated with Your exercise of permissions under this License.\n\n    8. Limitation of Liability. In no event and under no legal theory,\n      whether in tort (including negligence), contract, or otherwise,\n      unless required by applicable law (such as deliberate and grossly\n      negligent acts) or agreed to in writing, shall any Contributor be\n      liable to You for damages, including any direct, indirect, special,\n      incidental, or consequential damages of any character arising as a\n      result of this License or out of the use or inability to use the\n      Work (including but not limited to damages for loss of goodwill,\n      work stoppage, computer failure or malfunction, or any and all\n      other commercial damages or losses), even if such Contributor\n      has been advised of the possibility of such damages.\n\n    9. Accepting Warranty or Additional Liability. While redistributing\n      the Work or Derivative Works thereof, You may choose to offer,\n      and charge a fee for, acceptance of support, warranty, indemnity,\n      or other liability obligations and/or rights consistent with this\n      License. However, in accepting such obligations, You may act only\n      on Your own behalf and on Your sole responsibility, not on behalf\n      of any other Contributor, and only if You agree to indemnify,\n      defend, and hold each Contributor harmless for any liability\n      incurred by, or claims asserted against, such Contributor by reason\n      of your accepting any such warranty or additional liability.\n\n    END OF TERMS AND CONDITIONS\n\n    APPENDIX: How to apply the Apache License to your work.\n\n      To apply the Apache License to your work, attach the following\n      boilerplate notice, with the fields enclosed by brackets \"[]\"\n      replaced with your own identifying information. (Don't include\n      the brackets!)  The text should be enclosed in the appropriate\n      comment syntax for the file format. We also recommend that a\n      file or class name and description of purpose be included on the\n      same \"printed page\" as the copyright notice for easier\n      identification within third-party archives.\n\n    Copyright [yyyy] [name of copyright owner]\n\n    Licensed under the Apache License, Version 2.0 (the \"License\");\n    you may not use this file except in compliance with the License.\n    You may obtain a copy of the License at\n\n       http://www.apache.org/licenses/LICENSE-2.0\n\n    Unless required by applicable law or agreed to in writing, software\n    distributed under the License is distributed on an \"AS IS\" BASIS,\n    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n    See the License for the specific language governing permissions and\n    limitations under the License.\n\n\n\n## Runtime Library Exception to the Apache 2.0 License: ##\n\n\n    As an exception, if you use this Software to compile your source code and\n    portions of this Software are embedded into the binary product as a result,\n    you may redistribute such product without providing attribution as would\n    otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.\n\n---\n\n-\n\nThis product contains a derivation of the Tony Stone's 'process_test_files.rb'.\n\n  * LICENSE (Apache License 2.0):\n    * https://www.apache.org/licenses/LICENSE-2.0\n  * HOMEPAGE:\n    * https://codegists.com/snippet/ruby/generate_xctest_linux_runnerrb_tonystone_ruby\n\n---\n\nThis product contains a derivation of the lock implementation and various\nscripts from SwiftNIO.\n\n  * LICENSE (Apache License 2.0):\n    * https://www.apache.org/licenses/LICENSE-2.0\n  * HOMEPAGE:\n    * https://github.com/apple/swift-nio"
  },
  {
    "path": "Tests/CodMateTests/ClaudeHooksAdapterTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class ClaudeHooksAdapterTests: XCTestCase {\n  func testApplyHooksWritesClaudeSettingsHooks() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-claude-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let settingsURL = tmp.appendingPathComponent(\"settings.json\")\n\n    let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL)\n    let service = ClaudeSettingsService(fileManager: fm, paths: paths)\n    let rule = HookRule(\n      name: \"PreToolUse · Write\",\n      event: \"PreToolUse\",\n      matcher: \"Write|Edit\",\n      commands: [HookCommand(command: \"/usr/bin/echo\", args: [\"ok\"], timeoutMs: 30_000)],\n      enabled: true,\n      targets: HookTargets(codex: false, claude: true, gemini: false)\n    )\n\n    let warnings = try await service.applyHooksFromCodMate([rule])\n    XCTAssertTrue(warnings.isEmpty)\n\n    let data = try Data(contentsOf: settingsURL)\n    let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]\n    let hooks = obj?[\"hooks\"] as? [String: Any]\n    let pre = hooks?[\"PreToolUse\"] as? [[String: Any]]\n    XCTAssertEqual(pre?.count, 1)\n    XCTAssertEqual(pre?.first?[\"matcher\"] as? String, \"Write|Edit\")\n    let nested = pre?.first?[\"hooks\"] as? [[String: Any]]\n    XCTAssertEqual(nested?.count, 1)\n    XCTAssertEqual(nested?.first?[\"type\"] as? String, \"command\")\n    XCTAssertEqual(nested?.first?[\"command\"] as? String, \"/usr/bin/echo\")\n    XCTAssertEqual(nested?.first?[\"timeout\"] as? Int, 30_000)\n    XCTAssertNotNil(nested?.first?[\"name\"] as? String)\n  }\n\n  func testAllowManagedHooksOnlySkipsApply() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-claude-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let settingsURL = tmp.appendingPathComponent(\"settings.json\")\n    let initial = #\"{\"allowManagedHooksOnly\":true}\"#\n    try initial.write(to: settingsURL, atomically: true, encoding: .utf8)\n\n    let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL)\n    let service = ClaudeSettingsService(fileManager: fm, paths: paths)\n    let rule = HookRule(name: \"Stop\", event: \"Stop\", commands: [HookCommand(command: \"/bin/echo\")], enabled: true, targets: HookTargets(codex: false, claude: true, gemini: false))\n\n    let warnings = try await service.applyHooksFromCodMate([rule])\n    XCTAssertEqual(warnings.count, 1)\n    let text = try String(contentsOf: settingsURL, encoding: .utf8)\n    XCTAssertTrue(text.contains(\"allowManagedHooksOnly\"))\n    XCTAssertFalse(text.contains(\"codmate-hook:\"))\n  }\n\n  func testApplyPrunesPreviouslyManagedHooks() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-claude-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let settingsURL = tmp.appendingPathComponent(\"settings.json\")\n    let paths = ClaudeSettingsService.Paths(dir: tmp, file: settingsURL)\n    let service = ClaudeSettingsService(fileManager: fm, paths: paths)\n\n    let rule = HookRule(\n      name: \"Stop\",\n      event: \"Stop\",\n      commands: [HookCommand(command: \"/usr/bin/echo\")],\n      enabled: true,\n      targets: HookTargets(codex: false, claude: true, gemini: false)\n    )\n    _ = try await service.applyHooksFromCodMate([rule])\n    _ = try await service.applyHooksFromCodMate([rule])\n\n    let data = try Data(contentsOf: settingsURL)\n    let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]\n    let hooks = obj?[\"hooks\"] as? [String: Any]\n    let stop = hooks?[\"Stop\"] as? [[String: Any]]\n    let nested = stop?.first?[\"hooks\"] as? [[String: Any]]\n    let managed = (nested ?? []).filter { ($0[\"name\"] as? String)?.hasPrefix(\"codmate-hook:\") == true }\n    XCTAssertEqual(managed.count, 1)\n  }\n}\n\n"
  },
  {
    "path": "Tests/CodMateTests/CodexHooksAdapterTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class CodexHooksAdapterTests: XCTestCase {\n  func testApplySingleStopHookWritesNotifyArray() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-codex-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    let home = tmp.appendingPathComponent(\".codex\", isDirectory: true)\n    try fm.createDirectory(at: home, withIntermediateDirectories: true)\n    let configURL = home.appendingPathComponent(\"config.toml\")\n    try \"\".write(to: configURL, atomically: true, encoding: .utf8)\n\n    let service = CodexConfigService(paths: .init(home: home, configURL: configURL), fileManager: fm)\n    let rule = HookRule(\n      name: \"Stop · echo\",\n      event: \"Stop\",\n      commands: [HookCommand(command: \"/usr/bin/echo\", args: [\"hello\"])],\n      enabled: true,\n      targets: HookTargets(codex: true, claude: false, gemini: false)\n    )\n    let warnings = try await service.applyHooksFromCodMate([rule])\n    XCTAssertTrue(warnings.isEmpty)\n\n    let text = try String(contentsOf: configURL, encoding: .utf8)\n    XCTAssertTrue(text.contains(\"notify = [\\\"/usr/bin/echo\\\", \\\"hello\\\"]\"))\n  }\n\n  func testApplyMultipleCodexRulesDoesNotOverwriteExistingNotify() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-codex-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    let home = tmp.appendingPathComponent(\".codex\", isDirectory: true)\n    try fm.createDirectory(at: home, withIntermediateDirectories: true)\n    let configURL = home.appendingPathComponent(\"config.toml\")\n    try \"notify = [\\\"old-notify\\\"]\\n\".write(to: configURL, atomically: true, encoding: .utf8)\n\n    let service = CodexConfigService(paths: .init(home: home, configURL: configURL), fileManager: fm)\n    let rules = [\n      HookRule(name: \"Stop A\", event: \"Stop\", commands: [HookCommand(command: \"/bin/echo\", args: [\"a\"])], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false)),\n      HookRule(name: \"Stop B\", event: \"Stop\", commands: [HookCommand(command: \"/bin/echo\", args: [\"b\"])], enabled: true, targets: HookTargets(codex: true, claude: false, gemini: false)),\n    ]\n    let warnings = try await service.applyHooksFromCodMate(rules)\n    XCTAssertEqual(warnings.count, 1)\n\n    let unchanged = await service.getNotifyArray()\n    XCTAssertEqual(unchanged.first, \"old-notify\")\n  }\n}\n\n"
  },
  {
    "path": "Tests/CodMateTests/GeminiHooksAdapterTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class GeminiHooksAdapterTests: XCTestCase {\n  func testApplyHooksWritesGeminiSettingsHooksAndEnablesTools() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-gemini-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let settingsURL = tmp.appendingPathComponent(\"settings.json\")\n\n    let paths = GeminiSettingsService.Paths(directory: tmp, file: settingsURL)\n    let service = GeminiSettingsService(paths: paths, fileManager: fm)\n    let rule = HookRule(\n      name: \"PreToolUse\",\n      event: \"PreToolUse\",\n      matcher: \"Write\",\n      commands: [HookCommand(command: \"/usr/bin/echo\", args: [\"ok\"], timeoutMs: 10_000)],\n      enabled: true,\n      targets: HookTargets(codex: false, claude: false, gemini: true)\n    )\n\n    let warnings = try await service.applyHooksFromCodMate([rule])\n    XCTAssertTrue(warnings.isEmpty)\n\n    let data = try Data(contentsOf: settingsURL)\n    let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]\n    let hooks = obj?[\"hooks\"] as? [String: Any]\n    let pre = hooks?[\"PreToolUse\"] as? [[String: Any]]\n    XCTAssertEqual(pre?.count, 1)\n    XCTAssertEqual(pre?.first?[\"matcher\"] as? String, \"Write\")\n    let nested = pre?.first?[\"hooks\"] as? [[String: Any]]\n    XCTAssertEqual(nested?.count, 1)\n    XCTAssertTrue((nested?.first?[\"name\"] as? String)?.hasPrefix(\"codmate-hook:\") == true)\n\n    let tools = obj?[\"tools\"] as? [String: Any]\n    XCTAssertEqual(tools?[\"enableHooks\"] as? Bool, true)\n    XCTAssertEqual(tools?[\"enableMessageBusIntegration\"] as? Bool, true)\n  }\n\n  func testApplyPrunesPreviouslyManagedHooks() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-gemini-hooks-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let settingsURL = tmp.appendingPathComponent(\"settings.json\")\n\n    let paths = GeminiSettingsService.Paths(directory: tmp, file: settingsURL)\n    let service = GeminiSettingsService(paths: paths, fileManager: fm)\n    let rule = HookRule(\n      name: \"Stop\",\n      event: \"Stop\",\n      commands: [HookCommand(command: \"/usr/bin/echo\")],\n      enabled: true,\n      targets: HookTargets(codex: false, claude: false, gemini: true)\n    )\n    _ = try await service.applyHooksFromCodMate([rule])\n    _ = try await service.applyHooksFromCodMate([rule])\n\n    let data = try Data(contentsOf: settingsURL)\n    let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]\n    let hooks = obj?[\"hooks\"] as? [String: Any]\n    let stop = hooks?[\"Stop\"] as? [[String: Any]]\n    let nested = stop?.first?[\"hooks\"] as? [[String: Any]]\n    let managed = (nested ?? []).filter { ($0[\"name\"] as? String)?.hasPrefix(\"codmate-hook:\") == true }\n    XCTAssertEqual(managed.count, 1)\n  }\n}\n\n"
  },
  {
    "path": "Tests/CodMateTests/HooksStoreTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class HooksStoreTests: XCTestCase {\n  func testUpsertListUpdateDelete() async throws {\n    let fm = FileManager.default\n    let tmp = fm.temporaryDirectory.appendingPathComponent(\"codmate-hooks-store-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tmp, withIntermediateDirectories: true)\n    let paths = HooksStore.Paths(home: tmp, fileURL: tmp.appendingPathComponent(\"hooks.json\"))\n    let store = HooksStore(paths: paths, fileManager: fm)\n\n    let rule = HookRule(\n      name: \"Stop · echo\",\n      event: \"Stop\",\n      commands: [HookCommand(command: \"/usr/bin/echo\", args: [\"hello\"])],\n      enabled: true,\n      targets: HookTargets(codex: true, claude: true, gemini: true),\n      source: \"test\"\n    )\n\n    try await store.upsert(rule)\n    let list1 = await store.list()\n    XCTAssertEqual(list1.count, 1)\n    XCTAssertEqual(list1.first?.id, rule.id)\n\n    try await store.update(id: rule.id) { r in\n      r.enabled = false\n    }\n    let list2 = await store.list()\n    XCTAssertEqual(list2.first?.enabled, false)\n\n    try await store.delete(id: rule.id)\n    let list3 = await store.list()\n    XCTAssertEqual(list3.count, 0)\n  }\n}\n\n"
  },
  {
    "path": "Tests/CodMateTests/UpdateServiceTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class UpdateServiceTests: XCTestCase {\n  func testParseLatestRelease() throws {\n    let json = \"\"\"\n    {\n      \"tag_name\": \"v1.2.3\",\n      \"html_url\": \"https://github.com/loocor/CodMate/releases/tag/v1.2.3\",\n      \"draft\": false,\n      \"prerelease\": false,\n      \"assets\": [\n        {\"name\": \"codmate-arm64.dmg\", \"browser_download_url\": \"https://example.com/codmate-arm64.dmg\"}\n      ]\n    }\n    \"\"\"\n    let data = Data(json.utf8)\n    let release = try UpdateService.Release.decode(from: data)\n    XCTAssertEqual(release.tagName, \"v1.2.3\")\n    XCTAssertEqual(release.assets.first?.name, \"codmate-arm64.dmg\")\n  }\n}\n"
  },
  {
    "path": "Tests/CodMateTests/UpdateSupportTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class UpdateSupportTests: XCTestCase {\n  func testVersionCompare() {\n    XCTAssertTrue(Version(\"1.2.3\")! < Version(\"1.2.4\")!)\n    XCTAssertTrue(Version(\"1.2.3\")! > Version(\"1.2.2\")!)\n    XCTAssertTrue(Version(\"1.2.0\")! == Version(\"1.2\")!)\n  }\n\n  func testAssetNameForArch() {\n    XCTAssertEqual(UpdateAssetSelector.assetName(for: .arm64), \"codmate-arm64.dmg\")\n    XCTAssertEqual(UpdateAssetSelector.assetName(for: .x86_64), \"codmate-x86_64.dmg\")\n  }\n}\n"
  },
  {
    "path": "Tests/CodMateTests/UpdateViewModelTests.swift",
    "content": "import Foundation\nimport XCTest\n@testable import CodMate\n\nfinal class UpdateViewModelTests: XCTestCase {\n  @MainActor\n  func testInstallInstructions() {\n    let vm = UpdateViewModel(service: UpdateService())\n    XCTAssertTrue(vm.installInstructions.contains(\"Applications\"))\n  }\n\n  @MainActor\n  func testSandboxDownloadUsesTemporaryDirectory() async throws {\n    let originalSandbox = getenv(\"APP_SANDBOX_CONTAINER_ID\").map { String(cString: $0) }\n    defer {\n      if let originalSandbox {\n        setenv(\"APP_SANDBOX_CONTAINER_ID\", originalSandbox, 1)\n      } else {\n        unsetenv(\"APP_SANDBOX_CONTAINER_ID\")\n      }\n      MockURLProtocol.requestHandler = nil\n    }\n\n    let fileManager = FileManager.default\n    let downloadsDir = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? fileManager.temporaryDirectory\n    let sourceURL = fileManager.temporaryDirectory.appendingPathComponent(\"codmate-test-source.dmg\")\n    try Data(\"test\".utf8).write(to: sourceURL)\n    let assetName = UpdateAssetSelector.assetName(for: .current)\n    let releaseJSON = \"\"\"\n    {\n      \"tag_name\": \"v999.0.0\",\n      \"html_url\": \"https://example.com/release\",\n      \"draft\": false,\n      \"prerelease\": false,\n      \"assets\": [\n        {\n          \"name\": \"\\(assetName)\",\n          \"browser_download_url\": \"\\(sourceURL.absoluteString)\"\n        }\n      ]\n    }\n    \"\"\"\n    let releaseData = Data(releaseJSON.utf8)\n\n    MockURLProtocol.requestHandler = { request in\n      let response = HTTPURLResponse(\n        url: request.url ?? URL(string: \"https://api.github.com/\")!,\n        statusCode: 200,\n        httpVersion: nil,\n        headerFields: nil\n      )!\n      return (response, releaseData)\n    }\n\n    let config = URLSessionConfiguration.ephemeral\n    config.protocolClasses = [MockURLProtocol.self]\n    let session = URLSession(configuration: config)\n    let defaults = UserDefaults(suiteName: \"UpdateViewModelTests\")!\n    defaults.removePersistentDomain(forName: \"UpdateViewModelTests\")\n    let service = UpdateService(defaults: defaults, session: session, calendar: Calendar(identifier: .gregorian))\n    let vm = UpdateViewModel(service: service)\n\n    vm.checkNow()\n    let didUpdate = await waitUntil({\n      if case .updateAvailable = vm.state { return true }\n      return false\n    }, timeout: 2.0)\n    XCTAssertTrue(didUpdate)\n\n    setenv(\"APP_SANDBOX_CONTAINER_ID\", \"1\", 1)\n    let start = Date()\n    vm.downloadIfNeeded()\n    let didDownload = await waitUntil({\n      vm.showInstallInstructions || (!vm.isDownloading && vm.lastError != nil)\n    }, timeout: 5.0)\n    XCTAssertTrue(didDownload)\n    XCTAssertNil(vm.lastError)\n    XCTAssertTrue(vm.showInstallInstructions)\n\n    let tempHit = findDownloadedFile(in: fileManager.temporaryDirectory, baseName: assetName, since: start)\n    let downloadsHit = findDownloadedFile(in: downloadsDir, baseName: assetName, since: start)\n    XCTAssertNotNil(tempHit)\n    XCTAssertNil(downloadsHit)\n\n    if let tempHit { try? fileManager.removeItem(at: tempHit) }\n    if let downloadsHit { try? fileManager.removeItem(at: downloadsHit) }\n    try? fileManager.removeItem(at: sourceURL)\n  }\n}\n\nprivate final class MockURLProtocol: URLProtocol {\n  static var requestHandler: ((URLRequest) throws -> (HTTPURLResponse, Data))?\n\n  override class func canInit(with request: URLRequest) -> Bool {\n    true\n  }\n\n  override class func canonicalRequest(for request: URLRequest) -> URLRequest {\n    request\n  }\n\n  override func startLoading() {\n    guard let handler = Self.requestHandler else {\n      client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL))\n      return\n    }\n    do {\n      let (response, data) = try handler(request)\n      client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)\n      client?.urlProtocol(self, didLoad: data)\n      client?.urlProtocolDidFinishLoading(self)\n    } catch {\n      client?.urlProtocol(self, didFailWithError: error)\n    }\n  }\n\n  override func stopLoading() {}\n}\n\nprivate func findDownloadedFile(in directory: URL, baseName: String, since: Date) -> URL? {\n  let cutoff = since.addingTimeInterval(-2)\n  guard let urls = try? FileManager.default.contentsOfDirectory(\n    at: directory,\n    includingPropertiesForKeys: [.contentModificationDateKey],\n    options: [.skipsHiddenFiles]\n  ) else { return nil }\n  for url in urls {\n    let name = url.lastPathComponent\n    guard name == baseName || name.hasSuffix(\"-\\(baseName)\") else { continue }\n    let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])\n    if let date = values?.contentModificationDate, date >= cutoff {\n      return url\n    }\n  }\n  return nil\n}\n\nprivate func waitUntil(_ condition: @escaping () -> Bool, timeout: TimeInterval) async -> Bool {\n  let deadline = Date().addingTimeInterval(timeout)\n  while Date() < deadline {\n    if condition() { return true }\n    try? await Task.sleep(nanoseconds: 50_000_000)\n  }\n  return condition()\n}\n"
  },
  {
    "path": "Tests/CodMateTests/WizardResponseParserTests.swift",
    "content": "import XCTest\n@testable import CodMate\n\nfinal class WizardResponseParserTests: XCTestCase {\n  func testDecodeEnvelope() {\n    let raw = \"\"\"\n    {\"mode\":\"draft\",\"draft\":{\"event\":\"Stop\",\"commands\":[{\"command\":\"/bin/echo\"}]}}\n    \"\"\"\n    let envelope: WizardDraftEnvelope<HookWizardDraft>? = WizardResponseParser.decodeEnvelope(raw)\n    XCTAssertEqual(envelope?.mode, .draft)\n    XCTAssertEqual(envelope?.draft?.event, \"Stop\")\n  }\n\n  func testDecodeWithCodeFence() {\n    let raw = \"\"\"\n    ```json\n    {\"mode\":\"draft\",\"draft\":{\"event\":\"Stop\",\"commands\":[{\"command\":\"/bin/echo\"}]}}\n    ```\n    \"\"\"\n    let envelope: WizardDraftEnvelope<HookWizardDraft>? = WizardResponseParser.decodeEnvelope(raw)\n    XCTAssertEqual(envelope?.mode, .draft)\n    XCTAssertEqual(envelope?.draft?.commands.count, 1)\n  }\n\n  func testDecodeEnvelopeFromWrapper() {\n    let raw = \"\"\"\n    {\"result\":\"{\\\\\"mode\\\\\":\\\\\"draft\\\\\",\\\\\"draft\\\\\":{\\\\\"event\\\\\":\\\\\"Stop\\\\\",\\\\\"commands\\\\\":[{\\\\\"command\\\\\":\\\\\"/bin/echo\\\\\"}]}}\"}\n    \"\"\"\n    let envelope: WizardDraftEnvelope<HookWizardDraft>? = WizardResponseParser.decodeEnvelope(raw)\n    XCTAssertEqual(envelope?.mode, .draft)\n    XCTAssertEqual(envelope?.draft?.event, \"Stop\")\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/AntigravityIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"antigravity.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/AppIcon.appiconset/Contents.json",
    "content": "{\n  \"images\" : [\n    { \"idiom\" : \"mac\", \"size\" : \"16x16\",   \"scale\" : \"1x\", \"filename\" : \"icon_16x16.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"16x16\",   \"scale\" : \"2x\", \"filename\" : \"icon_16x16@2x.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"32x32\",   \"scale\" : \"1x\", \"filename\" : \"icon_32x32.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"32x32\",   \"scale\" : \"2x\", \"filename\" : \"icon_32x32@2x.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"128x128\", \"scale\" : \"1x\", \"filename\" : \"icon_128x128.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"128x128\", \"scale\" : \"2x\", \"filename\" : \"icon_128x128@2x.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"256x256\", \"scale\" : \"1x\", \"filename\" : \"icon_256x256.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"256x256\", \"scale\" : \"2x\", \"filename\" : \"icon_256x256@2x.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"512x512\", \"scale\" : \"1x\", \"filename\" : \"icon_512x512.png\" },\n    { \"idiom\" : \"mac\", \"size\" : \"512x512\", \"scale\" : \"2x\", \"filename\" : \"icon_512x512@2x.png\" }\n  ],\n  \"info\" : { \"version\" : 1, \"author\" : \"xcode\" }\n}\n\n"
  },
  {
    "path": "assets/Assets.xcassets/ChatGPTIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"chatgpt.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/ClaudeIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"claude.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/Contents.json",
    "content": "{\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  }\n}\n\n"
  },
  {
    "path": "assets/Assets.xcassets/DeepSeekIcon.imageset/Contents.json",
    "content": "{\n  \"images\": [\n    {\n      \"filename\": \"deepseek.svg\",\n      \"idiom\": \"mac\"\n    }\n  ],\n  \"info\": {\n    \"author\": \"xcode\",\n    \"version\": 1\n  },\n  \"properties\": {\n    \"preserves-vector-representation\": true,\n    \"template-rendering-intent\": \"original\"\n  }\n}"
  },
  {
    "path": "assets/Assets.xcassets/GeminiIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"gemini.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/KimiIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"kimi.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/MCPMateLogo.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"MCPMate.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/MiniMaxIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"minimax.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/OpenRouterIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"openrouter.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/QwenIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"qwen.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/Assets.xcassets/ZaiIcon.imageset/Contents.json",
    "content": "{\n  \"images\" : [\n    {\n      \"filename\" : \"zai.svg\",\n      \"idiom\" : \"mac\"\n    }\n  ],\n  \"info\" : {\n    \"author\" : \"xcode\",\n    \"version\" : 1\n  },\n  \"properties\" : {\n    \"preserves-vector-representation\" : true,\n    \"template-rendering-intent\" : \"original\"\n  }\n}\n"
  },
  {
    "path": "assets/CodMate-Notify.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<false/>\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-write</key>\n\t<true/>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-only</key>\n\t<true/>\n\t<key>com.apple.security.temporary-exception.files.home-relative-path.read-only</key>\n\t<array>\n\t\t<string>.ssh/config</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "assets/CodMate.entitlements",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>com.apple.security.app-sandbox</key>\n\t<false/>\n\t<key>com.apple.security.files.bookmarks.app-scope</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-write</key>\n\t<true/>\n\t<key>com.apple.security.network.client</key>\n\t<true/>\n\t<key>com.apple.security.files.user-selected.read-only</key>\n\t<true/>\n\t<key>com.apple.security.temporary-exception.files.home-relative-path.read-only</key>\n\t<array>\n\t\t<string>.ssh/config</string>\n\t</array>\n</dict>\n</plist>\n"
  },
  {
    "path": "assets/Info.plist",
    "content": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n\t<key>CFBundleDevelopmentRegion</key>\n\t<string>en</string>\n\t<key>CFBundleDisplayName</key>\n\t<string>CodMate</string>\n\t<key>CFBundleExecutable</key>\n\t<string>CodMate</string>\n\t<key>CFBundleIdentifier</key>\n\t<string>ai.umate.codmate</string>\n\t<key>CFBundleInfoDictionaryVersion</key>\n\t<string>6.0</string>\n\t<key>CFBundleName</key>\n\t<string>CodMate</string>\n\t<key>CFBundlePackageType</key>\n\t<string>APPL</string>\n\t<key>CFBundleShortVersionString</key>\n\t<string>0.0.0</string>\n\t<key>CFBundleVersion</key>\n\t<string>1</string>\n\t<key>CFBundleIconFile</key>\n\t<string>AppIcon</string>\n\t<key>CFBundleIconName</key>\n\t<string>AppIcon</string>\n\t<key>LSMinimumSystemVersion</key>\n\t<string>13.5</string>\n\t<key>LSApplicationCategoryType</key>\n\t<string>public.app-category.developer-tools</string>\n\t<key>NSMainStoryboardFile</key>\n\t<string></string>\n\t<key>NSPrincipalClass</key>\n\t<string>NSApplication</string>\n\t<key>NSHumanReadableCopyright</key>\n\t<string>Copyright © 2025 CodMate</string>\n\t<key>NSSupportsAutomaticGraphicsSwitching</key>\n\t<true/>\n\t<key>NSAppleEventsUsageDescription</key>\n\t<string>CodMate may open Terminal or other apps on your request.</string>\n\t<key>CFBundleURLTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleURLName</key>\n\t\t\t<string>ai.codmate.app</string>\n\t\t\t<key>CFBundleURLSchemes</key>\n\t\t\t<array>\n\t\t\t\t<string>codmate</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CFBundleDocumentTypes</key>\n\t<array>\n\t\t<dict>\n\t\t\t<key>CFBundleTypeName</key>\n\t\t\t<string>Folder</string>\n\t\t\t<key>CFBundleTypeRole</key>\n\t\t\t<string>Viewer</string>\n\t\t\t<key>LSHandlerRank</key>\n\t\t\t<string>Owner</string>\n\t\t\t<key>LSItemContentTypes</key>\n\t\t\t<array>\n\t\t\t\t<string>public.folder</string>\n\t\t\t\t<string>public.directory</string>\n\t\t\t</array>\n\t\t</dict>\n\t</array>\n\t<key>CodMateGitTag</key>\n\t<string></string>\n\t<key>CodMateGitCommit</key>\n\t<string></string>\n\t<key>CodMateGitDirty</key>\n\t<string></string>\n</dict>\n</plist>\n"
  },
  {
    "path": "docs/feature-inventory.md",
    "content": "# CodMate Feature Inventory\n\n> Purpose: Provide a traceable feature inventory with code evidence (file paths) for value/advantage/use-case synthesis. Each feature includes code evidence (file paths).\n\n## Coverage and Methodology\n- Coverage sources: `README.md`, `AGENTS.md`, `docs/` specification documents + `views/` (UI entry points) + `services/` (capability implementations) + `models/` (data/state).\n- Evidence format: Each feature is annotated with `Evidence:`, containing minimal necessary file paths (traceable item by item).\n- Note: This inventory focuses on \"feature points\", not marketing copy; it can be used to extract \"value/advantage/scenarios\" later.\n\n---\n\n## 1) Session Sources and Collection (Multi-CLI Unified Management)\n- Codex session parsing and provider (local `.jsonl`). Evidence: `services/SessionProvider.swift`, `services/CodexConfigService.swift`, `services/SessionIndexer.swift`\n- Claude Code session parsing and provider. Evidence: `services/ClaudeSessionParser.swift`, `services/ClaudeSessionProvider.swift`\n- Gemini CLI session parsing and provider. Evidence: `services/GeminiSessionParser.swift`, `services/GeminiSessionProvider.swift`\n- Remote session mirroring (SSH sync of remote sessions). Evidence: `services/RemoteSessionMirror.swift`, `services/RemoteSessionProvider.swift`, `services/SSHConfigResolver.swift`\n- Directory change monitoring (incremental session/index updates). Evidence: `services/DirectoryMonitor.swift`\n- Session activity tracking and statistics. Evidence: `services/SessionActivityTracker.swift`, `models/OverviewAggregate.swift`\n\n## 2) Indexing, Caching, and Performance Paths\n- Session indexing (SQLite) with incremental updates. Evidence: `services/SessionIndexSQLiteStore.swift`, `services/SessionIndexer.swift`\n- Lightweight caching and disk cache strategies. Evidence: `services/SessionCacheStore.swift`, `services/RipgrepDiskCache.swift`\n- Full-text scanning (ripgrep) and search caching. Evidence: `services/SessionRipgrepStore.swift`, `services/RipgrepRunner.swift`\n- Session timeline loading and incremental parsing. Evidence: `services/SessionTimelineLoader.swift`, `services/SessionEnrichmentService.swift`\n- Context pruning and optimization (avoiding oversized contexts). Evidence: `services/ContextTreeshaker.swift`\n\n## 3) Main Interface Structure and Navigation (3-Column Layout + Sidebar)\n- Three-column structure: Sidebar / List / Detail. Evidence: `views/Content/ContentView.swift`, `views/SessionNavigationView.swift`, `views/Content/ContentView+Sidebar.swift`\n- Sidebar top \"All Sessions\" entry and count. Evidence: `views/Content/ContentView+Sidebar.swift`, `models/SidebarState.swift`\n- Directory tree navigation (aggregated by cwd statistics). Evidence: `models/PathTree.swift`, `services/PathTreeStore.swift`, `views/PathTreeView.swift`\n- Calendar month view (daily statistics + multi-select). Evidence: `views/CalendarMonthView.swift`, `models/DateDimension.swift`\n- Overview cards and activity charts. Evidence: `views/OverviewCard.swift`, `views/OverviewActivityChart.swift`, `models/ActivityChartData.swift`\n\n## 4) Session List and Filtering\n- Session list row information (title/time/snippet/metrics). Evidence: `views/SessionListRowView.swift`, `models/SessionSummary.swift`\n- Sorting and scope (Today/Recent, etc.). Evidence: `models/SessionLoadScope.swift`, `models/SessionListViewModel.swift`\n- List filtering, status indicators, running sessions. Evidence: `models/SessionListViewModel.swift`, `models/SessionNavigation.swift`\n- Multi-dimensional filtering by project/directory/date. Evidence: `models/SessionListViewModel+Projects.swift`, `models/SessionListViewModel.swift`\n\n## 5) Session Details and Timeline\n- Conversation timeline view (user/assistant/tool/info). Evidence: `views/ConversationTimelineView.swift`, `models/TimelineEvent.swift`, `models/ConversationTurn.swift`\n- Timeline attachment parsing and opening. Evidence: `services/TimelineAttachmentDecoder.swift`, `services/TimelineAttachmentOpener.swift`\n- Environment Context/Turn Context display. Evidence: `models/EnvironmentContextInfo.swift`, `views/SessionDetailView.swift`\n- Task Instructions collapsible display. Evidence: `views/SessionDetailView.swift`\n\n## 6) Global Search and Content Retrieval\n- Global search panel (shortcuts/progress/cancel). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchViewModel.swift`\n- Search index service and incremental scanning. Evidence: `services/GlobalSearchService.swift`, `services/SessionRipgrepStore.swift`\n- Repository content search (repo-level scanning). Evidence: `services/RepoContentSearchService.swift`\n- Toolbar search entry. Evidence: `views/Search/ToolbarSearchField.swift`\n\n## 7) Projects / Tasks / Session Archiving\n- Project management (create/edit/select). Evidence: `views/ProjectsListView.swift`, `services/ProjectsStore.swift`, `models/Project.swift`\n- Task management and grouping. Evidence: `views/TaskListView.swift`, `services/TasksStore.swift`, `models/Task.swift`\n- Session assignment to projects/tasks. Evidence: `models/SessionListViewModel+Projects.swift`, `models/SessionListViewModel.swift`\n- Project-level Overview container and statistics. Evidence: `views/ProjectSpecificOverviewContainerView.swift`, `models/ProjectOverviewViewModel.swift`\n\n## 8) Session Metadata (Rename/Comment)\n- Session title/comment editing. Evidence: `views/EditSessionMetaView.swift`, `models/SessionListViewModel+Notes.swift`\n- Notes storage and migration. Evidence: `services/SessionNotesStore.swift`, `utils/FilenameSanitizer.swift`\n\n## 9) Resume / New / Terminal Workflow\n- Resume session (external terminal or embedded terminal). Evidence: `services/SessionActions+Terminal.swift`, `views/EmbeddedTerminalView.swift`, `views/CodMateTerminalView.swift`\n- New session (reuse cwd / model / policy). Evidence: `services/SessionActions+Commands.swift`, `models/SessionListViewModel+Commands.swift`\n- Terminal session management (keep running per session). Evidence: `services/TerminalSessionManager.swift`\n- External terminal configuration (Terminal/iTerm2/Warp). Evidence: `services/ExternalTerminalProfileStore.swift`, `views/ExternalTerminalMenuHelpers.swift`\n- Copy \"real command\" (full parameters). Evidence: `views/Content/ContentView+DetailActionBar.swift`, `services/SessionActions+Commands.swift`\n\n## 10) Prompt System and Quick Commands\n- Prompts Picker (insert into terminal input). Evidence: `views/Content/ContentView.swift`, `views/Content/ContentView+DetailActionBar.swift`\n- Prompt presets and merging (project-level/user-level/built-in). Evidence: `services/PresetPromptsStore.swift`\n- Prompt maintenance (add/delete/hide). Evidence: `services/PresetPromptsStore.swift`, `views/Content/ContentView.swift`\n- Warp title prompt (prompt title). Evidence: `utils/WarpTitlePrompt.swift`, `services/SessionPreferencesStore.swift`\n\n## 11) Git Review (Review Mode)\n- Git changes tree + diff preview. Evidence: `views/GitChanges/GitChangesPanel.swift`, `services/GitService.swift`\n- Stage / Unstage, operations by file/directory. Evidence: `views/GitChanges/GitChangesPanel+DiffTree.swift`, `services/GitService.swift`\n- Commit editing and execution. Evidence: `views/GitChanges/GitChangesPanel.swift`, `models/GitChangesViewModel.swift`\n- AI-generated Commit Message. Evidence: `models/GitChangesViewModel.swift`, `services/LLMHTTPService.swift`\n- Git history graph and commit details. Evidence: `views/GitChanges/GitChangesPanel+Graph.swift`, `models/GitGraphViewModel.swift`\n- Review panel state and persistence. Evidence: `models/ReviewPanelState.swift`, `views/GitChanges/GitChangesPanel+Lifecycle.swift`\n\n## 12) Providers / Models / Usage\n- Providers registry (unified management of API Key / Base URL / models). Evidence: `services/ProvidersRegistryService.swift`, `views/ProvidersSettingsView.swift`\n- Provider icon/display. Evidence: `views/ProviderIconView.swift`\n- Usage API clients and status. Evidence: `services/ClaudeUsageAPIClient.swift`, `services/CodexFeaturesService.swift`, `services/GeminiUsageAPIClient.swift`\n- Usage status UI and triple-ring indicator. Evidence: `views/UsageStatusControl.swift`, `views/TripleUsageDonutView.swift`\n\n## 13) MCP Servers and Extensions\n- MCP Servers list, enable, edit. Evidence: `models/MCPServer.swift`, `services/MCPServersStore.swift`, `views/MCPServersSettingsView.swift`\n- MCP Uni-Import (import/normalization). Evidence: `services/UniImportMCPNormalizer.swift`, `views/MCPServersSettingsView.swift`\n- MCP connection testing and capability detection. Evidence: `services/MCPQuickTestService.swift`\n- Extensions settings entry (MCP / Skills). Evidence: `views/ExtensionsSettingsView.swift`, `models/ExtensionsSettingsTab.swift`\n\n## 14) Skills (Skill Packages)\n- Skills list, loading, configuration. Evidence: `services/SkillsStore.swift`, `views/SkillsSettingsView.swift`\n- Skills synchronization and application to projects. Evidence: `services/SkillsSyncService.swift`, `services/ProjectExtensionsApplier.swift`\n- Skills package preview and details. Evidence: `views/Skills/SkillPackageExplorerView.swift`, `models/SkillsModels.swift`\n- Project-level Skills settings. Evidence: `views/ProjectsListView.swift`, `models/ProjectExtensionsViewModel.swift`\n\n## 15) Notification System\n- System notification wrapper. Evidence: `services/SystemNotifier.swift`, `services/EmbeddedNotifySniffer.swift`\n- Claude Code notification hook setup. Evidence: `models/ClaudeCodeVM.swift`, `services/ClaudeSettingsService.swift`\n- Gemini notification settings. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift`\n\n## 16) Diagnostics and Advanced Settings\n- Dialectics diagnostics panel (data directories/index/reports). Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift`\n- Advanced Settings: Path / Dialectics. Evidence: `views/AdvancedSettingsView.swift`, `views/AdvancedPathPane.swift`\n- Diagnostics report export. Evidence: `views/DiagnosticsViews.swift`, `services/SessionsDiagnosticsService.swift`\n\n## 17) Settings System (Multi-Page/Multi-Tab)\n- Settings main entry and categorization. Evidence: `views/SettingsView.swift`, `models/SettingCategory.swift`\n\n### 17.1 General\n- System menu bar icon display policy. Evidence: `views/SettingsView.swift`, `models/SystemMenuVisibility.swift`\n- Default editor selection (for quick opening in Review, etc.). Evidence: `views/SettingsView.swift`, `models/EditorApp.swift`\n- Global search panel style (⌘F display mode). Evidence: `views/SettingsView.swift`, `models/GlobalSearchModels.swift`\n- Timeline/Markdown message type visibility configuration (with \"Restore Defaults\"). Evidence: `views/SettingsView.swift`, `models/SessionPreferencesStore.swift`, `models/TimelineEvent.swift`\n\n### 17.2 Terminal\n- Embedded terminal toggle (non-sandboxed version), CLI console mode. Evidence: `views/SettingsView.swift`, `services/TerminalSessionManager.swift`\n- Terminal font and cursor style selection. Evidence: `views/SettingsView.swift`, `utils/TerminalFontResolver.swift`, `models/TerminalCursorStyleOption.swift`\n- External terminal default app and auto-open. Evidence: `views/SettingsView.swift`, `services/ExternalTerminalProfileStore.swift`\n- New/resume command auto-copy to clipboard. Evidence: `views/SettingsView.swift`, `services/SessionPreferencesStore.swift`\n- Warp tab title prompt. Evidence: `views/SettingsView.swift`, `utils/WarpTitlePrompt.swift`\n\n### 17.3 Command (Codex CLI Default Parameters)\n- Sandbox policy and Approval policy defaults. Evidence: `views/SettingsView.swift`, `models/ExecutionPolicy.swift`\n- `--full-auto` and dangerous bypass (bypass approvals/sandbox) toggle. Evidence: `views/SettingsView.swift`\n\n### 17.4 Providers (Global Provider Management)\n- Provider list, template add, edit/delete. Evidence: `views/ProvidersSettingsView.swift`, `services/ProvidersRegistryService.swift`\n- Provider editor: Codex/Claude Base URL, API Key Env, Wire API. Evidence: `views/ProvidersSettingsView.swift`\n- Model catalog editing: add/delete, default model, capability tags (reasoning/tool/vision/long context). Evidence: `views/ProvidersSettingsView.swift`, `services/ProvidersRegistryService.swift`\n- Connection testing and documentation entry. Evidence: `views/ProvidersSettingsView.swift`\n\n### 17.5 Codex Settings\n- Provider binding (Active Provider + Model). Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift`\n- Runtime defaults: Reasoning Effort/Summary, Verbosity, Sandbox, Approval. Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift`\n- Feature Flags: fetch and per-item override toggles. Evidence: `views/CodexSettingsView.swift`, `services/CodexFeaturesService.swift`\n- Notifications: TUI notifications, system notifications, notify bridge self-test. Evidence: `views/CodexSettingsView.swift`, `services/SystemNotifier.swift`\n- Privacy/environment policy: inheritance scope, include/exclude, environment variable overrides, hide/show reasoning. Evidence: `views/CodexSettingsView.swift`, `models/CodexVM.swift`\n- Raw Config read-only view and quick open. Evidence: `views/CodexSettingsView.swift`\n\n### 17.6 Claude Code Settings\n- Provider: Active Provider, default model and aliases (Haiku/Sonnet/Opus), login method. Evidence: `views/ClaudeCodeSettingsView.swift`, `models/ClaudeCodeVM.swift`\n- Runtime: Permission Mode, Skip Permissions, Debug/Verbose, tool allow/deny, IDE auto-connect, Strict MCP, Fallback Model. Evidence: `views/ClaudeCodeSettingsView.swift`, `models/ClaudeCodeVM.swift`\n- Notifications: install hook, hook command preview and self-test. Evidence: `views/ClaudeCodeSettingsView.swift`, `services/ClaudeSettingsService.swift`\n- Raw Config: settings.json read-only view and open. Evidence: `views/ClaudeCodeSettingsView.swift`\n\n### 17.7 Gemini CLI Settings\n- General: Preview Features, Prompt Completion, Vim Mode, Disable Auto Update, Session Retention. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift`\n- Runtime: Sandbox/Approval defaults. Evidence: `views/GeminiSettingsView.swift`, `models/ExecutionPolicy.swift`\n- Model: model selection, Max Session Turns, Compression Threshold, Skip Next Speaker Check. Evidence: `views/GeminiSettingsView.swift`, `models/GeminiVM.swift`\n- Notifications: system notifications and self-test. Evidence: `views/GeminiSettingsView.swift`\n- Raw Config: settings.json read-only view and open. Evidence: `views/GeminiSettingsView.swift`\n\n### 17.8 Extensions Settings\n- MCP Servers: list, enable/disable, Uni‑Import, form/JSON editing, connection testing. Evidence: `views/MCPServersSettingsView.swift`, `services/MCPQuickTestService.swift`\n- Skills: search, install (folder/Zip/URL/drag-drop), enable/disable, reinstall/uninstall, details preview. Evidence: `views/SkillsSettingsView.swift`, `models/SkillsLibraryViewModel.swift`\n\n### 17.9 Git Review Settings\n- Diff display (line numbers, soft wrap). Evidence: `views/GitReviewSettingsView.swift`, `services/GitService.swift`\n- Commit generation (Provider/Model selection). Evidence: `views/GitReviewSettingsView.swift`, `services/ProvidersRegistryService.swift`\n- Commit Prompt template. Evidence: `views/GitReviewSettingsView.swift`, `services/SessionPreferencesStore.swift`\n\n### 17.10 Remote Hosts\n- SSH host list and enable toggle (from `~/.ssh/config`). Evidence: `views/RemoteHostsSettingsView.swift`, `services/SSHConfigResolver.swift`\n- One-click sync/refresh, unavailable host prompts and permission guidance. Evidence: `views/RemoteHostsSettingsView.swift`, `services/SandboxPermissionsManager.swift`\n\n### 17.11 Advanced\n- Path: Projects/Notes root directory switching. Evidence: `views/AdvancedPathPane.swift`, `models/SessionPreferencesStore.swift`\n- CLI path overrides and auto-detection, PATH snapshot. Evidence: `views/AdvancedPathPane.swift`, `models/CLIPathVM.swift`\n- Dialectics: environment info, ripgrep statistics, index rebuild, sessions/notes/projects directory diagnostics, report export. Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift`, `services/SessionRipgrepStore.swift`\n\n### 17.12 About\n- Version/build time, project link, license viewing. Evidence: `views/AboutViews.swift`\n\n## 18) Menu Bar (Status Bar)\n- Status bar menu and quick actions. Evidence: `services/MenuBarController.swift`\n- Provider/model/usage display. Evidence: `services/MenuBarController.swift`, `models/UsageProviderSnapshot.swift`\n- Recent projects/sessions entry. Evidence: `services/MenuBarController.swift`, `views/RecentSessionsListView.swift`\n\n## 19) Security and Authorization\n- Security Scoped Bookmarks management. Evidence: `services/SecurityScopedBookmarks.swift`, `services/AuthorizationHub.swift`\n- Sandbox permissions management and prompts. Evidence: `services/SandboxPermissionsManager.swift`, `views/SandboxPermissionsView.swift`\n- External URL routing (codmate://). Evidence: `services/ExternalURLRouter.swift`\n\n## 20) Data Export and Formatting\n- Markdown export builder. Evidence: `utils/MarkdownExportBuilder.swift`, `views/SessionDetailView.swift`\n- Token/time/duration formatting. Evidence: `utils/TokenFormatter.swift`, `models/SessionEvent.swift`\n- Configurable Timeline/Markdown visibility. Evidence: `models/SessionPreferencesStore.swift`, `views/SettingsView.swift`\n\n## 21) Statistics and Display Helpers\n- Usage / Stats cards and aggregation. Evidence: `models/OverviewAggregate.swift`, `views/OverviewCard.swift`\n- Usage status models (Codex/Claude/Gemini). Evidence: `models/CodexUsageStatus.swift`, `models/ClaudeUsageStatus.swift`, `models/GeminiUsageStatus.swift`\n- Dual/triple-column statistics display components. Evidence: `views/TripleUsageDonutView.swift`, `views/UsageStatusControl.swift`\n\n## 22) Compatibility and Runtime Environment\n- CLI PATH environment setup and snapshot. Evidence: `utils/CLIEnvironment.swift`, `views/AdvancedPathPane.swift`\n- App distribution/environment identification. Evidence: `utils/AppDistribution.swift`, `utils/AppAvailability.swift`\n- Window/state persistence. Evidence: `services/WindowStateStore.swift`, `utils/WindowConfigurator.swift`\n\n## 23) Claude Web / Browser Integration (Auxiliary Capabilities)\n- Chrome/Safari cookie reading (Claude sessionKey). Evidence: `services/BrowserCookies/ChromeCookieImporter.swift`, `services/BrowserCookies/SafariCookieImporter.swift`\n- Claude Web API client (sessions/usage, etc.). Evidence: `services/ClaudeWebAPIClient.swift`, `services/LLMHTTPService.swift`\n\n---\n\n## 24) Value/Advantage Tag Library and Mapping (For Synthesis)\n> Note: The following tags can be used directly as \"value point\" titles or cards; features can be mapped to values later.\n\n### 24.1 Value Tag Library (Suggested Terms)\n- Efficiency and Speed (fast retrieval/fast resume/fast navigation)\n- Context Continuity (uninterrupted across terminals, across CLIs)\n- Traceability and Knowledge Accumulation (searchable, exportable, reviewable)\n- Customization and Control (Provider/Model/policy/permissions)\n- Security and Compliance (Sandbox, permissions, environment variables)\n- Collaboration and Standardization (projects/tasks/skills/prompt library)\n- Quality and Delivery Loop (Review/Commit)\n- Operations and Remote (SSH mirroring, remote sessions)\n- Ecosystem Compatibility (Codex/Claude/Gemini multi-source)\n- Diagnosability and Recoverability (diagnostics/index rebuild/reports)\n\n### 24.2 Feature → Value Mapping (Summary)\n- **Multi-source session unified management + remote mirroring** → Ecosystem compatibility, operations and remote, traceability\n  Evidence: `services/SessionProvider.swift`, `services/RemoteSessionMirror.swift`\n- **Global search + high-performance indexing** → Efficiency and speed, traceability\n  Evidence: `services/GlobalSearchService.swift`, `services/SessionIndexSQLiteStore.swift`\n- **Projects/Tasks organization** → Collaboration and standardization, context continuity\n  Evidence: `services/ProjectsStore.swift`, `services/TasksStore.swift`\n- **Resume/New + terminal integration** → Context continuity, efficiency and speed\n  Evidence: `services/SessionActions+Terminal.swift`, `views/EmbeddedTerminalView.swift`\n- **Review (Git Changes)** → Quality and delivery loop\n  Evidence: `views/GitChanges/GitChangesPanel.swift`, `services/GitService.swift`\n- **Providers/Models/Policies** → Customization and control, ecosystem compatibility\n  Evidence: `views/ProvidersSettingsView.swift`, `views/CodexSettingsView.swift`\n- **Notification system** → Efficiency and speed (reduced waiting and switching)\n  Evidence: `services/SystemNotifier.swift`, `views/ClaudeCodeSettingsView.swift`\n- **Diagnostics/Dialectics** → Diagnosability and recoverability, stability\n  Evidence: `views/DialecticsPane.swift`, `services/SessionsDiagnosticsService.swift`\n- **Sandbox/permissions/environment policy** → Security and compliance\n  Evidence: `views/CodexSettingsView.swift`, `services/SandboxPermissionsManager.swift`\n\n---\n\n## 25) Use Case Matrix (Suggested)\n> Note: For \"recommended use cases\" synthesis; can be rewritten as marketing descriptions.\n\n| Scenario | Key Requirements | Corresponding Capabilities (Examples) |\n|---|---|---|\n| Personal daily development | Quick history retrieval/continue context | Global search, Resume/New, timeline |\n| Team collaboration and standardization | Shared standards and prompts | Projects/Tasks, Skills, Prompts, Providers |\n| Multi-model/multi-vendor switching | Unified API/model management | Providers Registry, model catalog/capability tags |\n| Security-sensitive environments | Access/permission control | Sandbox/Approval/permission management/environment policy |\n| Remote development/operations | Unified remote session archiving | SSH remote mirroring, Remote Hosts |\n| Code review and delivery | Diff/Commit loop | Review mode, Stage/Unstage, AI Commit |\n| Large-scale history review | Traceability and export | Timeline, Markdown export, Notes |\n| Diagnostics and repair | Index/data anomaly troubleshooting | Dialectics, index rebuild, report export |\n\n---\n\n## 26) Secondary Inventory Supplements (Search / Terminal / Review)\n\n### 26.1 Search (Global Search)\n- Search scope switching (Scope segmented selection). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchModels.swift`\n- Search panel style (floating window / Popover). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchModels.swift`\n- Progress/statistics/cancel (files/matches). Evidence: `views/Search/GlobalSearchPanel.swift`, `services/SessionRipgrepStore.swift`\n- Result types and summaries (session/notes/project summaries). Evidence: `views/Search/GlobalSearchPanel.swift`, `models/GlobalSearchViewModel.swift`\n\n### 26.2 Terminal\n- Embedded terminal (SwiftTerm) and external terminal coexistence. Evidence: `views/EmbeddedTerminalView.swift`, `views/CodMateTerminalView.swift`\n- Theme synchronization (dark/light) and font strategy (CJK-friendly). Evidence: `views/EmbeddedTerminalView.swift`\n- Initial command injection and one-click copy/open external terminal. Evidence: `views/EmbeddedTerminalView.swift`\n- Terminal running and session binding (no exit on switch). Evidence: `services/TerminalSessionManager.swift`, `views/Content/ContentView+Detail.swift`\n\n### 26.3 Review (Git Changes)\n- Multi-mode layout: Diff / Graph / Explorer (or preview). Evidence: `views/GitChanges/GitChangesPanel.swift`, `views/GitChanges/GitChangesPanel+Graph.swift`\n- File tree and staged/unstaged view separation. Evidence: `views/GitChanges/GitChangesPanel+LeftPane.swift`\n- Line numbers/soft wrap settings. Evidence: `views/GitReviewSettingsView.swift`\n- Commit message generation and template. Evidence: `views/GitReviewSettingsView.swift`, `models/GitChangesViewModel.swift`\n\n---\n\n## To Be Refined Later (Optional)\n- Fine-grained UI entry inventory (function mapping for each Button/ToolbarItem).\n- Feature points → marketing copy (rewritten for different audiences).\n- Case-based implementation of typical scenarios (real project stories or flowcharts).\n"
  },
  {
    "path": "docs/icon.icon/icon.json",
    "content": "{\n  \"fill\" : {\n    \"automatic-gradient\" : \"extended-srgb:0.00000,0.53333,1.00000,1.00000\"\n  },\n  \"groups\" : [\n    {\n      \"layers\" : [\n        {\n          \"blend-mode\" : \"plus-lighter\",\n          \"glass\" : true,\n          \"image-name\" : \"4.4-–-layer.png\",\n          \"name\" : \"4.4-–-layer\"\n        }\n      ],\n      \"shadow\" : {\n        \"kind\" : \"neutral\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    },\n    {\n      \"layers\" : [\n        {\n          \"blend-mode\" : \"normal\",\n          \"image-name\" : \"3.3-–-layer.png\",\n          \"name\" : \"3.3-–-layer\"\n        },\n        {\n          \"glass\" : true,\n          \"hidden\" : false,\n          \"image-name\" : \"2.2-–-layer.png\",\n          \"name\" : \"2.2-–-layer\"\n        }\n      ],\n      \"shadow\" : {\n        \"kind\" : \"neutral\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    },\n    {\n      \"layers\" : [\n        {\n          \"image-name\" : \"1.background.png\",\n          \"name\" : \"1.background\",\n          \"opacity-specializations\" : [\n            {\n              \"appearance\" : \"dark\",\n              \"value\" : 0.25\n            }\n          ]\n        }\n      ],\n      \"shadow\" : {\n        \"kind\" : \"neutral\",\n        \"opacity\" : 0.5\n      },\n      \"translucency\" : {\n        \"enabled\" : true,\n        \"value\" : 0.5\n      }\n    }\n  ],\n  \"supported-platforms\" : {\n    \"circles\" : [\n      \"watchOS\"\n    ],\n    \"squares\" : \"shared\"\n  }\n}"
  },
  {
    "path": "docs/projects.md",
    "content": "Projects in CodMate (Phase 1)\n\nOverview\n- Introduces a virtual “Projects” view to organize Codex sessions conceptually, in addition to the existing physical directory view.\n- Projects map to the `projects` group in Codex `config.toml` and can also be assigned per-session.\n- Minimal viable goals: list projects, filter sessions by project, create a new project, and assign sessions to a project.\n\nGoals (v1)\n- Toggle sidebar middle area between Directories and Projects without changing top “All Sessions” and bottom Calendar.\n- Read/write projects from Codex config: `[projects.<id>]` tables holding at least folder path and trust level.\n- Allow creating a project (name, folder, trust, overview, instructions, optional profile).\n- Assign sessions to a project (context menu; drag-and-drop planned for v1.1).\n- When a project is selected, filter the middle session list accordingly.\n\nNon-goals (deferred)\n- Automatic profile creation/rename sync.\n- Project-scoped overrides of all global runtime settings.\n- Cross-session knowledge linking UX; export/minify pipelines.\n- Drag-drop from middle list to sidebar project rows (v1.1).\n\nData Model\n- Project (new model):\n  - `id: String` – stable identifier used in config/notes\n  - `name: String` – display name\n  - `directory: String` – absolute path for project root\n  - `trustLevel: String?` – e.g., `trusted` | `untrusted` (string passthrough)\n  - `overview: String?` – short description\n  - `instructions: String?` – default instructions for new sessions\n  - `profileId: String?` – optional profile association (future use)\n\n- Session metadata extension (notes JSON per session id):\n  - `projectId: String?`\n  - `profileId: String?` (reserved)\n  - Backward compatible with existing title/comment; missing keys are tolerated.\n\nPersistence\n- Projects: Codex config at `~/.codex/config.toml` via `[projects.<id>]` tables.\n  - Supported keys: `name`, `directory`, `trust_level`, `overview`, `instructions`, `profile`.\n  - We read both `directory` and `path` for compatibility; we write `directory`.\n- Session-to-project mapping: stored in notes JSON under `~/.codmate/notes/<sessionId>.json` along with title/comment (with automatic migration from the legacy `~/.codex/notes`).\n\nView Model Changes\n- `SessionListViewModel`\n  - New state: `projects: [Project]`, `selectedProjectIDs: Set<String>` (Cmd-click enables multi-select filters).\n  - Loads projects on startup and when config changes.\n  - Filters sessions by selected project (matches notes.projectId; directory matching is a future enhancement).\n  - New APIs: `assignSessions(to projectId: String, ids: [String])`, `loadProjects()`, `setSelectedProject(_:)`, `clearAllFilters()` resets both path and project.\n\nUI/UX\n- Sidebar (left):\n  - Top fixed: “All Sessions” row (unchanged). Click clears both path and project filters.\n  - Middle scrollable: segmented toggle – “Directories” | “Projects”.\n    - Directories: existing Path tree.\n    - Projects: list of projects with count badges; “New Project” button.\n  - Bottom fixed: calendar month view (unchanged).\n  - Only the middle area scrolls. Width rules unchanged.\n\n- Projects list interactions:\n  - Click selects project → filters sessions. Cmd-click toggles multi-selection across projects; the filter matches any selected project (including descendants).\n  - Context menu on session rows (middle column): “Assign to Project…” flyout that lists projects.\n  - “New Project” opens a sheet to input: Name, Directory (choose…), Trust Level, Overview, Instructions, Profile (optional).\n\nCLI Integration (preparation)\n- 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.\n\nPerformance\n- Projects list is small; reads config once and caches in memory. Writes rewrite only the `projects` region similar to providers.\n- Session assignment uses existing notes store; no large scans. Filtering is O(n) over the already-loaded day scope.\n\nError Handling\n- Config I/O surfaced via `SessionListViewModel.errorMessage` and non-blocking alerts.\n- Notes writes are best-effort; UI does not crash on failures.\n\nExtensibility (v1.1+)\n- Drag-and-drop from middle list to project rows (drop target) to assign sessions.\n- Directory inference: if a session’s `cwd` is under a project directory and no explicit assignment exists, consider it in that project (opt-in).\n- Project-level overrides for model, reasoning, sandbox/approval flags.\n- Auto-create and sync a same-id Profile; conflict prompts.\n"
  },
  {
    "path": "ghostty/Package.swift",
    "content": "// swift-tools-version: 6.0\n\nimport PackageDescription\nimport Foundation\n\nlet packageDir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()\nlet vendorLibDirDefault = packageDir.appendingPathComponent(\"Vendor/lib\").path\nlet vendorLibDir = ProcessInfo.processInfo.environment[\"GHOSTTY_VENDOR_LIB\"] ?? vendorLibDirDefault\n\nlet package = Package(\n    name: \"ghostty\",\n    platforms: [.macOS(.v13)],\n    products: [\n        .library(\n            name: \"GhosttyKit\",\n            targets: [\"GhosttyKit\"]\n        ),\n    ],\n    targets: [\n        // C library target for libghostty\n        .systemLibrary(\n            name: \"CGhostty\",\n            path: \"Sources/CGhostty\",\n            pkgConfig: nil\n        ),\n\n        // Swift wrapper target\n        .target(\n            name: \"GhosttyKit\",\n            dependencies: [\"CGhostty\"],\n            path: \"Sources/GhosttyKit\",\n            resources: [\n                .process(\"../../Resources/themes\"),\n            ],\n            linkerSettings: [\n                .linkedLibrary(\"ghostty\"),\n                .unsafeFlags([\n                    \"-L\", vendorLibDir,\n                    \"-Xlinker\", \"-rpath\", \"-Xlinker\", \"@executable_path/../Frameworks\",\n                    // Enable dead code stripping to remove unused symbols from static library\n                    \"-Xlinker\", \"-dead_strip\",\n                ]),\n                .linkedFramework(\"Metal\"),\n                .linkedFramework(\"MetalKit\"),\n                .linkedFramework(\"IOSurface\"),\n                .linkedFramework(\"Carbon\"),\n            ]\n        ),\n    ],\n    swiftLanguageModes: [.v5]\n)\n"
  },
  {
    "path": "ghostty/Resources/themes/Apple Classic",
    "content": "palette = 0=#000000\npalette = 1=#c91b00\npalette = 2=#00c200\npalette = 3=#c7c400\npalette = 4=#1c3fe1\npalette = 5=#ca30c7\npalette = 6=#00c5c7\npalette = 7=#c7c7c7\npalette = 8=#686868\npalette = 9=#ff6e67\npalette = 10=#5ffa68\npalette = 11=#fffc67\npalette = 12=#6871ff\npalette = 13=#ff77ff\npalette = 14=#60fdff\npalette = 15=#ffffff\nbackground = #2c2b2b\nforeground = #d5a200\ncursor-color = #c7c7c7\ncursor-text = #ffffff\nselection-background = #6b5b02\nselection-foreground = #67e000\n"
  },
  {
    "path": "ghostty/Resources/themes/Apple System Colors",
    "content": "palette = 0=#1a1a1a\npalette = 1=#cc372e\npalette = 2=#26a439\npalette = 3=#cdac08\npalette = 4=#0869cb\npalette = 5=#9647bf\npalette = 6=#479ec2\npalette = 7=#98989d\npalette = 8=#464646\npalette = 9=#ff453a\npalette = 10=#32d74b\npalette = 11=#ffd60a\npalette = 12=#0a84ff\npalette = 13=#bf5af2\npalette = 14=#76d6ff\npalette = 15=#ffffff\nbackground = #1e1e1e\nforeground = #ffffff\ncursor-color = #98989d\ncursor-text = #ffffff\nselection-background = #3f638b\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Apple System Colors Light",
    "content": "palette = 0=#1a1a1a\npalette = 1=#cc372e\npalette = 2=#26a439\npalette = 3=#cdac08\npalette = 4=#0869cb\npalette = 5=#9647bf\npalette = 6=#479ec2\npalette = 7=#98989d\npalette = 8=#464646\npalette = 9=#ff453a\npalette = 10=#32d74b\npalette = 11=#edbb00\npalette = 12=#0a84ff\npalette = 13=#bf5af2\npalette = 14=#3accf7\npalette = 15=#ffffff\nbackground = #feffff\nforeground = #000000\ncursor-color = #98989d\ncursor-text = #ffffff\nselection-background = #abd8ff\nselection-foreground = #000000\n"
  },
  {
    "path": "ghostty/Resources/themes/Atom",
    "content": "palette = 0=#000000\npalette = 1=#fd5ff1\npalette = 2=#87c38a\npalette = 3=#ffd7b1\npalette = 4=#85befd\npalette = 5=#b9b6fc\npalette = 6=#85befd\npalette = 7=#e0e0e0\npalette = 8=#4c4c4c\npalette = 9=#fd5ff1\npalette = 10=#94fa36\npalette = 11=#f5ffa8\npalette = 12=#96cbfe\npalette = 13=#b9b6fc\npalette = 14=#85befd\npalette = 15=#e0e0e0\nbackground = #161719\nforeground = #c5c8c6\ncursor-color = #d0d0d0\ncursor-text = #151515\nselection-background = #444444\nselection-foreground = #c5c8c6\n"
  },
  {
    "path": "ghostty/Resources/themes/Atom One Dark",
    "content": "palette = 0=#21252b\npalette = 1=#e06c75\npalette = 2=#98c379\npalette = 3=#e5c07b\npalette = 4=#61afef\npalette = 5=#c678dd\npalette = 6=#56b6c2\npalette = 7=#abb2bf\npalette = 8=#767676\npalette = 9=#e06c75\npalette = 10=#98c379\npalette = 11=#e5c07b\npalette = 12=#61afef\npalette = 13=#c678dd\npalette = 14=#56b6c2\npalette = 15=#abb2bf\nbackground = #21252b\nforeground = #abb2bf\ncursor-color = #abb2bf\ncursor-text = #21252b\nselection-background = #323844\nselection-foreground = #abb2bf\n"
  },
  {
    "path": "ghostty/Resources/themes/Atom One Light",
    "content": "palette = 0=#000000\npalette = 1=#de3e35\npalette = 2=#3f953a\npalette = 3=#d2b67c\npalette = 4=#2f5af3\npalette = 5=#950095\npalette = 6=#3f953a\npalette = 7=#bbbbbb\npalette = 8=#000000\npalette = 9=#de3e35\npalette = 10=#3f953a\npalette = 11=#d2b67c\npalette = 12=#2f5af3\npalette = 13=#a00095\npalette = 14=#3f953a\npalette = 15=#ffffff\nbackground = #f9f9f9\nforeground = #2a2c33\ncursor-color = #bbbbbb\ncursor-text = #ffffff\nselection-background = #ededed\nselection-foreground = #2a2c33\n"
  },
  {
    "path": "ghostty/Resources/themes/Dracula",
    "content": "palette = 0=#21222c\npalette = 1=#ff5555\npalette = 2=#50fa7b\npalette = 3=#f1fa8c\npalette = 4=#bd93f9\npalette = 5=#ff79c6\npalette = 6=#8be9fd\npalette = 7=#f8f8f2\npalette = 8=#6272a4\npalette = 9=#ff6e6e\npalette = 10=#69ff94\npalette = 11=#ffffa5\npalette = 12=#d6acff\npalette = 13=#ff92df\npalette = 14=#a4ffff\npalette = 15=#ffffff\nbackground = #282a36\nforeground = #f8f8f2\ncursor-color = #f8f8f2\ncursor-text = #282a36\nselection-background = #44475a\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Dracula+",
    "content": "palette = 0=#21222c\npalette = 1=#ff5555\npalette = 2=#50fa7b\npalette = 3=#ffcb6b\npalette = 4=#82aaff\npalette = 5=#c792ea\npalette = 6=#8be9fd\npalette = 7=#f8f8f2\npalette = 8=#545454\npalette = 9=#ff6e6e\npalette = 10=#69ff94\npalette = 11=#ffcb6b\npalette = 12=#d6acff\npalette = 13=#ff92df\npalette = 14=#a4ffff\npalette = 15=#f8f8f2\nbackground = #212121\nforeground = #f8f8f2\ncursor-color = #eceff4\ncursor-text = #282828\nselection-background = #f8f8f2\nselection-foreground = #545454\n"
  },
  {
    "path": "ghostty/Resources/themes/Farmhouse Dark",
    "content": "palette = 0=#1d2027\npalette = 1=#ba0004\npalette = 2=#549d00\npalette = 3=#c87300\npalette = 4=#0049e6\npalette = 5=#9f1b61\npalette = 6=#1fb65c\npalette = 7=#e8e4e1\npalette = 8=#464d54\npalette = 9=#eb0009\npalette = 10=#7ac100\npalette = 11=#ea9a00\npalette = 12=#006efe\npalette = 13=#bf3b7f\npalette = 14=#19e062\npalette = 15=#f4eef0\nbackground = #1d2027\nforeground = #e8e4e1\ncursor-color = #006efe\ncursor-text = #e8e4e1\nselection-background = #4d5658\nselection-foreground = #b3b1aa\n"
  },
  {
    "path": "ghostty/Resources/themes/Farmhouse Light",
    "content": "palette = 0=#1d2027\npalette = 1=#8d0003\npalette = 2=#3a7d00\npalette = 3=#a95600\npalette = 4=#092ccd\npalette = 5=#820046\npalette = 6=#229256\npalette = 7=#a8a4a1\npalette = 8=#394047\npalette = 9=#eb0009\npalette = 10=#7ac100\npalette = 11=#ea9a00\npalette = 12=#006efe\npalette = 13=#bf3b7f\npalette = 14=#00c649\npalette = 15=#f4eef0\nbackground = #e8e4e1\nforeground = #1d2027\ncursor-color = #006efe\ncursor-text = #1d2027\nselection-background = #b3b1aa\nselection-foreground = #4d5658\n"
  },
  {
    "path": "ghostty/Resources/themes/Flexoki Dark",
    "content": "palette = 0=#100f0f\npalette = 1=#d14d41\npalette = 2=#879a39\npalette = 3=#d0a215\npalette = 4=#4385be\npalette = 5=#ce5d97\npalette = 6=#3aa99f\npalette = 7=#878580\npalette = 8=#575653\npalette = 9=#af3029\npalette = 10=#66800b\npalette = 11=#ad8301\npalette = 12=#205ea6\npalette = 13=#a02f6f\npalette = 14=#24837b\npalette = 15=#cecdc3\nbackground = #100f0f\nforeground = #cecdc3\ncursor-color = #cecdc3\ncursor-text = #100f0f\nselection-background = #403e3c\nselection-foreground = #cecdc3\n"
  },
  {
    "path": "ghostty/Resources/themes/Flexoki Light",
    "content": "palette = 0=#100f0f\npalette = 1=#af3029\npalette = 2=#66800b\npalette = 3=#ad8301\npalette = 4=#205ea6\npalette = 5=#a02f6f\npalette = 6=#24837b\npalette = 7=#6f6e69\npalette = 8=#b7b5ac\npalette = 9=#d14d41\npalette = 10=#879a39\npalette = 11=#d0a215\npalette = 12=#4385be\npalette = 13=#ce5d97\npalette = 14=#3aa99f\npalette = 15=#cecdc3\nbackground = #fffcf0\nforeground = #100f0f\ncursor-color = #100f0f\ncursor-text = #fffcf0\nselection-background = #cecdc3\nselection-foreground = #100f0f\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub",
    "content": "palette = 0=#3e3e3e\npalette = 1=#970b16\npalette = 2=#07962a\npalette = 3=#c5bb94\npalette = 4=#003e8a\npalette = 5=#e94691\npalette = 6=#7cc4df\npalette = 7=#b2b2b2\npalette = 8=#666666\npalette = 9=#de0000\npalette = 10=#7ac895\npalette = 11=#d7b600\npalette = 12=#2e6cba\npalette = 13=#f29592\npalette = 14=#00c7cb\npalette = 15=#ffffff\nbackground = #f4f4f4\nforeground = #3e3e3e\ncursor-color = #3f3f3f\ncursor-text = #f4f4f4\nselection-background = #a9c1e2\nselection-foreground = #535353\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Dark",
    "content": "palette = 0=#000000\npalette = 1=#f78166\npalette = 2=#56d364\npalette = 3=#e3b341\npalette = 4=#6ca4f8\npalette = 5=#db61a2\npalette = 6=#2b7489\npalette = 7=#ffffff\npalette = 8=#4d4d4d\npalette = 9=#f78166\npalette = 10=#56d364\npalette = 11=#e3b341\npalette = 12=#6ca4f8\npalette = 13=#db61a2\npalette = 14=#2b7489\npalette = 15=#ffffff\nbackground = #101216\nforeground = #8b949e\ncursor-color = #c9d1d9\ncursor-text = #101216\nselection-background = #3b5070\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Dark Colorblind",
    "content": "palette = 0=#484f58\npalette = 1=#ec8e2c\npalette = 2=#58a6ff\npalette = 3=#d29922\npalette = 4=#58a6ff\npalette = 5=#bc8cff\npalette = 6=#39c5cf\npalette = 7=#b1bac4\npalette = 8=#6e7681\npalette = 9=#fdac54\npalette = 10=#79c0ff\npalette = 11=#e3b341\npalette = 12=#79c0ff\npalette = 13=#d2a8ff\npalette = 14=#56d4dd\npalette = 15=#ffffff\nbackground = #0d1117\nforeground = #c9d1d9\ncursor-color = #58a6ff\ncursor-text = #58a6ff\nselection-background = #c9d1d9\nselection-foreground = #0d1117\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Dark Default",
    "content": "palette = 0=#484f58\npalette = 1=#ff7b72\npalette = 2=#3fb950\npalette = 3=#d29922\npalette = 4=#58a6ff\npalette = 5=#bc8cff\npalette = 6=#39c5cf\npalette = 7=#b1bac4\npalette = 8=#6e7681\npalette = 9=#ffa198\npalette = 10=#56d364\npalette = 11=#e3b341\npalette = 12=#79c0ff\npalette = 13=#d2a8ff\npalette = 14=#56d4dd\npalette = 15=#ffffff\nbackground = #0d1117\nforeground = #e6edf3\ncursor-color = #2f81f7\ncursor-text = #2f81f7\nselection-background = #e6edf3\nselection-foreground = #0d1117\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Dark Dimmed",
    "content": "palette = 0=#545d68\npalette = 1=#f47067\npalette = 2=#57ab5a\npalette = 3=#c69026\npalette = 4=#539bf5\npalette = 5=#b083f0\npalette = 6=#39c5cf\npalette = 7=#909dab\npalette = 8=#636e7b\npalette = 9=#ff938a\npalette = 10=#6bc46d\npalette = 11=#daaa3f\npalette = 12=#6cb6ff\npalette = 13=#dcbdfb\npalette = 14=#56d4dd\npalette = 15=#cdd9e5\nbackground = #22272e\nforeground = #adbac7\ncursor-color = #539bf5\ncursor-text = #539bf5\nselection-background = #adbac7\nselection-foreground = #22272e\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Dark High Contrast",
    "content": "palette = 0=#7a828e\npalette = 1=#ff9492\npalette = 2=#26cd4d\npalette = 3=#f0b72f\npalette = 4=#71b7ff\npalette = 5=#cb9eff\npalette = 6=#39c5cf\npalette = 7=#d9dee3\npalette = 8=#9ea7b3\npalette = 9=#ffb1af\npalette = 10=#4ae168\npalette = 11=#f7c843\npalette = 12=#91cbff\npalette = 13=#dbb7ff\npalette = 14=#56d4dd\npalette = 15=#ffffff\nbackground = #0a0c10\nforeground = #f0f3f6\ncursor-color = #71b7ff\ncursor-text = #71b7ff\nselection-background = #f0f3f6\nselection-foreground = #0a0c10\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Light Colorblind",
    "content": "palette = 0=#24292f\npalette = 1=#b35900\npalette = 2=#0550ae\npalette = 3=#4d2d00\npalette = 4=#0969da\npalette = 5=#8250df\npalette = 6=#1b7c83\npalette = 7=#6e7781\npalette = 8=#57606a\npalette = 9=#8a4600\npalette = 10=#0969da\npalette = 11=#633c01\npalette = 12=#218bff\npalette = 13=#a475f9\npalette = 14=#3192aa\npalette = 15=#8c959f\nbackground = #ffffff\nforeground = #24292f\ncursor-color = #0969da\ncursor-text = #0969da\nselection-background = #24292f\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Light Default",
    "content": "palette = 0=#24292f\npalette = 1=#cf222e\npalette = 2=#116329\npalette = 3=#4d2d00\npalette = 4=#0969da\npalette = 5=#8250df\npalette = 6=#1b7c83\npalette = 7=#6e7781\npalette = 8=#57606a\npalette = 9=#a40e26\npalette = 10=#1a7f37\npalette = 11=#633c01\npalette = 12=#218bff\npalette = 13=#a475f9\npalette = 14=#3192aa\npalette = 15=#8c959f\nbackground = #ffffff\nforeground = #1f2328\ncursor-color = #0969da\ncursor-text = #0969da\nselection-background = #1f2328\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/GitHub Light High Contrast",
    "content": "palette = 0=#0e1116\npalette = 1=#a0111f\npalette = 2=#024c1a\npalette = 3=#3f2200\npalette = 4=#0349b4\npalette = 5=#622cbc\npalette = 6=#1b7c83\npalette = 7=#66707b\npalette = 8=#4b535d\npalette = 9=#86061d\npalette = 10=#055d20\npalette = 11=#4e2c00\npalette = 12=#1168e3\npalette = 13=#844ae7\npalette = 14=#3192aa\npalette = 15=#88929d\nbackground = #ffffff\nforeground = #0e1116\ncursor-color = #0349b4\ncursor-text = #0349b4\nselection-background = #0e1116\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/GitLab Dark",
    "content": "palette = 0=#000000\npalette = 1=#f57f6c\npalette = 2=#52b87a\npalette = 3=#d99530\npalette = 4=#7fb6ed\npalette = 5=#f88aaf\npalette = 6=#32c5d2\npalette = 7=#ffffff\npalette = 8=#666666\npalette = 9=#fcb5aa\npalette = 10=#91d4a8\npalette = 11=#e9be74\npalette = 12=#498dd1\npalette = 13=#fcacc5\npalette = 14=#5edee3\npalette = 15=#ffffff\nbackground = #28262b\nforeground = #ffffff\ncursor-color = #ffffff\ncursor-text = #ffffff\nselection-background = #ad95e9\nselection-foreground = #28262b\n"
  },
  {
    "path": "ghostty/Resources/themes/GitLab Dark Grey",
    "content": "palette = 0=#000000\npalette = 1=#f57f6c\npalette = 2=#52b87a\npalette = 3=#d99530\npalette = 4=#7fb6ed\npalette = 5=#f88aaf\npalette = 6=#32c5d2\npalette = 7=#ffffff\npalette = 8=#666666\npalette = 9=#fcb5aa\npalette = 10=#91d4a8\npalette = 11=#e9be74\npalette = 12=#498dd1\npalette = 13=#fcacc5\npalette = 14=#5edee3\npalette = 15=#ffffff\nbackground = #222222\nforeground = #ffffff\ncursor-color = #ffffff\ncursor-text = #ffffff\nselection-background = #ad95e9\nselection-foreground = #222222\n"
  },
  {
    "path": "ghostty/Resources/themes/GitLab Light",
    "content": "palette = 0=#303030\npalette = 1=#a31700\npalette = 2=#0a7f3d\npalette = 3=#af551d\npalette = 4=#006cd8\npalette = 5=#583cac\npalette = 6=#00798a\npalette = 7=#303030\npalette = 8=#303030\npalette = 9=#a31700\npalette = 10=#0a7f3d\npalette = 11=#af551d\npalette = 12=#006cd8\npalette = 13=#583cac\npalette = 14=#00798a\npalette = 15=#303030\nbackground = #fafaff\nforeground = #303030\ncursor-color = #303030\ncursor-text = #303030\nselection-background = #ad95e9\nselection-foreground = #fafaff\n"
  },
  {
    "path": "ghostty/Resources/themes/Iceberg Dark",
    "content": "palette = 0=#1e2132\npalette = 1=#e27878\npalette = 2=#b4be82\npalette = 3=#e2a478\npalette = 4=#84a0c6\npalette = 5=#a093c7\npalette = 6=#89b8c2\npalette = 7=#c6c8d1\npalette = 8=#6b7089\npalette = 9=#e98989\npalette = 10=#c0ca8e\npalette = 11=#e9b189\npalette = 12=#91acd1\npalette = 13=#ada0d3\npalette = 14=#95c4ce\npalette = 15=#d2d4de\nbackground = #161821\nforeground = #c6c8d1\ncursor-color = #c6c8d1\ncursor-text = #161821\nselection-background = #c6c8d1\nselection-foreground = #161821\n"
  },
  {
    "path": "ghostty/Resources/themes/Iceberg Light",
    "content": "palette = 0=#dcdfe7\npalette = 1=#cc517a\npalette = 2=#668e3d\npalette = 3=#c57339\npalette = 4=#2d539e\npalette = 5=#7759b4\npalette = 6=#3f83a6\npalette = 7=#33374c\npalette = 8=#8389a3\npalette = 9=#cc3768\npalette = 10=#598030\npalette = 11=#b6662d\npalette = 12=#22478e\npalette = 13=#6845ad\npalette = 14=#327698\npalette = 15=#262a3f\nbackground = #e8e9ec\nforeground = #33374c\ncursor-color = #33374c\ncursor-text = #e8e9ec\nselection-background = #33374c\nselection-foreground = #e8e9ec\n"
  },
  {
    "path": "ghostty/Resources/themes/Material",
    "content": "palette = 0=#212121\npalette = 1=#b7141f\npalette = 2=#457b24\npalette = 3=#f6981e\npalette = 4=#134eb2\npalette = 5=#560088\npalette = 6=#0e717c\npalette = 7=#afafaf\npalette = 8=#424242\npalette = 9=#e83b3f\npalette = 10=#7aba3a\npalette = 11=#bfaa00\npalette = 12=#54a4f3\npalette = 13=#aa4dbc\npalette = 14=#26bbd1\npalette = 15=#d9d9d9\nbackground = #eaeaea\nforeground = #232322\ncursor-color = #16afca\ncursor-text = #2e2e2d\nselection-background = #c2c2c2\nselection-foreground = #4e4e4e\n"
  },
  {
    "path": "ghostty/Resources/themes/Material Dark",
    "content": "palette = 0=#212121\npalette = 1=#b7141f\npalette = 2=#457b24\npalette = 3=#f6981e\npalette = 4=#134eb2\npalette = 5=#6f1aa1\npalette = 6=#0e717c\npalette = 7=#efefef\npalette = 8=#4f4f4f\npalette = 9=#e83b3f\npalette = 10=#7aba3a\npalette = 11=#ffea2e\npalette = 12=#54a4f3\npalette = 13=#aa4dbc\npalette = 14=#26bbd1\npalette = 15=#d9d9d9\nbackground = #232322\nforeground = #e5e5e5\ncursor-color = #16afca\ncursor-text = #dfdfdf\nselection-background = #dfdfdf\nselection-foreground = #3d3d3d\n"
  },
  {
    "path": "ghostty/Resources/themes/Material Darker",
    "content": "palette = 0=#000000\npalette = 1=#ff5370\npalette = 2=#c3e88d\npalette = 3=#ffcb6b\npalette = 4=#82aaff\npalette = 5=#c792ea\npalette = 6=#89ddff\npalette = 7=#ffffff\npalette = 8=#545454\npalette = 9=#ff5370\npalette = 10=#c3e88d\npalette = 11=#ffcb6b\npalette = 12=#82aaff\npalette = 13=#c792ea\npalette = 14=#89ddff\npalette = 15=#ffffff\nbackground = #212121\nforeground = #eeffff\ncursor-color = #ffffff\ncursor-text = #ffffff\nselection-background = #eeffff\nselection-foreground = #545454\n"
  },
  {
    "path": "ghostty/Resources/themes/Material Design Colors",
    "content": "palette = 0=#435b67\npalette = 1=#fc3841\npalette = 2=#5cf19e\npalette = 3=#fed032\npalette = 4=#37b6ff\npalette = 5=#fc226e\npalette = 6=#59ffd1\npalette = 7=#ffffff\npalette = 8=#a1b0b8\npalette = 9=#fc746d\npalette = 10=#adf7be\npalette = 11=#fee16c\npalette = 12=#70cfff\npalette = 13=#fc669b\npalette = 14=#9affe6\npalette = 15=#ffffff\nbackground = #1d262a\nforeground = #e7ebed\ncursor-color = #eaeaea\ncursor-text = #000000\nselection-background = #4e6a78\nselection-foreground = #e7ebed\n"
  },
  {
    "path": "ghostty/Resources/themes/Material Ocean",
    "content": "palette = 0=#546e7a\npalette = 1=#ff5370\npalette = 2=#c3e88d\npalette = 3=#ffcb6b\npalette = 4=#82aaff\npalette = 5=#c792ea\npalette = 6=#89ddff\npalette = 7=#ffffff\npalette = 8=#546e7a\npalette = 9=#ff5370\npalette = 10=#c3e88d\npalette = 11=#ffcb6b\npalette = 12=#82aaff\npalette = 13=#c792ea\npalette = 14=#89ddff\npalette = 15=#ffffff\nbackground = #0f111a\nforeground = #8f93a2\ncursor-color = #ffcc00\ncursor-text = #0f111a\nselection-background = #1f2233\nselection-foreground = #8f93a2\n"
  },
  {
    "path": "ghostty/Resources/themes/Melange Dark",
    "content": "palette = 0=#34302c\npalette = 1=#bd8183\npalette = 2=#78997a\npalette = 3=#e49b5d\npalette = 4=#7f91b2\npalette = 5=#b380b0\npalette = 6=#7b9695\npalette = 7=#c1a78e\npalette = 8=#867462\npalette = 9=#d47766\npalette = 10=#85b695\npalette = 11=#ebc06d\npalette = 12=#a3a9ce\npalette = 13=#cf9bc2\npalette = 14=#89b3b6\npalette = 15=#ece1d7\nbackground = #292522\nforeground = #ece1d7\ncursor-color = #ece1d7\ncursor-text = #292522\nselection-background = #ece1d7\nselection-foreground = #403a36\n"
  },
  {
    "path": "ghostty/Resources/themes/Melange Light",
    "content": "palette = 0=#e9e1db\npalette = 1=#c77b8b\npalette = 2=#6e9b72\npalette = 3=#bc5c00\npalette = 4=#7892bd\npalette = 5=#be79bb\npalette = 6=#739797\npalette = 7=#7d6658\npalette = 8=#a98a78\npalette = 9=#bf0021\npalette = 10=#3a684a\npalette = 11=#a06d00\npalette = 12=#465aa4\npalette = 13=#904180\npalette = 14=#3d6568\npalette = 15=#54433a\nbackground = #f1f1f1\nforeground = #54433a\ncursor-color = #54433a\ncursor-text = #f1f1f1\nselection-background = #54433a\nselection-foreground = #d9d3ce\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro",
    "content": "palette = 0=#2d2a2e\npalette = 1=#ff6188\npalette = 2=#a9dc76\npalette = 3=#ffd866\npalette = 4=#fc9867\npalette = 5=#ab9df2\npalette = 6=#78dce8\npalette = 7=#fcfcfa\npalette = 8=#727072\npalette = 9=#ff6188\npalette = 10=#a9dc76\npalette = 11=#ffd866\npalette = 12=#fc9867\npalette = 13=#ab9df2\npalette = 14=#78dce8\npalette = 15=#fcfcfa\nbackground = #2d2a2e\nforeground = #fcfcfa\ncursor-color = #c1c0c0\ncursor-text = #c1c0c0\nselection-background = #5b595c\nselection-foreground = #fcfcfa\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Light",
    "content": "palette = 0=#faf4f2\npalette = 1=#e14775\npalette = 2=#269d69\npalette = 3=#cc7a0a\npalette = 4=#e16032\npalette = 5=#7058be\npalette = 6=#1c8ca8\npalette = 7=#29242a\npalette = 8=#a59fa0\npalette = 9=#e14775\npalette = 10=#269d69\npalette = 11=#cc7a0a\npalette = 12=#e16032\npalette = 13=#7058be\npalette = 14=#1c8ca8\npalette = 15=#29242a\nbackground = #faf4f2\nforeground = #29242a\ncursor-color = #706b6e\ncursor-text = #706b6e\nselection-background = #bfb9ba\nselection-foreground = #29242a\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Light Sun",
    "content": "palette = 0=#f8efe7\npalette = 1=#ce4770\npalette = 2=#218871\npalette = 3=#b16803\npalette = 4=#d4572b\npalette = 5=#6851a2\npalette = 6=#2473b6\npalette = 7=#2c232e\npalette = 8=#a59c9c\npalette = 9=#ce4770\npalette = 10=#218871\npalette = 11=#b16803\npalette = 12=#d4572b\npalette = 13=#6851a2\npalette = 14=#2473b6\npalette = 15=#2c232e\nbackground = #f8efe7\nforeground = #2c232e\ncursor-color = #72696d\ncursor-text = #72696d\nselection-background = #beb5b3\nselection-foreground = #2c232e\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Machine",
    "content": "palette = 0=#273136\npalette = 1=#ff6d7e\npalette = 2=#a2e57b\npalette = 3=#ffed72\npalette = 4=#ffb270\npalette = 5=#baa0f8\npalette = 6=#7cd5f1\npalette = 7=#f2fffc\npalette = 8=#6b7678\npalette = 9=#ff6d7e\npalette = 10=#a2e57b\npalette = 11=#ffed72\npalette = 12=#ffb270\npalette = 13=#baa0f8\npalette = 14=#7cd5f1\npalette = 15=#f2fffc\nbackground = #273136\nforeground = #f2fffc\ncursor-color = #b8c4c3\ncursor-text = #b8c4c3\nselection-background = #545f62\nselection-foreground = #f2fffc\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Octagon",
    "content": "palette = 0=#282a3a\npalette = 1=#ff657a\npalette = 2=#bad761\npalette = 3=#ffd76d\npalette = 4=#ff9b5e\npalette = 5=#c39ac9\npalette = 6=#9cd1bb\npalette = 7=#eaf2f1\npalette = 8=#696d77\npalette = 9=#ff657a\npalette = 10=#bad761\npalette = 11=#ffd76d\npalette = 12=#ff9b5e\npalette = 13=#c39ac9\npalette = 14=#9cd1bb\npalette = 15=#eaf2f1\nbackground = #282a3a\nforeground = #eaf2f1\ncursor-color = #b2b9bd\ncursor-text = #b2b9bd\nselection-background = #535763\nselection-foreground = #eaf2f1\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Ristretto",
    "content": "palette = 0=#2c2525\npalette = 1=#fd6883\npalette = 2=#adda78\npalette = 3=#f9cc6c\npalette = 4=#f38d70\npalette = 5=#a8a9eb\npalette = 6=#85dacc\npalette = 7=#fff1f3\npalette = 8=#72696a\npalette = 9=#fd6883\npalette = 10=#adda78\npalette = 11=#f9cc6c\npalette = 12=#f38d70\npalette = 13=#a8a9eb\npalette = 14=#85dacc\npalette = 15=#fff1f3\nbackground = #2c2525\nforeground = #fff1f3\ncursor-color = #c3b7b8\ncursor-text = #c3b7b8\nselection-background = #5b5353\nselection-foreground = #fff1f3\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Pro Spectrum",
    "content": "palette = 0=#222222\npalette = 1=#fc618d\npalette = 2=#7bd88f\npalette = 3=#fce566\npalette = 4=#fd9353\npalette = 5=#948ae3\npalette = 6=#5ad4e6\npalette = 7=#f7f1ff\npalette = 8=#69676c\npalette = 9=#fc618d\npalette = 10=#7bd88f\npalette = 11=#fce566\npalette = 12=#fd9353\npalette = 13=#948ae3\npalette = 14=#5ad4e6\npalette = 15=#f7f1ff\nbackground = #222222\nforeground = #f7f1ff\ncursor-color = #bab6c0\ncursor-text = #bab6c0\nselection-background = #525053\nselection-foreground = #f7f1ff\n"
  },
  {
    "path": "ghostty/Resources/themes/Monokai Remastered",
    "content": "palette = 0=#1a1a1a\npalette = 1=#f4005f\npalette = 2=#98e024\npalette = 3=#fd971f\npalette = 4=#9d65ff\npalette = 5=#f4005f\npalette = 6=#58d1eb\npalette = 7=#c4c5b5\npalette = 8=#625e4c\npalette = 9=#f4005f\npalette = 10=#98e024\npalette = 11=#e0d561\npalette = 12=#9d65ff\npalette = 13=#f4005f\npalette = 14=#58d1eb\npalette = 15=#f6f6ef\nbackground = #0c0c0c\nforeground = #d9d9d9\ncursor-color = #fc971f\ncursor-text = #000000\nselection-background = #343434\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Neobones Dark",
    "content": "palette = 0=#0f191f\npalette = 1=#de6e7c\npalette = 2=#90ff6b\npalette = 3=#b77e64\npalette = 4=#8190d4\npalette = 5=#b279a7\npalette = 6=#66a5ad\npalette = 7=#c6d5cf\npalette = 8=#334652\npalette = 9=#e8838f\npalette = 10=#a0ff85\npalette = 11=#d68c67\npalette = 12=#92a0e2\npalette = 13=#cf86c1\npalette = 14=#65b8c1\npalette = 15=#98a39e\nbackground = #0f191f\nforeground = #c6d5cf\ncursor-color = #ceddd7\ncursor-text = #0f191f\nselection-background = #3a3e3d\nselection-foreground = #c6d5cf\n"
  },
  {
    "path": "ghostty/Resources/themes/Neobones Light",
    "content": "palette = 0=#e5ede6\npalette = 1=#a8334c\npalette = 2=#567a30\npalette = 3=#944927\npalette = 4=#286486\npalette = 5=#88507d\npalette = 6=#3b8992\npalette = 7=#202e18\npalette = 8=#99ac9c\npalette = 9=#94253e\npalette = 10=#3f5a22\npalette = 11=#803d1c\npalette = 12=#1d5573\npalette = 13=#7b3b70\npalette = 14=#2b747c\npalette = 15=#415934\nbackground = #e5ede6\nforeground = #202e18\ncursor-color = #202e18\ncursor-text = #e5ede6\nselection-background = #ade48c\nselection-foreground = #202e18\n"
  },
  {
    "path": "ghostty/Resources/themes/Nvim Dark",
    "content": "palette = 0=#07080d\npalette = 1=#ffc0b9\npalette = 2=#b3f6c0\npalette = 3=#fce094\npalette = 4=#a6dbff\npalette = 5=#ffcaff\npalette = 6=#8cf8f7\npalette = 7=#eef1f8\npalette = 8=#4f5258\npalette = 9=#ffc0b9\npalette = 10=#b3f6c0\npalette = 11=#fce094\npalette = 12=#a6dbff\npalette = 13=#ffcaff\npalette = 14=#8cf8f7\npalette = 15=#eef1f8\nbackground = #14161b\nforeground = #e0e2ea\ncursor-color = #9b9ea4\ncursor-text = #e0e2ea\nselection-background = #4f5258\nselection-foreground = #e0e2ea\n"
  },
  {
    "path": "ghostty/Resources/themes/Nvim Light",
    "content": "palette = 0=#07080d\npalette = 1=#590008\npalette = 2=#005523\npalette = 3=#6b5300\npalette = 4=#004c73\npalette = 5=#470045\npalette = 6=#007373\npalette = 7=#a2a5ac\npalette = 8=#4f5258\npalette = 9=#590008\npalette = 10=#005523\npalette = 11=#6b5300\npalette = 12=#004c73\npalette = 13=#470045\npalette = 14=#007373\npalette = 15=#eef1f8\nbackground = #e0e2ea\nforeground = #14161b\ncursor-color = #9b9ea4\ncursor-text = #14161b\nselection-background = #9b9ea4\nselection-foreground = #14161b\n"
  },
  {
    "path": "ghostty/Resources/themes/One Double Dark",
    "content": "palette = 0=#3d4452\npalette = 1=#f16372\npalette = 2=#8cc570\npalette = 3=#ecbe70\npalette = 4=#3fb1f5\npalette = 5=#d373e3\npalette = 6=#17b9c4\npalette = 7=#dbdfe5\npalette = 8=#525d6f\npalette = 9=#ff777b\npalette = 10=#82d882\npalette = 11=#f5c065\npalette = 12=#6dcaff\npalette = 13=#ff7bf4\npalette = 14=#00e5fb\npalette = 15=#f7f9fc\nbackground = #282c34\nforeground = #dbdfe5\ncursor-color = #f5e0dc\ncursor-text = #cdd6f4\nselection-background = #585b70\nselection-foreground = #cdd6f4\n"
  },
  {
    "path": "ghostty/Resources/themes/One Double Light",
    "content": "palette = 0=#454b58\npalette = 1=#f74840\npalette = 2=#25a343\npalette = 3=#cc8100\npalette = 4=#0087c1\npalette = 5=#b50da9\npalette = 6=#009ab7\npalette = 7=#c9b1b2\npalette = 8=#0e131f\npalette = 9=#ff3711\npalette = 10=#00b90e\npalette = 11=#ec9900\npalette = 12=#1065de\npalette = 13=#e500d8\npalette = 14=#00b4dd\npalette = 15=#ffffff\nbackground = #fafafa\nforeground = #383a43\ncursor-color = #1a1919\ncursor-text = #dbdfe5\nselection-background = #454e5e\nselection-foreground = #1a1919\n"
  },
  {
    "path": "ghostty/Resources/themes/One Half Dark",
    "content": "palette = 0=#282c34\npalette = 1=#e06c75\npalette = 2=#98c379\npalette = 3=#e5c07b\npalette = 4=#61afef\npalette = 5=#c678dd\npalette = 6=#56b6c2\npalette = 7=#dcdfe4\npalette = 8=#5d677a\npalette = 9=#e06c75\npalette = 10=#98c379\npalette = 11=#e5c07b\npalette = 12=#61afef\npalette = 13=#c678dd\npalette = 14=#56b6c2\npalette = 15=#dcdfe4\nbackground = #282c34\nforeground = #dcdfe4\ncursor-color = #a3b3cc\ncursor-text = #dcdfe4\nselection-background = #474e5d\nselection-foreground = #dcdfe4\n"
  },
  {
    "path": "ghostty/Resources/themes/One Half Light",
    "content": "palette = 0=#383a42\npalette = 1=#e45649\npalette = 2=#50a14f\npalette = 3=#c18401\npalette = 4=#0184bc\npalette = 5=#a626a4\npalette = 6=#0997b3\npalette = 7=#bababa\npalette = 8=#4f525e\npalette = 9=#e06c75\npalette = 10=#98c379\npalette = 11=#d8b36e\npalette = 12=#61afef\npalette = 13=#c678dd\npalette = 14=#56b6c2\npalette = 15=#ffffff\nbackground = #fafafa\nforeground = #383a42\ncursor-color = #a5b5e5\ncursor-text = #383a42\nselection-background = #bfceff\nselection-foreground = #383a42\n"
  },
  {
    "path": "ghostty/Resources/themes/Pencil Dark",
    "content": "palette = 0=#212121\npalette = 1=#c30771\npalette = 2=#10a778\npalette = 3=#a89c14\npalette = 4=#008ec4\npalette = 5=#5f4986\npalette = 6=#20a5ba\npalette = 7=#d9d9d9\npalette = 8=#4f4f4f\npalette = 9=#fb007a\npalette = 10=#5fd7af\npalette = 11=#f3e430\npalette = 12=#20bbfc\npalette = 13=#6855de\npalette = 14=#4fb8cc\npalette = 15=#f1f1f1\nbackground = #212121\nforeground = #f1f1f1\ncursor-color = #20bbfc\ncursor-text = #f1f1f1\nselection-background = #b6d6fd\nselection-foreground = #989898\n"
  },
  {
    "path": "ghostty/Resources/themes/Pencil Light",
    "content": "palette = 0=#212121\npalette = 1=#c30771\npalette = 2=#10a778\npalette = 3=#a89c14\npalette = 4=#008ec4\npalette = 5=#523c79\npalette = 6=#20a5ba\npalette = 7=#b3b3b3\npalette = 8=#424242\npalette = 9=#fb007a\npalette = 10=#52caa2\npalette = 11=#c0b100\npalette = 12=#20bbfc\npalette = 13=#6855de\npalette = 14=#4fb8cc\npalette = 15=#f1f1f1\nbackground = #f1f1f1\nforeground = #424242\ncursor-color = #20bbfc\ncursor-text = #424242\nselection-background = #b6d6fd\nselection-foreground = #424242\n"
  },
  {
    "path": "ghostty/Resources/themes/Raycast Dark",
    "content": "palette = 0=#000000\npalette = 1=#ff5360\npalette = 2=#59d499\npalette = 3=#ffc531\npalette = 4=#56c2ff\npalette = 5=#cf2f98\npalette = 6=#52eee5\npalette = 7=#ffffff\npalette = 8=#4c4c4c\npalette = 9=#ff6363\npalette = 10=#59d499\npalette = 11=#ffc531\npalette = 12=#56c2ff\npalette = 13=#cf2f98\npalette = 14=#52eee5\npalette = 15=#ffffff\nbackground = #1a1a1a\nforeground = #ffffff\ncursor-color = #cccccc\ncursor-text = #ffffff\nselection-background = #333333\nselection-foreground = #595959\n"
  },
  {
    "path": "ghostty/Resources/themes/Raycast Light",
    "content": "palette = 0=#000000\npalette = 1=#b12424\npalette = 2=#006b4f\npalette = 3=#f8a300\npalette = 4=#138af2\npalette = 5=#9a1b6e\npalette = 6=#3eb8bf\npalette = 7=#bfbfbf\npalette = 8=#000000\npalette = 9=#b12424\npalette = 10=#006b4f\npalette = 11=#f8a300\npalette = 12=#138af2\npalette = 13=#9a1b6e\npalette = 14=#3eb8bf\npalette = 15=#ffffff\nbackground = #ffffff\nforeground = #000000\ncursor-color = #000000\ncursor-text = #000000\nselection-background = #e5e5e5\nselection-foreground = #000000\n"
  },
  {
    "path": "ghostty/Resources/themes/Selenized Dark",
    "content": "palette = 0=#184956\npalette = 1=#fa5750\npalette = 2=#75b938\npalette = 3=#dbb32d\npalette = 4=#4695f7\npalette = 5=#f275be\npalette = 6=#41c7b9\npalette = 7=#72898f\npalette = 8=#396775\npalette = 9=#ff665c\npalette = 10=#84c747\npalette = 11=#ebc13d\npalette = 12=#58a3ff\npalette = 13=#ff84cd\npalette = 14=#53d6c7\npalette = 15=#cad8d9\nbackground = #103c48\nforeground = #adbcbc\ncursor-color = #adbcbc\ncursor-text = #adbcbc\nselection-background = #184956\nselection-foreground = #53d6c7\n"
  },
  {
    "path": "ghostty/Resources/themes/Selenized Light",
    "content": "palette = 0=#ece3cc\npalette = 1=#d2212d\npalette = 2=#489100\npalette = 3=#ad8900\npalette = 4=#0072d4\npalette = 5=#ca4898\npalette = 6=#009c8f\npalette = 7=#909995\npalette = 8=#bbb39c\npalette = 9=#cc1729\npalette = 10=#428b00\npalette = 11=#a78300\npalette = 12=#006dce\npalette = 13=#c44392\npalette = 14=#00978a\npalette = 15=#3a4d53\nbackground = #fbf3db\nforeground = #53676d\ncursor-color = #53676d\ncursor-text = #53676d\nselection-background = #ece3cc\nselection-foreground = #00978a\n"
  },
  {
    "path": "ghostty/Resources/themes/Seoulbones Dark",
    "content": "palette = 0=#4b4b4b\npalette = 1=#e388a3\npalette = 2=#98bd99\npalette = 3=#ffdf9b\npalette = 4=#97bdde\npalette = 5=#a5a6c5\npalette = 6=#6fbdbe\npalette = 7=#dddddd\npalette = 8=#797172\npalette = 9=#eb99b1\npalette = 10=#8fcd92\npalette = 11=#ffe5b3\npalette = 12=#a2c8e9\npalette = 13=#b2b3da\npalette = 14=#6bcacb\npalette = 15=#a8a8a8\nbackground = #4b4b4b\nforeground = #dddddd\ncursor-color = #e2e2e2\ncursor-text = #4b4b4b\nselection-background = #777777\nselection-foreground = #dddddd\n"
  },
  {
    "path": "ghostty/Resources/themes/Seoulbones Light",
    "content": "palette = 0=#e2e2e2\npalette = 1=#dc5284\npalette = 2=#628562\npalette = 3=#c48562\npalette = 4=#0084a3\npalette = 5=#896788\npalette = 6=#008586\npalette = 7=#555555\npalette = 8=#a5a0a1\npalette = 9=#be3c6d\npalette = 10=#487249\npalette = 11=#a76b48\npalette = 12=#006f89\npalette = 13=#7f4c7e\npalette = 14=#006f70\npalette = 15=#777777\nbackground = #e2e2e2\nforeground = #555555\ncursor-color = #555555\ncursor-text = #e2e2e2\nselection-background = #cccccc\nselection-foreground = #555555\n"
  },
  {
    "path": "ghostty/Resources/themes/Tinacious Design Dark",
    "content": "palette = 0=#1d1d26\npalette = 1=#ff3399\npalette = 2=#00d364\npalette = 3=#ffcc66\npalette = 4=#00cbff\npalette = 5=#cc66ff\npalette = 6=#00ceca\npalette = 7=#cbcbf0\npalette = 8=#636667\npalette = 9=#ff2f92\npalette = 10=#00d364\npalette = 11=#ffd479\npalette = 12=#00cbff\npalette = 13=#d783ff\npalette = 14=#00d5d4\npalette = 15=#d5d6f3\nbackground = #1d1d26\nforeground = #cbcbf0\ncursor-color = #cbcbf0\ncursor-text = #ffffff\nselection-background = #ff3399\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Tinacious Design Light",
    "content": "palette = 0=#1d1d26\npalette = 1=#ff3399\npalette = 2=#00d364\npalette = 3=#e5b34d\npalette = 4=#00cbff\npalette = 5=#cc66ff\npalette = 6=#00ceca\npalette = 7=#b2b2d7\npalette = 8=#636667\npalette = 9=#ff2f92\npalette = 10=#00d364\npalette = 11=#d9ae52\npalette = 12=#00cbff\npalette = 13=#d783ff\npalette = 14=#00c8c7\npalette = 15=#d5d6f3\nbackground = #f8f8ff\nforeground = #1d1d26\ncursor-color = #b2b2d7\ncursor-text = #ffffff\nselection-background = #ff3399\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/TokyoNight",
    "content": "palette = 0=#15161e\npalette = 1=#f7768e\npalette = 2=#9ece6a\npalette = 3=#e0af68\npalette = 4=#7aa2f7\npalette = 5=#bb9af7\npalette = 6=#7dcfff\npalette = 7=#a9b1d6\npalette = 8=#414868\npalette = 9=#f7768e\npalette = 10=#9ece6a\npalette = 11=#e0af68\npalette = 12=#7aa2f7\npalette = 13=#bb9af7\npalette = 14=#7dcfff\npalette = 15=#c0caf5\nbackground = #1a1b26\nforeground = #c0caf5\ncursor-color = #c0caf5\ncursor-text = #15161e\nselection-background = #33467c\nselection-foreground = #c0caf5\n"
  },
  {
    "path": "ghostty/Resources/themes/TokyoNight Day",
    "content": "palette = 0=#e9e9ed\npalette = 1=#f52a65\npalette = 2=#587539\npalette = 3=#8c6c3e\npalette = 4=#2e7de9\npalette = 5=#9854f1\npalette = 6=#007197\npalette = 7=#6172b0\npalette = 8=#a1a6c5\npalette = 9=#f52a65\npalette = 10=#587539\npalette = 11=#8c6c3e\npalette = 12=#2e7de9\npalette = 13=#9854f1\npalette = 14=#007197\npalette = 15=#3760bf\nbackground = #e1e2e7\nforeground = #3760bf\ncursor-color = #3760bf\ncursor-text = #e1e2e7\nselection-background = #99a7df\nselection-foreground = #3760bf\n"
  },
  {
    "path": "ghostty/Resources/themes/TokyoNight Moon",
    "content": "palette = 0=#1b1d2b\npalette = 1=#ff757f\npalette = 2=#c3e88d\npalette = 3=#ffc777\npalette = 4=#82aaff\npalette = 5=#c099ff\npalette = 6=#86e1fc\npalette = 7=#828bb8\npalette = 8=#444a73\npalette = 9=#ff757f\npalette = 10=#c3e88d\npalette = 11=#ffc777\npalette = 12=#82aaff\npalette = 13=#c099ff\npalette = 14=#86e1fc\npalette = 15=#c8d3f5\nbackground = #222436\nforeground = #c8d3f5\ncursor-color = #c8d3f5\ncursor-text = #222436\nselection-background = #2d3f76\nselection-foreground = #c8d3f5\n"
  },
  {
    "path": "ghostty/Resources/themes/TokyoNight Night",
    "content": "palette = 0=#15161e\npalette = 1=#f7768e\npalette = 2=#9ece6a\npalette = 3=#e0af68\npalette = 4=#7aa2f7\npalette = 5=#bb9af7\npalette = 6=#7dcfff\npalette = 7=#a9b1d6\npalette = 8=#414868\npalette = 9=#f7768e\npalette = 10=#9ece6a\npalette = 11=#e0af68\npalette = 12=#7aa2f7\npalette = 13=#bb9af7\npalette = 14=#7dcfff\npalette = 15=#c0caf5\nbackground = #1a1b26\nforeground = #c0caf5\ncursor-color = #c0caf5\ncursor-text = #1a1b26\nselection-background = #283457\nselection-foreground = #c0caf5\n"
  },
  {
    "path": "ghostty/Resources/themes/TokyoNight Storm",
    "content": "palette = 0=#1d202f\npalette = 1=#f7768e\npalette = 2=#9ece6a\npalette = 3=#e0af68\npalette = 4=#7aa2f7\npalette = 5=#bb9af7\npalette = 6=#7dcfff\npalette = 7=#a9b1d6\npalette = 8=#4e5575\npalette = 9=#f7768e\npalette = 10=#9ece6a\npalette = 11=#e0af68\npalette = 12=#7aa2f7\npalette = 13=#bb9af7\npalette = 14=#7dcfff\npalette = 15=#c0caf5\nbackground = #24283b\nforeground = #c0caf5\ncursor-color = #c0caf5\ncursor-text = #1d202f\nselection-background = #364a82\nselection-foreground = #c0caf5\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow",
    "content": "palette = 0=#000000\npalette = 1=#c82829\npalette = 2=#718c00\npalette = 3=#eab700\npalette = 4=#4271ae\npalette = 5=#8959a8\npalette = 6=#3e999f\npalette = 7=#bfbfbf\npalette = 8=#000000\npalette = 9=#c82829\npalette = 10=#718c00\npalette = 11=#eab700\npalette = 12=#4271ae\npalette = 13=#8959a8\npalette = 14=#3e999f\npalette = 15=#ffffff\nbackground = #ffffff\nforeground = #4d4d4c\ncursor-color = #4d4d4c\ncursor-text = #ffffff\nselection-background = #d6d6d6\nselection-foreground = #4d4d4c\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow Night",
    "content": "palette = 0=#000000\npalette = 1=#cc6666\npalette = 2=#b5bd68\npalette = 3=#f0c674\npalette = 4=#81a2be\npalette = 5=#b294bb\npalette = 6=#8abeb7\npalette = 7=#ffffff\npalette = 8=#4c4c4c\npalette = 9=#cc6666\npalette = 10=#b5bd68\npalette = 11=#f0c674\npalette = 12=#81a2be\npalette = 13=#b294bb\npalette = 14=#8abeb7\npalette = 15=#ffffff\nbackground = #1d1f21\nforeground = #c5c8c6\ncursor-color = #c5c8c6\ncursor-text = #1d1f21\nselection-background = #373b41\nselection-foreground = #c5c8c6\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow Night Blue",
    "content": "palette = 0=#000000\npalette = 1=#ff9da4\npalette = 2=#d1f1a9\npalette = 3=#ffeead\npalette = 4=#bbdaff\npalette = 5=#ebbbff\npalette = 6=#99ffff\npalette = 7=#ffffff\npalette = 8=#4c4c4c\npalette = 9=#ff9da4\npalette = 10=#d1f1a9\npalette = 11=#ffeead\npalette = 12=#bbdaff\npalette = 13=#ebbbff\npalette = 14=#99ffff\npalette = 15=#ffffff\nbackground = #002451\nforeground = #ffffff\ncursor-color = #ffffff\ncursor-text = #003f8e\nselection-background = #003f8e\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow Night Bright",
    "content": "palette = 0=#000000\npalette = 1=#d54e53\npalette = 2=#b9ca4a\npalette = 3=#e7c547\npalette = 4=#7aa6da\npalette = 5=#c397d8\npalette = 6=#70c0b1\npalette = 7=#ffffff\npalette = 8=#404040\npalette = 9=#d54e53\npalette = 10=#b9ca4a\npalette = 11=#e7c547\npalette = 12=#7aa6da\npalette = 13=#c397d8\npalette = 14=#70c0b1\npalette = 15=#ffffff\nbackground = #000000\nforeground = #eaeaea\ncursor-color = #eaeaea\ncursor-text = #000000\nselection-background = #424242\nselection-foreground = #eaeaea\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow Night Burns",
    "content": "palette = 0=#252525\npalette = 1=#832e31\npalette = 2=#a63c40\npalette = 3=#d3494e\npalette = 4=#fc595f\npalette = 5=#df9395\npalette = 6=#ba8586\npalette = 7=#f5f5f5\npalette = 8=#5d6f71\npalette = 9=#832e31\npalette = 10=#a63c40\npalette = 11=#d2494e\npalette = 12=#fc595f\npalette = 13=#df9395\npalette = 14=#ba8586\npalette = 15=#f5f5f5\nbackground = #151515\nforeground = #a1b0b8\ncursor-color = #ff443e\ncursor-text = #708284\nselection-background = #b0bec5\nselection-foreground = #2a2d32\n"
  },
  {
    "path": "ghostty/Resources/themes/Tomorrow Night Eighties",
    "content": "palette = 0=#000000\npalette = 1=#f2777a\npalette = 2=#99cc99\npalette = 3=#ffcc66\npalette = 4=#6699cc\npalette = 5=#cc99cc\npalette = 6=#66cccc\npalette = 7=#ffffff\npalette = 8=#595959\npalette = 9=#f2777a\npalette = 10=#99cc99\npalette = 11=#ffcc66\npalette = 12=#6699cc\npalette = 13=#cc99cc\npalette = 14=#66cccc\npalette = 15=#ffffff\nbackground = #2d2d2d\nforeground = #cccccc\ncursor-color = #cccccc\ncursor-text = #2d2d2d\nselection-background = #515151\nselection-foreground = #cccccc\n"
  },
  {
    "path": "ghostty/Resources/themes/Xcode Dark",
    "content": "palette = 0=#414453\npalette = 1=#ff8170\npalette = 2=#78c2b3\npalette = 3=#d9c97c\npalette = 4=#4eb0cc\npalette = 5=#ff7ab2\npalette = 6=#b281eb\npalette = 7=#dfdfe0\npalette = 8=#7f8c98\npalette = 9=#ff8170\npalette = 10=#acf2e4\npalette = 11=#ffa14f\npalette = 12=#6bdfff\npalette = 13=#ff7ab2\npalette = 14=#dabaff\npalette = 15=#dfdfe0\nbackground = #292a30\nforeground = #dfdfe0\ncursor-color = #dfdfe0\ncursor-text = #292a30\nselection-background = #414453\nselection-foreground = #dfdfe0\n"
  },
  {
    "path": "ghostty/Resources/themes/Xcode Dark hc",
    "content": "palette = 0=#43454b\npalette = 1=#ff8a7a\npalette = 2=#83c9bc\npalette = 3=#d9c668\npalette = 4=#4ec4e6\npalette = 5=#ff85b8\npalette = 6=#cda1ff\npalette = 7=#ffffff\npalette = 8=#838991\npalette = 9=#ff8a7a\npalette = 10=#b1faeb\npalette = 11=#ffa14f\npalette = 12=#6bdfff\npalette = 13=#ff85b8\npalette = 14=#e5cfff\npalette = 15=#ffffff\nbackground = #1f1f24\nforeground = #ffffff\ncursor-color = #ffffff\ncursor-text = #1f1f24\nselection-background = #43454b\nselection-foreground = #ffffff\n"
  },
  {
    "path": "ghostty/Resources/themes/Xcode Light",
    "content": "palette = 0=#b4d8fd\npalette = 1=#d12f1b\npalette = 2=#3e8087\npalette = 3=#78492a\npalette = 4=#0f68a0\npalette = 5=#ad3da4\npalette = 6=#804fb8\npalette = 7=#262626\npalette = 8=#8a99a6\npalette = 9=#d12f1b\npalette = 10=#23575c\npalette = 11=#78492a\npalette = 12=#0b4f79\npalette = 13=#ad3da4\npalette = 14=#4b21b0\npalette = 15=#262626\nbackground = #ffffff\nforeground = #262626\ncursor-color = #262626\ncursor-text = #ffffff\nselection-background = #b4d8fd\nselection-foreground = #262626\n"
  },
  {
    "path": "ghostty/Resources/themes/Xcode Light hc",
    "content": "palette = 0=#b4d8fd\npalette = 1=#ad1805\npalette = 2=#355d61\npalette = 3=#78492a\npalette = 4=#0058a1\npalette = 5=#9c2191\npalette = 6=#703daa\npalette = 7=#000000\npalette = 8=#8a99a6\npalette = 9=#ad1805\npalette = 10=#174145\npalette = 11=#78492a\npalette = 12=#003f73\npalette = 13=#9c2191\npalette = 14=#441ea1\npalette = 15=#000000\nbackground = #ffffff\nforeground = #000000\ncursor-color = #000000\ncursor-text = #ffffff\nselection-background = #b4d8fd\nselection-foreground = #000000\n"
  },
  {
    "path": "ghostty/Resources/themes/Zenbones Dark",
    "content": "palette = 0=#1c1917\npalette = 1=#de6e7c\npalette = 2=#819b69\npalette = 3=#b77e64\npalette = 4=#6099c0\npalette = 5=#b279a7\npalette = 6=#66a5ad\npalette = 7=#b4bdc3\npalette = 8=#4d4540\npalette = 9=#e8838f\npalette = 10=#8bae68\npalette = 11=#d68c67\npalette = 12=#61abda\npalette = 13=#cf86c1\npalette = 14=#65b8c1\npalette = 15=#888f94\nbackground = #1c1917\nforeground = #b4bdc3\ncursor-color = #c4cacf\ncursor-text = #1c1917\nselection-background = #3d4042\nselection-foreground = #b4bdc3\n"
  },
  {
    "path": "ghostty/Resources/themes/Zenbones Light",
    "content": "palette = 0=#f0edec\npalette = 1=#a8334c\npalette = 2=#4f6c31\npalette = 3=#944927\npalette = 4=#286486\npalette = 5=#88507d\npalette = 6=#3b8992\npalette = 7=#2c363c\npalette = 8=#b5a7a0\npalette = 9=#94253e\npalette = 10=#3f5a22\npalette = 11=#803d1c\npalette = 12=#1d5573\npalette = 13=#7b3b70\npalette = 14=#2b747c\npalette = 15=#4f5e68\nbackground = #f0edec\nforeground = #2c363c\ncursor-color = #2c363c\ncursor-text = #f0edec\nselection-background = #cbd9e3\nselection-foreground = #2c363c\n"
  },
  {
    "path": "ghostty/Resources/themes/Zenwritten Dark",
    "content": "palette = 0=#191919\npalette = 1=#de6e7c\npalette = 2=#819b69\npalette = 3=#b77e64\npalette = 4=#6099c0\npalette = 5=#b279a7\npalette = 6=#66a5ad\npalette = 7=#bbbbbb\npalette = 8=#4a4546\npalette = 9=#e8838f\npalette = 10=#8bae68\npalette = 11=#d68c67\npalette = 12=#61abda\npalette = 13=#cf86c1\npalette = 14=#65b8c1\npalette = 15=#8e8e8e\nbackground = #191919\nforeground = #bbbbbb\ncursor-color = #c9c9c9\ncursor-text = #191919\nselection-background = #404040\nselection-foreground = #bbbbbb\n"
  },
  {
    "path": "ghostty/Resources/themes/Zenwritten Light",
    "content": "palette = 0=#eeeeee\npalette = 1=#a8334c\npalette = 2=#4f6c31\npalette = 3=#944927\npalette = 4=#286486\npalette = 5=#88507d\npalette = 6=#3b8992\npalette = 7=#353535\npalette = 8=#aca9a9\npalette = 9=#94253e\npalette = 10=#3f5a22\npalette = 11=#803d1c\npalette = 12=#1d5573\npalette = 13=#7b3b70\npalette = 14=#2b747c\npalette = 15=#5c5c5c\nbackground = #eeeeee\nforeground = #353535\ncursor-color = #353535\ncursor-text = #eeeeee\nselection-background = #d7d7d7\nselection-foreground = #353535\n"
  },
  {
    "path": "ghostty/Resources/themes/iTerm2 Solarized Dark",
    "content": "palette = 0=#073642\npalette = 1=#dc322f\npalette = 2=#859900\npalette = 3=#b58900\npalette = 4=#268bd2\npalette = 5=#d33682\npalette = 6=#2aa198\npalette = 7=#eee8d5\npalette = 8=#335e69\npalette = 9=#cb4b16\npalette = 10=#586e75\npalette = 11=#657b83\npalette = 12=#839496\npalette = 13=#6c71c4\npalette = 14=#93a1a1\npalette = 15=#fdf6e3\nbackground = #002b36\nforeground = #839496\ncursor-color = #839496\ncursor-text = #073642\nselection-background = #073642\nselection-foreground = #93a1a1\n"
  },
  {
    "path": "ghostty/Resources/themes/iTerm2 Solarized Light",
    "content": "palette = 0=#073642\npalette = 1=#dc322f\npalette = 2=#859900\npalette = 3=#b58900\npalette = 4=#268bd2\npalette = 5=#d33682\npalette = 6=#2aa198\npalette = 7=#bbb5a2\npalette = 8=#002b36\npalette = 9=#cb4b16\npalette = 10=#586e75\npalette = 11=#657b83\npalette = 12=#839496\npalette = 13=#6c71c4\npalette = 14=#93a1a1\npalette = 15=#fdf6e3\nbackground = #fdf6e3\nforeground = #657b83\ncursor-color = #657b83\ncursor-text = #eee8d5\nselection-background = #eee8d5\nselection-foreground = #586e75\n"
  },
  {
    "path": "ghostty/Resources/themes/iTerm2 Tango Dark",
    "content": "palette = 0=#000000\npalette = 1=#d81e00\npalette = 2=#5ea702\npalette = 3=#cfae00\npalette = 4=#427ab3\npalette = 5=#89658e\npalette = 6=#00a7aa\npalette = 7=#dbded8\npalette = 8=#686a66\npalette = 9=#f54235\npalette = 10=#99e343\npalette = 11=#fdeb61\npalette = 12=#84b0d8\npalette = 13=#bc94b7\npalette = 14=#37e6e8\npalette = 15=#f1f1f0\nbackground = #000000\nforeground = #ffffff\ncursor-color = #ffffff\ncursor-text = #000000\nselection-background = #c1deff\nselection-foreground = #000000\n"
  },
  {
    "path": "ghostty/Resources/themes/iTerm2 Tango Light",
    "content": "palette = 0=#000000\npalette = 1=#d81e00\npalette = 2=#5ea702\npalette = 3=#cfae00\npalette = 4=#427ab3\npalette = 5=#89658e\npalette = 6=#00a7aa\npalette = 7=#b5b8b2\npalette = 8=#686a66\npalette = 9=#f54235\npalette = 10=#8cd736\npalette = 11=#d7c53a\npalette = 12=#84b0d8\npalette = 13=#bc94b7\npalette = 14=#1eccce\npalette = 15=#f1f1f0\nbackground = #ffffff\nforeground = #000000\ncursor-color = #000000\ncursor-text = #ffffff\nselection-background = #c1deff\nselection-foreground = #000000\n"
  },
  {
    "path": "ghostty/Sources/CGhostty/module.modulemap",
    "content": "// Ghostty C API module definition\nmodule CGhostty {\n    umbrella header \"ghostty.h\"\n    export *\n    link \"ghostty\"\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Clipboard.swift",
    "content": "//\n//  Clipboard.swift\n//  GhosttyKit\n//\n//  Shared pasteboard helper for simple text copies\n//\n\nimport AppKit\n\nenum Clipboard {\n    static func copy(_ text: String) {\n        let pasteboard = NSPasteboard.general\n        pasteboard.clearContents()\n        pasteboard.setString(text, forType: .string)\n    }\n\n    static func readString() -> String? {\n        NSPasteboard.general.string(forType: .string)\n    }\n\n    static func copy(lines: [String], separator: String = \"\\n\") {\n        copy(lines.joined(separator: separator))\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.Action.swift",
    "content": "//\n//  Ghostty.Action.swift\n//  CodMate\n//\n//  Action types for Ghostty terminal events\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport Foundation\nimport CGhostty\n\n// MARK: - Ghostty.Action\n\nextension Ghostty {\n    enum Action {}\n}\n\n// MARK: - Scrollbar\n\nextension Ghostty.Action {\n    /// Represents the scrollbar state from the terminal core.\n    ///\n    /// ## Fields\n    /// - `total`: Total rows in scrollback + active area\n    /// - `offset`: First visible row (0 = top of history)\n    /// - `len`: Number of visible rows (viewport height)\n    struct Scrollbar {\n        let total: UInt64\n        let offset: UInt64\n        let len: UInt64\n\n        init(c: ghostty_action_scrollbar_s) {\n            total = c.total\n            offset = c.offset\n            len = c.len\n        }\n\n        init(total: UInt64, offset: UInt64, len: UInt64) {\n            self.total = total\n            self.offset = offset\n            self.len = len\n        }\n    }\n}\n\n// MARK: - Notification Names\n\nextension Notification.Name {\n    /// Posted when the terminal scrollbar state changes.\n    /// userInfo contains ScrollbarKey with Ghostty.Action.Scrollbar value.\n    static let ghosttyDidUpdateScrollbar = Notification.Name(\"ai.umate.codmate.ghostty.didUpdateScrollbar\")\n\n    /// Posted when Ghostty configuration is reloaded (e.g., font size changed).\n    /// Terminal views should refresh their surface size to trigger reflow.\n    static let ghosttyConfigDidReload = Notification.Name(\"ai.umate.codmate.ghostty.configDidReload\")\n\n    /// Key for scrollbar state in notification userInfo\n    static let ScrollbarKey = ghosttyDidUpdateScrollbar.rawValue + \".scrollbar\"\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.App.swift",
    "content": "//\n//  Ghostty.App.swift\n//  CodMate\n//\n//  Minimal Ghostty app wrapper - Phase 1: Basic lifecycle\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport CGhostty\nimport Combine\nimport Foundation\nimport OSLog\nimport SwiftUI\n\n// MARK: - Ghostty Namespace\n\npublic enum Ghostty {\n    static let logger = Logger(\n        subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"Ghostty\")\n\n    /// Wrapper to hold reference to a surface for tracking\n    /// Note: ghostty_surface_t is an opaque pointer, so we store it directly\n    /// The surface is freed when the GhosttyTerminalView is deallocated\n    class SurfaceReference {\n        let surface: ghostty_surface_t\n        var isValid: Bool = true\n\n        init(_ surface: ghostty_surface_t) {\n            self.surface = surface\n        }\n\n        func invalidate() {\n            isValid = false\n        }\n    }\n\n    /// Stable userdata container for Ghostty surface callbacks.\n    /// Holds a weak terminal view reference to avoid use-after-free.\n    final class SurfaceUserdata {\n        weak var terminalView: GhosttyTerminalView?\n\n        init(view: GhosttyTerminalView) {\n            self.terminalView = view\n        }\n    }\n}\n\n// MARK: - Ghostty.App\n\nextension Ghostty {\n    /// Minimal wrapper for ghostty_app_t lifecycle management\n    @MainActor\n    public class App: ObservableObject {\n        public enum Readiness: String {\n            case loading, error, ready\n        }\n\n        // MARK: - Published Properties\n\n        /// The ghostty app instance\n        @Published public var app: ghostty_app_t? = nil\n\n        /// Readiness state\n        @Published public var readiness: Readiness = .loading\n\n        /// Track active surfaces for config propagation\n        private var activeSurfaces: [Ghostty.SurfaceReference] = []\n\n        /// Track last known system appearance state to detect changes\n        private var lastKnownIsDark: Bool?\n\n        /// Track last known theme to detect changes\n        private var lastKnownTheme: String?\n\n        /// Track last known font settings to detect changes\n        private var lastKnownFontName: String?\n        private var lastKnownFontSize: Double?\n        private var lastKnownCursorStyle: String?\n\n        /// Observer for in-app appearance setting changes\n        private var appearanceSettingObserver: NSObjectProtocol?\n        private var appAppearanceObservation: NSKeyValueObservation?\n\n        // MARK: - Terminal Settings from AppStorage\n        // Note: Theme settings are managed via SessionPreferencesStore and synced here\n        // We use AppStorage for backward compatibility with existing code\n\n        @AppStorage(\"terminal.fontName\") private var terminalFontName = \"Menlo\"\n        @AppStorage(\"terminal.fontSize\") private var terminalFontSize = 12.0\n        @AppStorage(\"terminal.cursorStyle\") private var terminalCursorStyleRaw = \"blinkBlock\"\n        @AppStorage(\"terminalThemeName\") private var terminalThemeName = \"Xcode Dark\"\n        @AppStorage(\"terminalThemeNameLight\") private var terminalThemeNameLight = \"Xcode Light\"\n        @AppStorage(\"terminalUsePerAppearanceTheme\") private var usePerAppearanceTheme = true\n        @AppStorage(\"appearanceMode\") private var appearanceMode = \"system\"\n\n        /// Parse cursor style raw value to Ghostty config values\n        private var cursorStyleConfig: (style: String, blink: Bool) {\n            // Map raw values to Ghostty config\n            // Raw values: blinkBlock, steadyBlock, blinkUnderline, steadyUnderline, blinkBar, steadyBar\n            let style: String\n            let blink: Bool\n\n            if terminalCursorStyleRaw.contains(\"Block\") {\n                style = \"block\"\n                blink = terminalCursorStyleRaw.contains(\"blink\")\n            } else if terminalCursorStyleRaw.contains(\"Underline\") {\n                style = \"underline\"\n                blink = terminalCursorStyleRaw.contains(\"blink\")\n            } else if terminalCursorStyleRaw.contains(\"Bar\") {\n                style = \"bar\"\n                blink = terminalCursorStyleRaw.contains(\"blink\")\n            } else {\n                // Default fallback\n                style = \"block\"\n                blink = true\n            }\n\n            return (style: style, blink: blink)\n        }\n\n        private var effectiveThemeName: String {\n            guard usePerAppearanceTheme else { return terminalThemeName }\n\n            switch appearanceMode {\n            case \"light\":\n                return terminalThemeNameLight\n            case \"dark\":\n                return terminalThemeName\n            default:\n                return currentSystemIsDark() ? terminalThemeName : terminalThemeNameLight\n            }\n        }\n\n        // MARK: - Initialization\n\n        public init() {\n            // Migrate old theme names to new names\n            if terminalThemeName == \"Dark\" && terminalThemeNameLight == \"Light\" {\n                // Migrate from generic Dark/Light to Xcode themes if both are defaults\n                terminalThemeName = \"Xcode Dark\"\n                terminalThemeNameLight = \"Xcode Light\"\n            }\n\n            // Log initial theme configuration\n            Ghostty.logger.info(\n                \"Ghostty.App initializing with usePerAppearanceTheme=\\(self.usePerAppearanceTheme), dark=\\(self.terminalThemeName), light=\\(self.terminalThemeNameLight)\"\n            )\n\n            // CRITICAL: Initialize libghostty first\n            let initResult = ghostty_init(0, nil)\n            if initResult != GHOSTTY_SUCCESS {\n                Ghostty.logger.critical(\"ghostty_init failed with code: \\(initResult)\")\n                readiness = .error\n                return\n            }\n\n            // Create runtime config with callbacks\n            var runtime_cfg = ghostty_runtime_config_s(\n                userdata: Unmanaged.passUnretained(self).toOpaque(),\n                supports_selection_clipboard: true,\n                wakeup_cb: { userdata in App.wakeup(userdata) },\n                action_cb: { app, target, action in App.action(app!, target: target, action: action)\n                },\n                read_clipboard_cb: { userdata, loc, state in\n                    App.readClipboard(userdata, location: loc, state: state)\n                },\n                confirm_read_clipboard_cb: { userdata, str, state, request in\n                    App.confirmReadClipboard(userdata, string: str, state: state, request: request)\n                },\n                write_clipboard_cb: { userdata, loc, content, count, confirm in\n                    App.writeClipboard(\n                        userdata, location: loc, contents: content, count: count, confirm: confirm)\n                },\n                close_surface_cb: { userdata, processAlive in\n                    App.closeSurface(userdata, processAlive: processAlive)\n                }\n            )\n\n            // Create config and load Ghostty terminal settings\n            guard let config = ghostty_config_new() else {\n                Ghostty.logger.critical(\"ghostty_config_new failed\")\n                readiness = .error\n                return\n            }\n\n            // Load config from settings\n            loadConfigIntoGhostty(config)\n\n            // Finalize config (required before use)\n            ghostty_config_finalize(config)\n\n            // Create the ghostty app\n            guard let app = ghostty_app_new(&runtime_cfg, config) else {\n                Ghostty.logger.critical(\"ghostty_app_new failed\")\n                ghostty_config_free(config)\n                readiness = .error\n                return\n            }\n\n            // Free config after app creation (app clones it)\n            ghostty_config_free(config)\n\n            // CRITICAL: Unset XDG_CONFIG_HOME after app creation\n            // If left set, fish will look for config.fish in the temp directory instead of ~/.config\n            unsetenv(\"XDG_CONFIG_HOME\")\n\n            self.app = app\n            self.readiness = .ready\n\n            // Store initial appearance and theme\n            lastKnownIsDark = currentSystemIsDark()\n            lastKnownTheme = effectiveThemeName\n            lastKnownFontName = terminalFontName\n            lastKnownFontSize = terminalFontSize\n            lastKnownCursorStyle = terminalCursorStyleRaw\n\n            appAppearanceObservation = NSApp.observe(\\.effectiveAppearance, options: [.new]) {\n                [weak self] _, _ in\n                Task { @MainActor [weak self] in\n                    self?.handleAppearanceChange()\n                }\n            }\n\n            // Observe system appearance changes via DistributedNotificationCenter\n            DistributedNotificationCenter.default().addObserver(\n                self,\n                selector: #selector(systemAppearanceDidChange),\n                name: NSNotification.Name(\"AppleInterfaceThemeChangedNotification\"),\n                object: nil\n            )\n\n            // Observe in-app setting changes (appearance, font, cursor)\n            appearanceSettingObserver = NotificationCenter.default.addObserver(\n                forName: UserDefaults.didChangeNotification,\n                object: nil,\n                queue: .main\n            ) { [weak self] _ in\n                Task { @MainActor [weak self] in\n                    self?.checkSettingsChange()\n                }\n            }\n\n            Ghostty.logger.info(\"Ghostty app initialized successfully\")\n\n            // Delay theme verification to ensure NSApp is fully initialized\n            // During @StateObject init, NSApp.effectiveAppearance may not be accurate yet\n            Task { @MainActor [weak self] in\n                // Wait a brief moment for the app to finish launching\n                try? await Task.sleep(nanoseconds: 100_000_000)  // 0.1 seconds\n                self?.verifyAndCorrectThemeIfNeeded()\n            }\n        }\n\n        /// Verify theme matches system appearance and reload if necessary\n        private func verifyAndCorrectThemeIfNeeded() {\n            guard usePerAppearanceTheme else { return }\n\n            let currentIsDark = currentSystemIsDark()\n            let expectedTheme = effectiveThemeName\n\n            Ghostty.logger.info(\n                \"Theme verification: systemIsDark=\\(currentIsDark), expectedTheme=\\(expectedTheme), currentTheme=\\(self.lastKnownTheme ?? \"nil\")\"\n            )\n\n            if expectedTheme != lastKnownTheme {\n                Ghostty.logger.info(\n                    \"Theme mismatch detected, reloading config with correct theme: \\(expectedTheme)\"\n                )\n                lastKnownIsDark = currentIsDark\n                lastKnownTheme = expectedTheme\n                reloadConfig()\n            }\n        }\n\n        @objc private func systemAppearanceDidChange(_ notification: Notification) {\n            // DistributedNotificationCenter calls on a background thread\n            // Must dispatch to MainActor for safe access to @MainActor-isolated methods\n            Task { @MainActor [weak self] in\n                self?.handleAppearanceChange()\n            }\n        }\n\n        private func handleAppearanceChange() {\n            guard usePerAppearanceTheme else { return }\n\n            let currentIsDark = currentSystemIsDark()\n            guard currentIsDark != lastKnownIsDark else { return }\n\n            lastKnownIsDark = currentIsDark\n            reloadIfThemeChanged()\n        }\n\n        private func checkSettingsChange() {\n            // Check theme changes\n            if usePerAppearanceTheme {\n                reloadIfThemeChanged()\n            }\n\n            // Check font and cursor style changes\n            let currentFontName = self.terminalFontName\n            let currentFontSize = self.terminalFontSize\n            let currentCursorStyle = self.terminalCursorStyleRaw\n\n            let fontChanged =\n                currentFontName != lastKnownFontName || currentFontSize != lastKnownFontSize\n            let cursorChanged = currentCursorStyle != lastKnownCursorStyle\n\n            if fontChanged || cursorChanged {\n                if fontChanged {\n                    lastKnownFontName = currentFontName\n                    lastKnownFontSize = currentFontSize\n                    Ghostty.logger.info(\n                        \"Font changed, reloading terminal config - Font: \\(currentFontName) \\(Int(currentFontSize))pt\"\n                    )\n                }\n                if cursorChanged {\n                    lastKnownCursorStyle = currentCursorStyle\n                    Ghostty.logger.info(\n                        \"Cursor style changed, reloading terminal config - Style: \\(currentCursorStyle)\"\n                    )\n                }\n                reloadConfig()\n            }\n        }\n\n        private func reloadIfThemeChanged() {\n            let newTheme = effectiveThemeName\n            guard newTheme != lastKnownTheme else { return }\n\n            lastKnownTheme = newTheme\n            Ghostty.logger.info(\"Theme changed, reloading terminal config with theme: \\(newTheme)\")\n            reloadConfig()\n        }\n\n        deinit {\n            // Note: Cannot access @MainActor isolated properties in deinit\n            // The app will be freed when the instance is deallocated\n            // For proper cleanup, call a cleanup method before deinitialization\n        }\n\n        // MARK: - App Operations\n\n        /// Clean up the ghostty app resources\n        func cleanup() {\n            appAppearanceObservation?.invalidate()\n            appAppearanceObservation = nil\n            DistributedNotificationCenter.default().removeObserver(self)\n\n            if let observer = appearanceSettingObserver {\n                NotificationCenter.default.removeObserver(observer)\n                appearanceSettingObserver = nil\n            }\n\n            if let app = self.app {\n                ghostty_app_free(app)\n                self.app = nil\n            }\n        }\n\n        func appTick() {\n            guard let app = self.app else { return }\n            ghostty_app_tick(app)\n        }\n\n        /// Register a surface for config update tracking\n        /// Returns the surface reference that should be stored by the view\n        @discardableResult\n        func registerSurface(_ surface: ghostty_surface_t) -> Ghostty.SurfaceReference {\n            let ref = Ghostty.SurfaceReference(surface)\n            activeSurfaces.append(ref)\n            // Clean up invalid surfaces\n            activeSurfaces = activeSurfaces.filter { $0.isValid }\n            return ref\n        }\n\n        /// Unregister a surface when it's being deallocated\n        func unregisterSurface(_ ref: Ghostty.SurfaceReference) {\n            ref.invalidate()\n            activeSurfaces = activeSurfaces.filter { $0.isValid }\n        }\n\n        /// Reload configuration (call when settings change)\n        func reloadConfig() {\n            guard let app = self.app else { return }\n\n            // Create new config with updated settings\n            guard let config = ghostty_config_new() else {\n                Ghostty.logger.error(\"ghostty_config_new failed during reload\")\n                return\n            }\n\n            // Load config from settings\n            loadConfigIntoGhostty(config)\n\n            // Finalize config (required before use)\n            ghostty_config_finalize(config)\n\n            // Update the app config\n            ghostty_app_update_config(app, config)\n\n            // Propagate config to all existing surfaces\n            for surfaceRef in activeSurfaces where surfaceRef.isValid {\n                ghostty_surface_update_config(surfaceRef.surface, config)\n            }\n\n            // Clean up invalid surfaces\n            activeSurfaces = activeSurfaces.filter { $0.isValid }\n\n            ghostty_config_free(config)\n\n            // Unset XDG_CONFIG_HOME so it doesn't affect fish/shell config loading\n            unsetenv(\"XDG_CONFIG_HOME\")\n\n            // Notify all terminal views to refresh (triggers reflow on font size changes)\n            NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)\n\n            Ghostty.logger.info(\n                \"Configuration reloaded and propagated to \\(self.activeSurfaces.count) surfaces\")\n        }\n\n        // MARK: - Private Helpers\n\n        /// Generate and load config content into a ghostty_config_t\n        private func loadConfigIntoGhostty(_ config: ghostty_config_t) {\n            // Create temp config directory and use Ghostty themes\n            let tempDir = NSTemporaryDirectory()\n            let ghosttyConfigDir = (tempDir as NSString).appendingPathComponent(\".config/ghostty\")\n            let configFilePath = (ghosttyConfigDir as NSString).appendingPathComponent(\"config\")\n\n            do {\n                try FileManager.default.createDirectory(\n                    atPath: ghosttyConfigDir, withIntermediateDirectories: true)\n                syncBundledThemes(into: ghosttyConfigDir)\n\n                // Detect shell for integration\n                let shell = ProcessInfo.processInfo.environment[\"SHELL\"] ?? \"/bin/zsh\"\n                let shellName = (shell as NSString).lastPathComponent\n\n                // Determine theme based on current system appearance\n                let isDark = currentSystemIsDark()\n                let themeName = effectiveThemeName\n\n                // Log theme selection\n                Ghostty.logger.info(\n                    \"Loading terminal config: systemIsDark=\\(isDark), usePerAppearance=\\(self.usePerAppearanceTheme), theme=\\(themeName)\"\n                )\n\n                let themeURL = URL(fileURLWithPath: ghosttyConfigDir)\n                    .appendingPathComponent(\"themes\", isDirectory: true)\n                    .appendingPathComponent(themeName)\n                if !FileManager.default.fileExists(atPath: themeURL.path) {\n                    Ghostty.logger.warning(\"Ghostty theme file missing at: \\(themeURL.path)\")\n                }\n\n                let configContent = \"\"\"\n                    font-family = \\(terminalFontName)\n                    font-size = \\(Int(terminalFontSize))\n                    window-inherit-font-size = false\n                    window-padding-balance = true\n                    window-padding-x = 0\n                    window-padding-y = 0\n                    window-padding-color = extend-always\n\n                    # Enable shell integration (resources dir auto-detected from app bundle)\n                    shell-integration = \\(shellName)\n                    shell-integration-features = no-cursor,sudo,title\n\n                    # Cursor\n                    cursor-style = \\(cursorStyleConfig.style)\n                    cursor-style-blink = \\(cursorStyleConfig.blink)\n\n                    theme = \\(themeName)\n\n                    # Disable audible bell\n                    audible-bell = false\n\n                    # Custom keybinds\n                    keybind = shift+enter=text:\\\\n\n\n                    \"\"\"\n\n                Ghostty.logger.info(\"Loading Ghostty theme: \\(themeName)\")\n\n                try configContent.write(toFile: configFilePath, atomically: true, encoding: .utf8)\n\n                // Set XDG_CONFIG_HOME to our temp directory\n                // With bundle ID \"ai.umate.codmate\", Ghostty will look for config at:\n                // ~/Library/Application Support/ai.umate.codmate/config (won't exist)\n                // So it will use our XDG config only\n                setenv(\n                    \"XDG_CONFIG_HOME\", (tempDir as NSString).appendingPathComponent(\".config\"), 1)\n\n                // Load default files - will load our XDG config\n                // Will NOT load user's Ghostty config (com.mitchellh.ghostty) since bundle ID is different\n                ghostty_config_load_default_files(config)\n\n                Ghostty.logger.info(\n                    \"Loaded Ghostty terminal settings - Font: \\(self.terminalFontName) \\(Int(self.terminalFontSize))pt, Theme: \\(themeName)\"\n                )\n            } catch {\n                Ghostty.logger.warning(\"Failed to write config: \\(error)\")\n            }\n        }\n\n        private func currentSystemIsDark() -> Bool {\n            // Primary detection: Use UserDefaults (most reliable, especially during app initialization)\n            // AppleInterfaceStyle is only set when in Dark mode, absent in Light mode\n            if UserDefaults.standard.string(forKey: \"AppleInterfaceStyle\") == \"Dark\" {\n                return true\n            }\n\n            // Secondary detection: Use NSApp.effectiveAppearance (more accurate after app fully launches)\n            // This may return incorrect value during early initialization\n            let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua])\n            if appearance == .darkAqua {\n                return true\n            }\n            if appearance == .aqua {\n                return false\n            }\n\n            // Fallback: default to light mode\n            return false\n        }\n\n        private func syncBundledThemes(into ghosttyConfigDir: String) {\n            // Access themes from Package resources via Bundle.module\n            guard\n                let themesURL = Bundle.module.url(\n                    forResource: \"themes\", withExtension: nil, subdirectory: nil)\n            else {\n                Ghostty.logger.warning(\"Ghostty themes resource not found in Bundle.module\")\n                return\n            }\n\n            let sourceDir = themesURL.path\n            let destDir = (ghosttyConfigDir as NSString).appendingPathComponent(\"themes\")\n\n            let fm = FileManager.default\n            var isDir: ObjCBool = false\n            guard fm.fileExists(atPath: sourceDir, isDirectory: &isDir), isDir.boolValue else {\n                Ghostty.logger.warning(\"Ghostty themes directory not found at: \\(sourceDir)\")\n                return\n            }\n\n            if fm.fileExists(atPath: destDir, isDirectory: &isDir) {\n                guard isDir.boolValue else { return }\n            } else {\n                try? fm.createDirectory(atPath: destDir, withIntermediateDirectories: true)\n            }\n\n            guard let files = try? fm.contentsOfDirectory(atPath: sourceDir) else { return }\n            for file in files {\n                let from = (sourceDir as NSString).appendingPathComponent(file)\n                let to = (destDir as NSString).appendingPathComponent(file)\n                if fm.fileExists(atPath: to) { continue }\n                _ = try? fm.copyItem(atPath: from, toPath: to)\n            }\n        }\n\n        // MARK: - Callbacks (macOS)\n\n        static func wakeup(_ userdata: UnsafeMutableRawPointer?) {\n            guard let userdata = userdata else { return }\n            let state = Unmanaged<App>.fromOpaque(userdata).takeUnretainedValue()\n\n            // Schedule tick on main thread\n            DispatchQueue.main.async {\n                state.appTick()\n            }\n        }\n\n        static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s)\n            -> Bool\n        {\n            // Get the terminal view from surface userdata if target is a surface\n            let terminalView: GhosttyTerminalView? = {\n                guard target.tag == GHOSTTY_TARGET_SURFACE else { return nil }\n                let surface = target.target.surface\n                guard let userdata = ghostty_surface_userdata(surface) else { return nil }\n                let surfaceUserdata = Unmanaged<Ghostty.SurfaceUserdata>.fromOpaque(userdata)\n                    .takeUnretainedValue()\n                return surfaceUserdata.terminalView\n            }()\n\n            NSLog(\n                \"[Ghostty.App] action callback: tag=%d, has terminalView=%@\", action.tag.rawValue,\n                terminalView != nil ? \"YES\" : \"NO\")\n\n            switch action.tag {\n            case GHOSTTY_ACTION_SET_TITLE:\n                // Window/tab title change\n                if let titlePtr = action.action.set_title.title, let terminalView = terminalView {\n                    let title = String(cString: titlePtr)\n                    Ghostty.logger.info(\"Title changed: \\(title)\")\n\n                    // Propagate to terminal view callback with weak capture\n                    DispatchQueue.main.async { [weak terminalView] in\n                        terminalView?.onTitleChange?(title)\n                    }\n                }\n                return true\n\n            case GHOSTTY_ACTION_PWD:\n                // Working directory change\n                if let pwdPtr = action.action.pwd.pwd {\n                    let pwd = String(cString: pwdPtr)\n                    Ghostty.logger.info(\"PWD changed: \\(pwd)\")\n                }\n                return true\n\n            case GHOSTTY_ACTION_PROMPT_TITLE:\n                // Prompt title update (for shell integration)\n                Ghostty.logger.debug(\"Prompt title action received\")\n                return true\n\n            case GHOSTTY_ACTION_PROGRESS_REPORT:\n                if let terminalView = terminalView {\n                    let report = action.action.progress_report\n                    let state = GhosttyProgressState(cState: report.state)\n                    let value = report.progress >= 0 ? Int(report.progress) : nil\n                    DispatchQueue.main.async { [weak terminalView] in\n                        terminalView?.onProgressReport?(state, value)\n                    }\n                }\n                return true\n\n            case GHOSTTY_ACTION_CELL_SIZE:\n                // Cell size update - used for row-to-pixel conversion in scrollbar\n                if let terminalView = terminalView {\n                    let cellSize = action.action.cell_size\n                    let backingSize = NSSize(\n                        width: Double(cellSize.width), height: Double(cellSize.height))\n                    DispatchQueue.main.async { [weak terminalView] in\n                        guard let terminalView = terminalView else { return }\n                        // Convert from backing (pixel) coordinates to points\n                        terminalView.cellSize = terminalView.convertFromBacking(backingSize)\n                    }\n                }\n                return true\n\n            case GHOSTTY_ACTION_SCROLLBAR:\n                // Scrollbar state update - post notification for scroll view\n                let scrollbar = Ghostty.Action.Scrollbar(c: action.action.scrollbar)\n                NotificationCenter.default.post(\n                    name: .ghosttyDidUpdateScrollbar,\n                    object: terminalView,\n                    userInfo: [Notification.Name.ScrollbarKey: scrollbar]\n                )\n                return true\n\n            default:\n                // Log unhandled actions\n                Ghostty.logger.debug(\n                    \"Action received: \\(action.tag.rawValue) on target: \\(target.tag.rawValue)\")\n                return false\n            }\n        }\n\n        static func readClipboard(\n            _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e,\n            state: UnsafeMutableRawPointer?\n        ) {\n            // userdata is the GhosttyTerminalView instance\n            guard let userdata = userdata else { return }\n            let surfaceUserdata = Unmanaged<Ghostty.SurfaceUserdata>.fromOpaque(userdata)\n                .takeUnretainedValue()\n            guard let terminalView = surfaceUserdata.terminalView else { return }\n            guard let surface = terminalView.surface?.unsafeCValue else { return }\n\n            // Read from macOS clipboard\n            let clipboardString = Clipboard.readString() ?? \"\"\n\n            // Complete the clipboard request by providing data to Ghostty\n            clipboardString.withCString { ptr in\n                ghostty_surface_complete_clipboard_request(surface, ptr, state, false)\n            }\n\n            Ghostty.logger.debug(\"Read clipboard: \\(clipboardString.prefix(50))...\")\n        }\n\n        static func confirmReadClipboard(\n            _ userdata: UnsafeMutableRawPointer?,\n            string: UnsafePointer<CChar>?,\n            state: UnsafeMutableRawPointer?,\n            request: ghostty_clipboard_request_e\n        ) {\n            // Clipboard read confirmation\n            // For security, apps can confirm before allowing clipboard access\n            // For now, just log it\n            Ghostty.logger.debug(\"Clipboard read confirmation requested\")\n        }\n\n        static func writeClipboard(\n            _ userdata: UnsafeMutableRawPointer?,\n            location: ghostty_clipboard_e,\n            contents: UnsafePointer<ghostty_clipboard_content_s>?,\n            count: Int,\n            confirm: Bool\n        ) {\n            guard let contents = contents, count > 0 else { return }\n\n            // The runtime passes an array of clipboard entries; prefer the first\n            // textual entry. The API does not supply a byte length, so we treat\n            // the data as a null-terminated UTF-8 C string.\n            for idx in 0..<count {\n                let entry = contents.advanced(by: idx).pointee\n                guard let dataPtr = entry.data else { continue }\n\n                var string = String(cString: dataPtr)\n                if !string.isEmpty {\n                    // Apply copy transformations from settings\n                    let settings = TerminalCopySettings(\n                        trimTrailingWhitespace: UserDefaults.standard.object(\n                            forKey: \"terminalCopyTrimTrailingWhitespace\") as? Bool ?? true,\n                        collapseBlankLines: UserDefaults.standard.bool(\n                            forKey: \"terminalCopyCollapseBlankLines\"),\n                        stripShellPrompts: UserDefaults.standard.bool(\n                            forKey: \"terminalCopyStripShellPrompts\"),\n                        flattenCommands: UserDefaults.standard.bool(\n                            forKey: \"terminalCopyFlattenCommands\"),\n                        removeBoxDrawing: UserDefaults.standard.bool(\n                            forKey: \"terminalCopyRemoveBoxDrawing\"),\n                        stripAnsiCodes: UserDefaults.standard.object(\n                            forKey: \"terminalCopyStripAnsiCodes\") as? Bool ?? true\n                    )\n                    string = TerminalTextCleaner.cleanText(string, settings: settings)\n\n                    Clipboard.copy(string)\n                    Ghostty.logger.debug(\"Wrote to clipboard: \\(string.prefix(50))...\")\n                    return\n                }\n            }\n        }\n\n        static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {\n            // userdata is the GhosttyTerminalView instance\n            guard let userdata = userdata else { return }\n            let surfaceUserdata = Unmanaged<Ghostty.SurfaceUserdata>.fromOpaque(userdata)\n                .takeUnretainedValue()\n            let terminalView = surfaceUserdata.terminalView\n\n            Ghostty.logger.info(\"Close surface: processAlive=\\(processAlive)\")\n\n            // Trigger process exit callback on main thread with weak capture\n            DispatchQueue.main.async { [weak terminalView] in\n                terminalView?.onProcessExit?()\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.Input.swift",
    "content": "import AppKit\nimport SwiftUI\nimport CGhostty\n\nextension SwiftUI.EventModifiers {\n    /// Initialize EventModifiers from NSEvent.ModifierFlags\n    init(nsFlags: NSEvent.ModifierFlags) {\n        var modifiers = SwiftUI.EventModifiers()\n        if nsFlags.contains(.shift) { modifiers.insert(.shift) }\n        if nsFlags.contains(.control) { modifiers.insert(.control) }\n        if nsFlags.contains(.option) { modifiers.insert(.option) }\n        if nsFlags.contains(.command) { modifiers.insert(.command) }\n        self = modifiers\n    }\n}\n\nextension Ghostty {\n    // Input types split into separate files: Ghostty.Key.swift, Ghostty.MouseEvent.swift, Ghostty.KeyEvent.swift, Ghostty.Mods.swift\n    struct Input {}\n\n    // MARK: Keyboard Shortcuts\n\n    /// Return the key equivalent for the given trigger.\n    ///\n    /// Returns nil if the trigger doesn't have an equivalent KeyboardShortcut. This is possible\n    /// because Ghostty input triggers are a superset of what can be represented by a macOS\n    /// KeyboardShortcut. For example, macOS doesn't have any way to represent function keys\n    /// (F1, F2, ...) with a KeyboardShortcut. This doesn't represent a practical issue because input\n    /// handling for Ghostty is handled at a lower level (usually). This function should generally only\n    /// be used for things like NSMenu that only support keyboard shortcuts anyways.\n    static func keyboardShortcut(for trigger: ghostty_input_trigger_s) -> KeyboardShortcut? {\n        let key: KeyEquivalent\n        switch (trigger.tag) {\n        case GHOSTTY_TRIGGER_PHYSICAL:\n            // Only functional keys can be converted to a KeyboardShortcut. Other physical\n            // mappings cannot because KeyboardShortcut in Swift is inherently layout-dependent.\n            if let equiv = Self.keyToEquivalent[trigger.key.physical.rawValue] {\n                key = equiv\n            } else {\n                return nil\n            }\n\n        case GHOSTTY_TRIGGER_UNICODE:\n            guard let scalar = UnicodeScalar(trigger.key.unicode) else { return nil }\n            key = KeyEquivalent(Character(scalar))\n\n        default:\n            return nil\n        }\n\n        return KeyboardShortcut(\n            key,\n            modifiers: EventModifiers(nsFlags: Ghostty.eventModifierFlags(mods: trigger.mods)))\n    }\n\n    // MARK: Mods\n\n    /// Returns the event modifier flags set for the Ghostty mods enum.\n    static func eventModifierFlags(mods: ghostty_input_mods_e) -> NSEvent.ModifierFlags {\n        var flags = NSEvent.ModifierFlags(rawValue: 0);\n        if (mods.rawValue & GHOSTTY_MODS_SHIFT.rawValue != 0) { flags.insert(.shift) }\n        if (mods.rawValue & GHOSTTY_MODS_CTRL.rawValue != 0) { flags.insert(.control) }\n        if (mods.rawValue & GHOSTTY_MODS_ALT.rawValue != 0) { flags.insert(.option) }\n        if (mods.rawValue & GHOSTTY_MODS_SUPER.rawValue != 0) { flags.insert(.command) }\n        return flags\n    }\n\n    /// Translate event modifier flags to a ghostty mods enum.\n    static func ghosttyMods(_ flags: NSEvent.ModifierFlags) -> ghostty_input_mods_e {\n        var mods: UInt32 = GHOSTTY_MODS_NONE.rawValue\n\n        if (flags.contains(.shift)) { mods |= GHOSTTY_MODS_SHIFT.rawValue }\n        if (flags.contains(.control)) { mods |= GHOSTTY_MODS_CTRL.rawValue }\n        if (flags.contains(.option)) { mods |= GHOSTTY_MODS_ALT.rawValue }\n        if (flags.contains(.command)) { mods |= GHOSTTY_MODS_SUPER.rawValue }\n        if (flags.contains(.capsLock)) { mods |= GHOSTTY_MODS_CAPS.rawValue }\n\n        // Handle sided input. We can't tell that both are pressed in the\n        // Ghostty structure but thats okay -- we don't use that information.\n        let rawFlags = flags.rawValue\n        if (rawFlags & UInt(NX_DEVICERSHIFTKEYMASK) != 0) { mods |= GHOSTTY_MODS_SHIFT_RIGHT.rawValue }\n        if (rawFlags & UInt(NX_DEVICERCTLKEYMASK) != 0) { mods |= GHOSTTY_MODS_CTRL_RIGHT.rawValue }\n        if (rawFlags & UInt(NX_DEVICERALTKEYMASK) != 0) { mods |= GHOSTTY_MODS_ALT_RIGHT.rawValue }\n        if (rawFlags & UInt(NX_DEVICERCMDKEYMASK) != 0) { mods |= GHOSTTY_MODS_SUPER_RIGHT.rawValue }\n\n        return ghostty_input_mods_e(mods)\n    }\n\n    /// A map from the Ghostty key enum to the keyEquivalent string for shortcuts. Note that\n    /// not all ghostty key enum values are represented here because not all of them can be\n    /// mapped to a KeyEquivalent.\n    static let keyToEquivalent: [UInt32 : KeyEquivalent] = [\n        // Function keys\n        GHOSTTY_KEY_ARROW_UP.rawValue: .upArrow,\n        GHOSTTY_KEY_ARROW_DOWN.rawValue: .downArrow,\n        GHOSTTY_KEY_ARROW_LEFT.rawValue: .leftArrow,\n        GHOSTTY_KEY_ARROW_RIGHT.rawValue: .rightArrow,\n        GHOSTTY_KEY_HOME.rawValue: .home,\n        GHOSTTY_KEY_END.rawValue: .end,\n        GHOSTTY_KEY_DELETE.rawValue: .delete,\n        GHOSTTY_KEY_PAGE_UP.rawValue: .pageUp,\n        GHOSTTY_KEY_PAGE_DOWN.rawValue: .pageDown,\n        GHOSTTY_KEY_ESCAPE.rawValue: .escape,\n        GHOSTTY_KEY_ENTER.rawValue: .return,\n        GHOSTTY_KEY_TAB.rawValue: .tab,\n        GHOSTTY_KEY_BACKSPACE.rawValue: .delete,\n        GHOSTTY_KEY_SPACE.rawValue: .space,\n    ]\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.Key.swift",
    "content": "import Foundation\nimport CGhostty\n\nextension Ghostty.Input {\n    /// `ghostty_input_key_e`\n    enum Key: String, CaseIterable {\n        // Writing System Keys\n        case backquote\n        case backslash\n        case bracketLeft\n        case bracketRight\n        case comma\n        case digit0\n        case digit1\n        case digit2\n        case digit3\n        case digit4\n        case digit5\n        case digit6\n        case digit7\n        case digit8\n        case digit9\n        case equal\n        case intlBackslash\n        case intlRo\n        case intlYen\n        case a\n        case b\n        case c\n        case d\n        case e\n        case f\n        case g\n        case h\n        case i\n        case j\n        case k\n        case l\n        case m\n        case n\n        case o\n        case p\n        case q\n        case r\n        case s\n        case t\n        case u\n        case v\n        case w\n        case x\n        case y\n        case z\n        case minus\n        case period\n        case quote\n        case semicolon\n        case slash\n\n        // Functional Keys\n        case altLeft\n        case altRight\n        case backspace\n        case capsLock\n        case contextMenu\n        case controlLeft\n        case controlRight\n        case enter\n        case metaLeft\n        case metaRight\n        case shiftLeft\n        case shiftRight\n        case space\n        case tab\n        case convert\n        case kanaMode\n        case nonConvert\n\n        // Control Pad Section\n        case delete\n        case end\n        case help\n        case home\n        case insert\n        case pageDown\n        case pageUp\n\n        // Arrow Pad Section\n        case arrowDown\n        case arrowLeft\n        case arrowRight\n        case arrowUp\n\n        // Numpad Section\n        case numLock\n        case numpad0\n        case numpad1\n        case numpad2\n        case numpad3\n        case numpad4\n        case numpad5\n        case numpad6\n        case numpad7\n        case numpad8\n        case numpad9\n        case numpadAdd\n        case numpadBackspace\n        case numpadClear\n        case numpadClearEntry\n        case numpadComma\n        case numpadDecimal\n        case numpadDivide\n        case numpadEnter\n        case numpadEqual\n        case numpadMemoryAdd\n        case numpadMemoryClear\n        case numpadMemoryRecall\n        case numpadMemoryStore\n        case numpadMemorySubtract\n        case numpadMultiply\n        case numpadParenLeft\n        case numpadParenRight\n        case numpadSubtract\n        case numpadSeparator\n        case numpadUp\n        case numpadDown\n        case numpadRight\n        case numpadLeft\n        case numpadBegin\n        case numpadHome\n        case numpadEnd\n        case numpadInsert\n        case numpadDelete\n        case numpadPageUp\n        case numpadPageDown\n\n        // Function Section\n        case escape\n        case f1\n        case f2\n        case f3\n        case f4\n        case f5\n        case f6\n        case f7\n        case f8\n        case f9\n        case f10\n        case f11\n        case f12\n        case f13\n        case f14\n        case f15\n        case f16\n        case f17\n        case f18\n        case f19\n        case f20\n        case f21\n        case f22\n        case f23\n        case f24\n        case f25\n        case fn\n        case fnLock\n        case printScreen\n        case scrollLock\n        case pause\n\n        // Media Keys\n        case browserBack\n        case browserFavorites\n        case browserForward\n        case browserHome\n        case browserRefresh\n        case browserSearch\n        case browserStop\n        case eject\n        case launchApp1\n        case launchApp2\n        case launchMail\n        case mediaPlayPause\n        case mediaSelect\n        case mediaStop\n        case mediaTrackNext\n        case mediaTrackPrevious\n        case power\n        case sleep\n        case audioVolumeDown\n        case audioVolumeMute\n        case audioVolumeUp\n        case wakeUp\n\n        // Legacy, Non-standard, and Special Keys\n        case copy\n        case cut\n        case paste\n\n        /// Get a key from a keycode\n        init?(keyCode: UInt16) {\n            if let key = Key.allCases.first(where: { $0.keyCode == keyCode }) {\n                self = key\n                return\n            }\n\n            return nil\n        }\n\n        var cKey: ghostty_input_key_e {\n            switch self {\n            // Writing System Keys\n            case .backquote: GHOSTTY_KEY_BACKQUOTE\n            case .backslash: GHOSTTY_KEY_BACKSLASH\n            case .bracketLeft: GHOSTTY_KEY_BRACKET_LEFT\n            case .bracketRight: GHOSTTY_KEY_BRACKET_RIGHT\n            case .comma: GHOSTTY_KEY_COMMA\n            case .digit0: GHOSTTY_KEY_DIGIT_0\n            case .digit1: GHOSTTY_KEY_DIGIT_1\n            case .digit2: GHOSTTY_KEY_DIGIT_2\n            case .digit3: GHOSTTY_KEY_DIGIT_3\n            case .digit4: GHOSTTY_KEY_DIGIT_4\n            case .digit5: GHOSTTY_KEY_DIGIT_5\n            case .digit6: GHOSTTY_KEY_DIGIT_6\n            case .digit7: GHOSTTY_KEY_DIGIT_7\n            case .digit8: GHOSTTY_KEY_DIGIT_8\n            case .digit9: GHOSTTY_KEY_DIGIT_9\n            case .equal: GHOSTTY_KEY_EQUAL\n            case .intlBackslash: GHOSTTY_KEY_INTL_BACKSLASH\n            case .intlRo: GHOSTTY_KEY_INTL_RO\n            case .intlYen: GHOSTTY_KEY_INTL_YEN\n            case .a: GHOSTTY_KEY_A\n            case .b: GHOSTTY_KEY_B\n            case .c: GHOSTTY_KEY_C\n            case .d: GHOSTTY_KEY_D\n            case .e: GHOSTTY_KEY_E\n            case .f: GHOSTTY_KEY_F\n            case .g: GHOSTTY_KEY_G\n            case .h: GHOSTTY_KEY_H\n            case .i: GHOSTTY_KEY_I\n            case .j: GHOSTTY_KEY_J\n            case .k: GHOSTTY_KEY_K\n            case .l: GHOSTTY_KEY_L\n            case .m: GHOSTTY_KEY_M\n            case .n: GHOSTTY_KEY_N\n            case .o: GHOSTTY_KEY_O\n            case .p: GHOSTTY_KEY_P\n            case .q: GHOSTTY_KEY_Q\n            case .r: GHOSTTY_KEY_R\n            case .s: GHOSTTY_KEY_S\n            case .t: GHOSTTY_KEY_T\n            case .u: GHOSTTY_KEY_U\n            case .v: GHOSTTY_KEY_V\n            case .w: GHOSTTY_KEY_W\n            case .x: GHOSTTY_KEY_X\n            case .y: GHOSTTY_KEY_Y\n            case .z: GHOSTTY_KEY_Z\n            case .minus: GHOSTTY_KEY_MINUS\n            case .period: GHOSTTY_KEY_PERIOD\n            case .quote: GHOSTTY_KEY_QUOTE\n            case .semicolon: GHOSTTY_KEY_SEMICOLON\n            case .slash: GHOSTTY_KEY_SLASH\n\n            // Functional Keys\n            case .altLeft: GHOSTTY_KEY_ALT_LEFT\n            case .altRight: GHOSTTY_KEY_ALT_RIGHT\n            case .backspace: GHOSTTY_KEY_BACKSPACE\n            case .capsLock: GHOSTTY_KEY_CAPS_LOCK\n            case .contextMenu: GHOSTTY_KEY_CONTEXT_MENU\n            case .controlLeft: GHOSTTY_KEY_CONTROL_LEFT\n            case .controlRight: GHOSTTY_KEY_CONTROL_RIGHT\n            case .enter: GHOSTTY_KEY_ENTER\n            case .metaLeft: GHOSTTY_KEY_META_LEFT\n            case .metaRight: GHOSTTY_KEY_META_RIGHT\n            case .shiftLeft: GHOSTTY_KEY_SHIFT_LEFT\n            case .shiftRight: GHOSTTY_KEY_SHIFT_RIGHT\n            case .space: GHOSTTY_KEY_SPACE\n            case .tab: GHOSTTY_KEY_TAB\n            case .convert: GHOSTTY_KEY_CONVERT\n            case .kanaMode: GHOSTTY_KEY_KANA_MODE\n            case .nonConvert: GHOSTTY_KEY_NON_CONVERT\n\n            // Control Pad Section\n            case .delete: GHOSTTY_KEY_DELETE\n            case .end: GHOSTTY_KEY_END\n            case .help: GHOSTTY_KEY_HELP\n            case .home: GHOSTTY_KEY_HOME\n            case .insert: GHOSTTY_KEY_INSERT\n            case .pageDown: GHOSTTY_KEY_PAGE_DOWN\n            case .pageUp: GHOSTTY_KEY_PAGE_UP\n\n            // Arrow Pad Section\n            case .arrowDown: GHOSTTY_KEY_ARROW_DOWN\n            case .arrowLeft: GHOSTTY_KEY_ARROW_LEFT\n            case .arrowRight: GHOSTTY_KEY_ARROW_RIGHT\n            case .arrowUp: GHOSTTY_KEY_ARROW_UP\n\n            // Numpad Section\n            case .numLock: GHOSTTY_KEY_NUM_LOCK\n            case .numpad0: GHOSTTY_KEY_NUMPAD_0\n            case .numpad1: GHOSTTY_KEY_NUMPAD_1\n            case .numpad2: GHOSTTY_KEY_NUMPAD_2\n            case .numpad3: GHOSTTY_KEY_NUMPAD_3\n            case .numpad4: GHOSTTY_KEY_NUMPAD_4\n            case .numpad5: GHOSTTY_KEY_NUMPAD_5\n            case .numpad6: GHOSTTY_KEY_NUMPAD_6\n            case .numpad7: GHOSTTY_KEY_NUMPAD_7\n            case .numpad8: GHOSTTY_KEY_NUMPAD_8\n            case .numpad9: GHOSTTY_KEY_NUMPAD_9\n            case .numpadAdd: GHOSTTY_KEY_NUMPAD_ADD\n            case .numpadBackspace: GHOSTTY_KEY_NUMPAD_BACKSPACE\n            case .numpadClear: GHOSTTY_KEY_NUMPAD_CLEAR\n            case .numpadClearEntry: GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY\n            case .numpadComma: GHOSTTY_KEY_NUMPAD_COMMA\n            case .numpadDecimal: GHOSTTY_KEY_NUMPAD_DECIMAL\n            case .numpadDivide: GHOSTTY_KEY_NUMPAD_DIVIDE\n            case .numpadEnter: GHOSTTY_KEY_NUMPAD_ENTER\n            case .numpadEqual: GHOSTTY_KEY_NUMPAD_EQUAL\n            case .numpadMemoryAdd: GHOSTTY_KEY_NUMPAD_MEMORY_ADD\n            case .numpadMemoryClear: GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR\n            case .numpadMemoryRecall: GHOSTTY_KEY_NUMPAD_MEMORY_RECALL\n            case .numpadMemoryStore: GHOSTTY_KEY_NUMPAD_MEMORY_STORE\n            case .numpadMemorySubtract: GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT\n            case .numpadMultiply: GHOSTTY_KEY_NUMPAD_MULTIPLY\n            case .numpadParenLeft: GHOSTTY_KEY_NUMPAD_PAREN_LEFT\n            case .numpadParenRight: GHOSTTY_KEY_NUMPAD_PAREN_RIGHT\n            case .numpadSubtract: GHOSTTY_KEY_NUMPAD_SUBTRACT\n            case .numpadSeparator: GHOSTTY_KEY_NUMPAD_SEPARATOR\n            case .numpadUp: GHOSTTY_KEY_NUMPAD_UP\n            case .numpadDown: GHOSTTY_KEY_NUMPAD_DOWN\n            case .numpadRight: GHOSTTY_KEY_NUMPAD_RIGHT\n            case .numpadLeft: GHOSTTY_KEY_NUMPAD_LEFT\n            case .numpadBegin: GHOSTTY_KEY_NUMPAD_BEGIN\n            case .numpadHome: GHOSTTY_KEY_NUMPAD_HOME\n            case .numpadEnd: GHOSTTY_KEY_NUMPAD_END\n            case .numpadInsert: GHOSTTY_KEY_NUMPAD_INSERT\n            case .numpadDelete: GHOSTTY_KEY_NUMPAD_DELETE\n            case .numpadPageUp: GHOSTTY_KEY_NUMPAD_PAGE_UP\n            case .numpadPageDown: GHOSTTY_KEY_NUMPAD_PAGE_DOWN\n\n            // Function Section\n            case .escape: GHOSTTY_KEY_ESCAPE\n            case .f1: GHOSTTY_KEY_F1\n            case .f2: GHOSTTY_KEY_F2\n            case .f3: GHOSTTY_KEY_F3\n            case .f4: GHOSTTY_KEY_F4\n            case .f5: GHOSTTY_KEY_F5\n            case .f6: GHOSTTY_KEY_F6\n            case .f7: GHOSTTY_KEY_F7\n            case .f8: GHOSTTY_KEY_F8\n            case .f9: GHOSTTY_KEY_F9\n            case .f10: GHOSTTY_KEY_F10\n            case .f11: GHOSTTY_KEY_F11\n            case .f12: GHOSTTY_KEY_F12\n            case .f13: GHOSTTY_KEY_F13\n            case .f14: GHOSTTY_KEY_F14\n            case .f15: GHOSTTY_KEY_F15\n            case .f16: GHOSTTY_KEY_F16\n            case .f17: GHOSTTY_KEY_F17\n            case .f18: GHOSTTY_KEY_F18\n            case .f19: GHOSTTY_KEY_F19\n            case .f20: GHOSTTY_KEY_F20\n            case .f21: GHOSTTY_KEY_F21\n            case .f22: GHOSTTY_KEY_F22\n            case .f23: GHOSTTY_KEY_F23\n            case .f24: GHOSTTY_KEY_F24\n            case .f25: GHOSTTY_KEY_F25\n            case .fn: GHOSTTY_KEY_FN\n            case .fnLock: GHOSTTY_KEY_FN_LOCK\n            case .printScreen: GHOSTTY_KEY_PRINT_SCREEN\n            case .scrollLock: GHOSTTY_KEY_SCROLL_LOCK\n            case .pause: GHOSTTY_KEY_PAUSE\n\n            // Media Keys\n            case .browserBack: GHOSTTY_KEY_BROWSER_BACK\n            case .browserFavorites: GHOSTTY_KEY_BROWSER_FAVORITES\n            case .browserForward: GHOSTTY_KEY_BROWSER_FORWARD\n            case .browserHome: GHOSTTY_KEY_BROWSER_HOME\n            case .browserRefresh: GHOSTTY_KEY_BROWSER_REFRESH\n            case .browserSearch: GHOSTTY_KEY_BROWSER_SEARCH\n            case .browserStop: GHOSTTY_KEY_BROWSER_STOP\n            case .eject: GHOSTTY_KEY_EJECT\n            case .launchApp1: GHOSTTY_KEY_LAUNCH_APP_1\n            case .launchApp2: GHOSTTY_KEY_LAUNCH_APP_2\n            case .launchMail: GHOSTTY_KEY_LAUNCH_MAIL\n            case .mediaPlayPause: GHOSTTY_KEY_MEDIA_PLAY_PAUSE\n            case .mediaSelect: GHOSTTY_KEY_MEDIA_SELECT\n            case .mediaStop: GHOSTTY_KEY_MEDIA_STOP\n            case .mediaTrackNext: GHOSTTY_KEY_MEDIA_TRACK_NEXT\n            case .mediaTrackPrevious: GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS\n            case .power: GHOSTTY_KEY_POWER\n            case .sleep: GHOSTTY_KEY_SLEEP\n            case .audioVolumeDown: GHOSTTY_KEY_AUDIO_VOLUME_DOWN\n            case .audioVolumeMute: GHOSTTY_KEY_AUDIO_VOLUME_MUTE\n            case .audioVolumeUp: GHOSTTY_KEY_AUDIO_VOLUME_UP\n            case .wakeUp: GHOSTTY_KEY_WAKE_UP\n\n            // Legacy, Non-standard, and Special Keys\n            case .copy: GHOSTTY_KEY_COPY\n            case .cut: GHOSTTY_KEY_CUT\n            case .paste: GHOSTTY_KEY_PASTE\n            }\n        }\n\n        // Based on src/input/keycodes.zig\n        var keyCode: UInt16? {\n            switch self {\n            // Writing System Keys\n            case .backquote: return 0x0032\n            case .backslash: return 0x002a\n            case .bracketLeft: return 0x0021\n            case .bracketRight: return 0x001e\n            case .comma: return 0x002b\n            case .digit0: return 0x001d\n            case .digit1: return 0x0012\n            case .digit2: return 0x0013\n            case .digit3: return 0x0014\n            case .digit4: return 0x0015\n            case .digit5: return 0x0017\n            case .digit6: return 0x0016\n            case .digit7: return 0x001a\n            case .digit8: return 0x001c\n            case .digit9: return 0x0019\n            case .equal: return 0x0018\n            case .intlBackslash: return 0x000a\n            case .intlRo: return 0x005e\n            case .intlYen: return 0x005d\n            case .a: return 0x0000\n            case .b: return 0x000b\n            case .c: return 0x0008\n            case .d: return 0x0002\n            case .e: return 0x000e\n            case .f: return 0x0003\n            case .g: return 0x0005\n            case .h: return 0x0004\n            case .i: return 0x0022\n            case .j: return 0x0026\n            case .k: return 0x0028\n            case .l: return 0x0025\n            case .m: return 0x002e\n            case .n: return 0x002d\n            case .o: return 0x001f\n            case .p: return 0x0023\n            case .q: return 0x000c\n            case .r: return 0x000f\n            case .s: return 0x0001\n            case .t: return 0x0011\n            case .u: return 0x0020\n            case .v: return 0x0009\n            case .w: return 0x000d\n            case .x: return 0x0007\n            case .y: return 0x0010\n            case .z: return 0x0006\n            case .minus: return 0x001b\n            case .period: return 0x002f\n            case .quote: return 0x0027\n            case .semicolon: return 0x0029\n            case .slash: return 0x002c\n\n            // Functional Keys\n            case .altLeft: return 0x003a\n            case .altRight: return 0x003d\n            case .backspace: return 0x0033\n            case .capsLock: return 0x0039\n            case .contextMenu: return 0x006e\n            case .controlLeft: return 0x003b\n            case .controlRight: return 0x003e\n            case .enter: return 0x0024\n            case .metaLeft: return 0x0037\n            case .metaRight: return 0x0036\n            case .shiftLeft: return 0x0038\n            case .shiftRight: return 0x003c\n            case .space: return 0x0031\n            case .tab: return 0x0030\n            case .convert: return nil // No Mac keycode\n            case .kanaMode: return nil // No Mac keycode\n            case .nonConvert: return nil // No Mac keycode\n\n            // Control Pad Section\n            case .delete: return 0x0075\n            case .end: return 0x0077\n            case .help: return nil // No Mac keycode\n            case .home: return 0x0073\n            case .insert: return 0x0072\n            case .pageDown: return 0x0079\n            case .pageUp: return 0x0074\n\n            // Arrow Pad Section\n            case .arrowDown: return 0x007d\n            case .arrowLeft: return 0x007b\n            case .arrowRight: return 0x007c\n            case .arrowUp: return 0x007e\n\n            // Numpad Section\n            case .numLock: return 0x0047\n            case .numpad0: return 0x0052\n            case .numpad1: return 0x0053\n            case .numpad2: return 0x0054\n            case .numpad3: return 0x0055\n            case .numpad4: return 0x0056\n            case .numpad5: return 0x0057\n            case .numpad6: return 0x0058\n            case .numpad7: return 0x0059\n            case .numpad8: return 0x005b\n            case .numpad9: return 0x005c\n            case .numpadAdd: return 0x0045\n            case .numpadBackspace: return nil // No Mac keycode\n            case .numpadClear: return nil // No Mac keycode\n            case .numpadClearEntry: return nil // No Mac keycode\n            case .numpadComma: return 0x005f\n            case .numpadDecimal: return 0x0041\n            case .numpadDivide: return 0x004b\n            case .numpadEnter: return 0x004c\n            case .numpadEqual: return 0x0051\n            case .numpadMemoryAdd: return nil // No Mac keycode\n            case .numpadMemoryClear: return nil // No Mac keycode\n            case .numpadMemoryRecall: return nil // No Mac keycode\n            case .numpadMemoryStore: return nil // No Mac keycode\n            case .numpadMemorySubtract: return nil // No Mac keycode\n            case .numpadMultiply: return 0x0043\n            case .numpadParenLeft: return nil // No Mac keycode\n            case .numpadParenRight: return nil // No Mac keycode\n            case .numpadSubtract: return 0x004e\n            case .numpadSeparator: return nil // No Mac keycode\n            case .numpadUp: return nil // No Mac keycode\n            case .numpadDown: return nil // No Mac keycode\n            case .numpadRight: return nil // No Mac keycode\n            case .numpadLeft: return nil // No Mac keycode\n            case .numpadBegin: return nil // No Mac keycode\n            case .numpadHome: return nil // No Mac keycode\n            case .numpadEnd: return nil // No Mac keycode\n            case .numpadInsert: return nil // No Mac keycode\n            case .numpadDelete: return nil // No Mac keycode\n            case .numpadPageUp: return nil // No Mac keycode\n            case .numpadPageDown: return nil // No Mac keycode\n\n            // Function Section\n            case .escape: return 0x0035\n            case .f1: return 0x007a\n            case .f2: return 0x0078\n            case .f3: return 0x0063\n            case .f4: return 0x0076\n            case .f5: return 0x0060\n            case .f6: return 0x0061\n            case .f7: return 0x0062\n            case .f8: return 0x0064\n            case .f9: return 0x0065\n            case .f10: return 0x006d\n            case .f11: return 0x0067\n            case .f12: return 0x006f\n            case .f13: return 0x0069\n            case .f14: return 0x006b\n            case .f15: return 0x0071\n            case .f16: return 0x006a\n            case .f17: return 0x0040\n            case .f18: return 0x004f\n            case .f19: return 0x0050\n            case .f20: return 0x005a\n            case .f21: return nil // No Mac keycode\n            case .f22: return nil // No Mac keycode\n            case .f23: return nil // No Mac keycode\n            case .f24: return nil // No Mac keycode\n            case .f25: return nil // No Mac keycode\n            case .fn: return nil // No Mac keycode\n            case .fnLock: return nil // No Mac keycode\n            case .printScreen: return nil // No Mac keycode\n            case .scrollLock: return nil // No Mac keycode\n            case .pause: return nil // No Mac keycode\n\n            // Media Keys\n            case .browserBack: return nil // No Mac keycode\n            case .browserFavorites: return nil // No Mac keycode\n            case .browserForward: return nil // No Mac keycode\n            case .browserHome: return nil // No Mac keycode\n            case .browserRefresh: return nil // No Mac keycode\n            case .browserSearch: return nil // No Mac keycode\n            case .browserStop: return nil // No Mac keycode\n            case .eject: return nil // No Mac keycode\n            case .launchApp1: return nil // No Mac keycode\n            case .launchApp2: return nil // No Mac keycode\n            case .launchMail: return nil // No Mac keycode\n            case .mediaPlayPause: return nil // No Mac keycode\n            case .mediaSelect: return nil // No Mac keycode\n            case .mediaStop: return nil // No Mac keycode\n            case .mediaTrackNext: return nil // No Mac keycode\n            case .mediaTrackPrevious: return nil // No Mac keycode\n            case .power: return nil // No Mac keycode\n            case .sleep: return nil // No Mac keycode\n            case .audioVolumeDown: return 0x0049\n            case .audioVolumeMute: return 0x004a\n            case .audioVolumeUp: return 0x0048\n            case .wakeUp: return nil // No Mac keycode\n\n            // Legacy, Non-standard, and Special Keys\n            case .copy: return nil // No Mac keycode\n            case .cut: return nil // No Mac keycode\n            case .paste: return nil // No Mac keycode\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.KeyEvent.swift",
    "content": "import Foundation\nimport CGhostty\n\nextension Ghostty.Input {\n    /// `ghostty_input_key_s`\n    struct KeyEvent {\n        let action: Action\n        let key: Key\n        let text: String?\n        let composing: Bool\n        let mods: Mods\n        let consumedMods: Mods\n        let unshiftedCodepoint: UInt32\n\n        init(\n            key: Key,\n            action: Action = .press,\n            text: String? = nil,\n            composing: Bool = false,\n            mods: Mods = [],\n            consumedMods: Mods = [],\n            unshiftedCodepoint: UInt32 = 0\n        ) {\n            self.key = key\n            self.action = action\n            self.text = text\n            self.composing = composing\n            self.mods = mods\n            self.consumedMods = consumedMods\n            self.unshiftedCodepoint = unshiftedCodepoint\n        }\n\n        init?(cValue: ghostty_input_key_s) {\n            // Convert action\n            switch cValue.action {\n            case GHOSTTY_ACTION_PRESS: self.action = .press\n            case GHOSTTY_ACTION_RELEASE: self.action = .release\n            case GHOSTTY_ACTION_REPEAT: self.action = .repeat\n            default: self.action = .press\n            }\n\n            // Convert key from keycode\n            guard let key = Key(keyCode: UInt16(cValue.keycode)) else { return nil }\n            self.key = key\n\n            // Convert text\n            if let textPtr = cValue.text {\n                self.text = String(cString: textPtr)\n            } else {\n                self.text = nil\n            }\n\n            // Set composing state\n            self.composing = cValue.composing\n\n            // Convert modifiers\n            self.mods = Mods(cMods: cValue.mods)\n            self.consumedMods = Mods(cMods: cValue.consumed_mods)\n\n            // Set unshifted codepoint\n            self.unshiftedCodepoint = cValue.unshifted_codepoint\n        }\n\n        /// Executes a closure with a temporary C representation of this KeyEvent.\n        ///\n        /// This method safely converts the Swift KeyEntity to a C `ghostty_input_key_s` struct\n        /// and passes it to the provided closure. The C struct is only valid within the closure's\n        /// execution scope. The text field's C string pointer is managed automatically and will\n        /// be invalid after the closure returns.\n        ///\n        /// - Parameter execute: A closure that receives the C struct and returns a value\n        /// - Returns: The value returned by the closure\n        @discardableResult\n        func withCValue<T>(execute: (ghostty_input_key_s) -> T) -> T {\n            var keyEvent = ghostty_input_key_s()\n            keyEvent.action = action.cAction\n            keyEvent.keycode = UInt32(key.keyCode ?? 0)\n            keyEvent.composing = composing\n            keyEvent.mods = mods.cMods\n            keyEvent.consumed_mods = consumedMods.cMods\n            keyEvent.unshifted_codepoint = unshiftedCodepoint\n\n            // Handle text with proper memory management\n            if let text = text {\n                return text.withCString { textPtr in\n                    keyEvent.text = textPtr\n                    return execute(keyEvent)\n                }\n            } else {\n                keyEvent.text = nil\n                return execute(keyEvent)\n            }\n        }\n    }\n}\n\n// MARK: Ghostty.Input.Action\n\nextension Ghostty.Input {\n    /// `ghostty_input_action_e`\n    enum Action: String, CaseIterable {\n        case release\n        case press\n        case `repeat`\n\n        var cAction: ghostty_input_action_e {\n            switch self {\n            case .release: GHOSTTY_ACTION_RELEASE\n            case .press: GHOSTTY_ACTION_PRESS\n            case .repeat: GHOSTTY_ACTION_REPEAT\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.Mods.swift",
    "content": "import AppKit\nimport Foundation\nimport CGhostty\n\nextension Ghostty.Input {\n    /// `ghostty_input_mods_e`\n    struct Mods: OptionSet {\n        let rawValue: UInt32\n\n        static let none = Mods(rawValue: GHOSTTY_MODS_NONE.rawValue)\n        static let shift = Mods(rawValue: GHOSTTY_MODS_SHIFT.rawValue)\n        static let ctrl = Mods(rawValue: GHOSTTY_MODS_CTRL.rawValue)\n        static let alt = Mods(rawValue: GHOSTTY_MODS_ALT.rawValue)\n        static let `super` = Mods(rawValue: GHOSTTY_MODS_SUPER.rawValue)\n        static let caps = Mods(rawValue: GHOSTTY_MODS_CAPS.rawValue)\n        static let shiftRight = Mods(rawValue: GHOSTTY_MODS_SHIFT_RIGHT.rawValue)\n        static let ctrlRight = Mods(rawValue: GHOSTTY_MODS_CTRL_RIGHT.rawValue)\n        static let altRight = Mods(rawValue: GHOSTTY_MODS_ALT_RIGHT.rawValue)\n        static let superRight = Mods(rawValue: GHOSTTY_MODS_SUPER_RIGHT.rawValue)\n\n        var cMods: ghostty_input_mods_e {\n            ghostty_input_mods_e(rawValue)\n        }\n\n        init(rawValue: UInt32) {\n            self.rawValue = rawValue\n        }\n\n        init(cMods: ghostty_input_mods_e) {\n            self.rawValue = cMods.rawValue\n        }\n\n        init(nsFlags: NSEvent.ModifierFlags) {\n            self.init(cMods: Ghostty.ghosttyMods(nsFlags))\n        }\n\n        var nsFlags: NSEvent.ModifierFlags {\n            Ghostty.eventModifierFlags(mods: cMods)\n        }\n    }\n}\n\n// MARK: Ghostty.Input.ScrollMods\n\nextension Ghostty.Input {\n    /// `ghostty_input_scroll_mods_t` - Scroll event modifiers\n    ///\n    /// This is a packed bitmask that contains precision and momentum information\n    /// for scroll events, matching the Zig `ScrollMods` packed struct.\n    struct ScrollMods {\n        let rawValue: Int32\n\n        /// True if this is a high-precision scroll event (e.g., trackpad, Magic Mouse)\n        var precision: Bool {\n            rawValue & 0b0000_0001 != 0\n        }\n\n        /// The momentum phase of the scroll event for inertial scrolling\n        var momentum: Momentum {\n            let momentumBits = (rawValue >> 1) & 0b0000_0111\n            return Momentum(rawValue: UInt8(momentumBits)) ?? .none\n        }\n\n        init(precision: Bool = false, momentum: Momentum = .none) {\n            var value: Int32 = 0\n            if precision {\n                value |= 0b0000_0001\n            }\n            value |= Int32(momentum.rawValue) << 1\n            self.rawValue = value\n        }\n\n        init(rawValue: Int32) {\n            self.rawValue = rawValue\n        }\n\n        var cScrollMods: ghostty_input_scroll_mods_t {\n            rawValue\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.MouseEvent.swift",
    "content": "import AppKit\nimport Foundation\nimport CGhostty\n\nextension Ghostty.Input {\n    /// Represents a mouse input event with button state, button type, and modifier keys.\n    struct MouseButtonEvent {\n        let action: MouseState\n        let button: MouseButton\n        let mods: Mods\n\n        init(\n            action: MouseState,\n            button: MouseButton,\n            mods: Mods = []\n        ) {\n            self.action = action\n            self.button = button\n            self.mods = mods\n        }\n\n        /// Creates a MouseEvent from C enum values.\n        ///\n        /// This initializer converts C-style mouse input enums to Swift types.\n        /// Returns nil if any of the C enum values are invalid or unsupported.\n        ///\n        /// - Parameters:\n        ///   - state: The mouse button state (press/release)\n        ///   - button: The mouse button that was pressed/released\n        ///   - mods: The modifier keys held during the mouse event\n        init?(state: ghostty_input_mouse_state_e, button: ghostty_input_mouse_button_e, mods: ghostty_input_mods_e) {\n            // Convert state\n            switch state {\n            case GHOSTTY_MOUSE_RELEASE: self.action = .release\n            case GHOSTTY_MOUSE_PRESS: self.action = .press\n            default: return nil\n            }\n\n            // Convert button\n            switch button {\n            case GHOSTTY_MOUSE_UNKNOWN: self.button = .unknown\n            case GHOSTTY_MOUSE_LEFT: self.button = .left\n            case GHOSTTY_MOUSE_RIGHT: self.button = .right\n            case GHOSTTY_MOUSE_MIDDLE: self.button = .middle\n            default: return nil\n            }\n\n            // Convert modifiers\n            self.mods = Mods(cMods: mods)\n        }\n    }\n\n    /// Represents a mouse position/movement event with coordinates and modifier keys.\n    struct MousePosEvent {\n        let x: Double\n        let y: Double\n        let mods: Mods\n\n        init(\n            x: Double,\n            y: Double,\n            mods: Mods = []\n        ) {\n            self.x = x\n            self.y = y\n            self.mods = mods\n        }\n    }\n\n    /// Represents a mouse scroll event with scroll deltas and modifier keys.\n    struct MouseScrollEvent {\n        let x: Double\n        let y: Double\n        let mods: ScrollMods\n\n        init(\n            x: Double,\n            y: Double,\n            mods: ScrollMods = .init(rawValue: 0)\n        ) {\n            self.x = x\n            self.y = y\n            self.mods = mods\n        }\n    }\n}\n\n// MARK: Ghostty.Input.MouseState\n\nextension Ghostty.Input {\n    /// `ghostty_input_mouse_state_e`\n    enum MouseState: String, CaseIterable {\n        case release\n        case press\n\n        var cMouseState: ghostty_input_mouse_state_e {\n            switch self {\n            case .release: GHOSTTY_MOUSE_RELEASE\n            case .press: GHOSTTY_MOUSE_PRESS\n            }\n        }\n    }\n}\n\n// MARK: Ghostty.Input.MouseButton\n\nextension Ghostty.Input {\n    /// `ghostty_input_mouse_button_e`\n    enum MouseButton: String, CaseIterable {\n        case unknown\n        case left\n        case right\n        case middle\n\n        var cMouseButton: ghostty_input_mouse_button_e {\n            switch self {\n            case .unknown: GHOSTTY_MOUSE_UNKNOWN\n            case .left: GHOSTTY_MOUSE_LEFT\n            case .right: GHOSTTY_MOUSE_RIGHT\n            case .middle: GHOSTTY_MOUSE_MIDDLE\n            }\n        }\n    }\n}\n\n// MARK: Ghostty.Input.Momentum\n\nextension Ghostty.Input {\n    /// `ghostty_input_mouse_momentum_e` - Momentum phase for scroll events\n    enum Momentum: UInt8, CaseIterable {\n        case none = 0\n        case began = 1\n        case stationary = 2\n        case changed = 3\n        case ended = 4\n        case cancelled = 5\n        case mayBegin = 6\n\n        var cMomentum: ghostty_input_mouse_momentum_e {\n            switch self {\n            case .none: GHOSTTY_MOUSE_MOMENTUM_NONE\n            case .began: GHOSTTY_MOUSE_MOMENTUM_BEGAN\n            case .stationary: GHOSTTY_MOUSE_MOMENTUM_STATIONARY\n            case .changed: GHOSTTY_MOUSE_MOMENTUM_CHANGED\n            case .ended: GHOSTTY_MOUSE_MOMENTUM_ENDED\n            case .cancelled: GHOSTTY_MOUSE_MOMENTUM_CANCELLED\n            case .mayBegin: GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN\n            }\n        }\n    }\n}\n\nextension Ghostty.Input.Momentum {\n    /// Create a Momentum from an NSEvent.Phase\n    init(_ phase: NSEvent.Phase) {\n        switch phase {\n        case .began: self = .began\n        case .stationary: self = .stationary\n        case .changed: self = .changed\n        case .ended: self = .ended\n        case .cancelled: self = .cancelled\n        case .mayBegin: self = .mayBegin\n        default: self = .none\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/Ghostty.Surface.swift",
    "content": "import Foundation\nimport CGhostty\n\nextension Ghostty {\n    /// Represents a single surface within Ghostty.\n    ///\n    /// Wraps a `ghostty_surface_t`\n    final class Surface: @unchecked Sendable {\n        private struct Handle: @unchecked Sendable {\n            let value: ghostty_surface_t\n        }\n\n        private let handle: Handle\n        private let userdataToRelease: UnsafeMutableRawPointer?\n\n        /// Read the underlying C value for this surface. This is unsafe because the value will be\n        /// freed when the Surface class is deinitialized.\n        var unsafeCValue: ghostty_surface_t {\n            handle.value\n        }\n\n        /// Initialize from the C structure.\n        init(cSurface: ghostty_surface_t, userdataToRelease: UnsafeMutableRawPointer? = nil) {\n            self.handle = Handle(value: cSurface)\n            self.userdataToRelease = userdataToRelease\n        }\n\n        deinit {\n            // deinit is not guaranteed to happen on the main actor and our API\n            // calls into libghostty must happen there so we capture the surface\n            // value so we don't capture `self` and then we detach it in a task.\n            // We can't wait for the task to succeed so this will happen sometime\n            // but that's okay.\n            Task.detached { @MainActor [handle, userdataToRelease] in\n                ghostty_surface_free(handle.value)\n                if let userdataToRelease = userdataToRelease {\n                    Unmanaged<Ghostty.SurfaceUserdata>.fromOpaque(userdataToRelease).release()\n                }\n            }\n        }\n\n        /// Send text to the terminal as if it was typed. This doesn't send the key events so keyboard\n        /// shortcuts and other encodings do not take effect.\n        @MainActor\n        func sendText(_ text: String) {\n            let len = text.utf8CString.count\n            if (len == 0) { return }\n\n            text.withCString { ptr in\n                // len includes the null terminator so we do len - 1\n                ghostty_surface_text(handle.value, ptr, UInt(len - 1))\n            }\n        }\n\n        /// Send a key event to the terminal.\n        ///\n        /// This sends the full key event including modifiers, action type, and text to the terminal.\n        /// Unlike `sendText`, this method processes keyboard shortcuts, key bindings, and terminal\n        /// encoding based on the complete key event information.\n        ///\n        /// - Parameter event: The key event to send to the terminal\n        @MainActor\n        func sendKeyEvent(_ event: Input.KeyEvent) {\n            event.withCValue { cEvent in\n                ghostty_surface_key(handle.value, cEvent)\n            }\n        }\n\n        /// Whether the terminal has captured mouse input.\n        ///\n        /// When the mouse is captured, the terminal application is receiving mouse events\n        /// directly rather than the host system handling them. This typically occurs when\n        /// a terminal application enables mouse reporting mode.\n        @MainActor\n        var mouseCaptured: Bool {\n            ghostty_surface_mouse_captured(handle.value)\n        }\n\n        /// Whether closing this terminal requires user confirmation.\n        ///\n        /// Returns true if the terminal is busy (command running, cursor not at prompt).\n        /// Uses Ghostty's internal prompt detection to avoid confirming idle shells.\n        @MainActor\n        var needsConfirmQuit: Bool {\n            ghostty_surface_needs_confirm_quit(handle.value)\n        }\n\n        /// Send a mouse button event to the terminal.\n        ///\n        /// This sends a complete mouse button event including the button state (press/release),\n        /// which button was pressed, and any modifier keys that were held during the event.\n        /// The terminal processes this event according to its mouse handling configuration.\n        ///\n        /// - Parameter event: The mouse button event to send to the terminal\n        @MainActor\n        func sendMouseButton(_ event: Input.MouseButtonEvent) {\n            ghostty_surface_mouse_button(\n                handle.value,\n                event.action.cMouseState,\n                event.button.cMouseButton,\n                event.mods.cMods)\n        }\n\n        /// Send a mouse position event to the terminal.\n        ///\n        /// This reports the current mouse position to the terminal, which may be used\n        /// for mouse tracking, hover effects, or other position-dependent features.\n        /// The terminal will only receive these events if mouse reporting is enabled.\n        ///\n        /// - Parameter event: The mouse position event to send to the terminal\n        @MainActor\n        func sendMousePos(_ event: Input.MousePosEvent) {\n            ghostty_surface_mouse_pos(\n                handle.value,\n                event.x,\n                event.y,\n                event.mods.cMods)\n        }\n\n        /// Send a mouse scroll event to the terminal.\n        ///\n        /// This sends scroll wheel input to the terminal with delta values for both\n        /// horizontal and vertical scrolling, along with precision and momentum information.\n        /// The terminal processes this according to its scroll handling configuration.\n        ///\n        /// - Parameter event: The mouse scroll event to send to the terminal\n        @MainActor\n        func sendMouseScroll(_ event: Input.MouseScrollEvent) {\n            ghostty_surface_mouse_scroll(\n                handle.value,\n                event.x,\n                event.y,\n                event.mods.cScrollMods)\n        }\n\n        /// Perform a keybinding action.\n        ///\n        /// The action can be any valid keybind parameter. e.g. `keybind = goto_tab:4`\n        /// you can perform `goto_tab:4` with this.\n        ///\n        /// Returns true if the action was performed. Invalid actions return false.\n        @MainActor\n        func perform(action: String) -> Bool {\n            let len = action.utf8CString.count\n            if (len == 0) { return false }\n            return action.withCString { cString in\n                ghostty_surface_binding_action(handle.value, cString, UInt(len - 1))\n            }\n        }\n\n        /// Terminal grid size information\n        struct TerminalSize {\n            let columns: UInt16\n            let rows: UInt16\n            let widthPx: UInt32\n            let heightPx: UInt32\n            let cellWidthPx: UInt32\n            let cellHeightPx: UInt32\n        }\n\n        /// Get current terminal size\n        @MainActor\n        func terminalSize() -> TerminalSize {\n            let cSize = ghostty_surface_size(handle.value)\n            return TerminalSize(\n                columns: cSize.columns,\n                rows: cSize.rows,\n                widthPx: cSize.width_px,\n                heightPx: cSize.height_px,\n                cellWidthPx: cSize.cell_width_px,\n                cellHeightPx: cSize.cell_height_px\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyIMEHandler.swift",
    "content": "//\n//  GhosttyIMEHandler.swift\n//  CodMate\n//\n//  Handles Input Method Editor (IME) support for Ghostty terminal\n//  Enables proper input for Japanese, Chinese, Korean, etc.\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport OSLog\nimport CGhostty\n\n/// Manages IME (Input Method Editor) state and text input handling for Ghostty terminal\n@MainActor\nclass GhosttyIMEHandler {\n    // MARK: - Properties\n\n    private weak var view: NSView?\n    private weak var surface: Ghostty.Surface?\n\n    /// Track marked text for IME composition\n    private(set) var markedText: String = \"\"\n\n    /// Attributes for displaying marked text\n    private let markedTextAttributes: [NSAttributedString.Key: Any] = [\n        .underlineStyle: NSUnderlineStyle.single.rawValue,\n        .underlineColor: NSColor.textColor\n    ]\n\n    /// Accumulates text from insertText calls during keyDown\n    /// Set to non-nil during keyDown to track if IME inserted text\n    private(set) var keyTextAccumulator: [String]?\n\n    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"GhosttyIME\")\n\n    // MARK: - Initialization\n\n    init(view: NSView, surface: Ghostty.Surface?) {\n        self.view = view\n        self.surface = surface\n    }\n\n    // MARK: - Public API\n\n    /// Update surface reference\n    func updateSurface(_ surface: Ghostty.Surface?) {\n        self.surface = surface\n    }\n\n    /// Check if currently composing marked text\n    var hasMarkedText: Bool {\n        !markedText.isEmpty\n    }\n\n    /// Start accumulating text from insertText calls (call before interpretKeyEvents)\n    func beginKeyTextAccumulation() {\n        keyTextAccumulator = []\n    }\n\n    /// End accumulation and return accumulated texts (call after interpretKeyEvents)\n    func endKeyTextAccumulation() -> [String]? {\n        defer { keyTextAccumulator = nil }\n        return keyTextAccumulator\n    }\n\n    /// Clear marked text state\n    func clearMarkedText() {\n        if !markedText.isEmpty {\n            markedText = \"\"\n            view?.needsDisplay = true\n        }\n    }\n\n    // MARK: - NSTextInputClient Methods\n\n    func insertText(_ string: Any, replacementRange: NSRange) {\n        guard let text = anyToString(string) else { return }\n\n        // Clear any marked text when committing\n        clearMarkedText()\n\n        // If we're in a keyDown event (accumulator exists), accumulate the text\n        // The keyDown handler will send it to the terminal\n        if keyTextAccumulator != nil {\n            keyTextAccumulator?.append(text)\n            return\n        }\n\n        // Otherwise send directly to terminal (e.g., paste operation)\n        surface?.sendText(text)\n    }\n\n    func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {\n        guard let text = anyToString(string) else { return }\n\n        // Update marked text state\n        markedText = text\n\n        // Tell system we've handled the marked text\n        view?.inputContext?.invalidateCharacterCoordinates()\n        view?.needsDisplay = true\n\n        Self.logger.debug(\"IME marked text: \\(text)\")\n    }\n\n    func unmarkText() {\n        // Commit any pending marked text\n        if !markedText.isEmpty {\n            surface?.sendText(markedText)\n            markedText = \"\"\n            view?.needsDisplay = true\n        }\n    }\n\n    func selectedRange() -> NSRange {\n        // Terminals don't have text selection in the traditional sense for IME\n        return NSRange(location: NSNotFound, length: 0)\n    }\n\n    func markedRange() -> NSRange {\n        // Return range of marked text if we have any\n        if markedText.isEmpty {\n            return NSRange(location: NSNotFound, length: 0)\n        }\n        return NSRange(location: 0, length: markedText.utf16.count)\n    }\n\n    func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {\n        // Return attributed marked text for IME window\n        guard !markedText.isEmpty else { return nil }\n\n        let attributedString = NSAttributedString(\n            string: markedText,\n            attributes: markedTextAttributes\n        )\n\n        if actualRange != nil {\n            actualRange?.pointee = NSRange(location: 0, length: markedText.utf16.count)\n        }\n\n        return attributedString\n    }\n\n    func validAttributesForMarkedText() -> [NSAttributedString.Key] {\n        return [\n            .underlineStyle,\n            .underlineColor,\n            .backgroundColor,\n            .foregroundColor\n        ]\n    }\n\n    func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?, viewFrame: NSRect, window: NSWindow?, surface: ghostty_surface_t?) -> NSRect {\n        // Get cursor position from Ghostty for IME window placement\n        guard let surface = surface else {\n            return NSRect(x: viewFrame.origin.x, y: viewFrame.origin.y, width: 0, height: 0)\n        }\n\n        var x: Double = 0\n        var y: Double = 0\n        var width: Double = 0\n        var height: Double = 0\n\n        // Get IME cursor position from Ghostty\n        ghostty_surface_ime_point(surface, &x, &y, &width, &height)\n\n        // Ghostty coordinates are in top-left (0, 0) origin, but AppKit expects bottom-left\n        // Convert Y coordinate by subtracting from frame height\n        let viewRect = NSRect(\n            x: x,\n            y: viewFrame.size.height - y,\n            width: range.length == 0 ? 0 : max(width, 1),\n            height: max(height, 1)\n        )\n\n        // Convert to window coordinates\n        guard let view = view else { return viewRect }\n        let windowRect = view.convert(viewRect, to: nil)\n\n        // Convert to screen coordinates\n        guard let window = window else { return windowRect }\n        return window.convertToScreen(windowRect)\n    }\n\n    func characterIndex(for point: NSPoint) -> Int {\n        return NSNotFound\n    }\n\n    // MARK: - Helper\n\n    private func anyToString(_ string: Any) -> String? {\n        switch string {\n        case let string as NSString:\n            return string as String\n        case let string as NSAttributedString:\n            return string.string\n        default:\n            return nil\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyInputHandler.swift",
    "content": "//\n//  GhosttyInputHandler.swift\n//  CodMate\n//\n//  Handles keyboard, mouse, and scroll input forwarding to Ghostty terminal\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport OSLog\nimport CGhostty\n\n/// Manages input event forwarding (keyboard, mouse, scroll) to Ghostty terminal\n@MainActor\nclass GhosttyInputHandler {\n    // MARK: - Properties\n\n    private weak var view: NSView?\n    private weak var surface: Ghostty.Surface?\n    private weak var imeHandler: GhosttyIMEHandler?\n\n    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"GhosttyInput\")\n\n    // MARK: - Initialization\n\n    init(view: NSView, surface: Ghostty.Surface?, imeHandler: GhosttyIMEHandler) {\n        self.view = view\n        self.surface = surface\n        self.imeHandler = imeHandler\n    }\n\n    // MARK: - Public API\n\n    /// Update surface reference\n    func updateSurface(_ surface: Ghostty.Surface?) {\n        self.surface = surface\n    }\n\n    // MARK: - Keyboard Input\n\n    func handleKeyDown(with event: NSEvent, interpretKeyEvents: @escaping ([NSEvent]) -> Void) {\n        guard let surface = surface else {\n            Self.logger.warning(\"keyDown: no surface\")\n            // Even without surface, call interpretKeyEvents for IME support\n            interpretKeyEvents([event])\n            return\n        }\n\n        if let terminalView = view as? GhosttyTerminalView,\n           terminalView.handlePasteCommandIfNeeded(event) {\n            return\n        }\n\n        let action = event.isARepeat ? GHOSTTY_ACTION_REPEAT : GHOSTTY_ACTION_PRESS\n\n        // Track if we had marked text before this event\n        // Important for handling ESC and backspace during IME composition\n        let markedTextBefore = imeHandler?.hasMarkedText ?? false\n\n        // Set up key text accumulator to track insertText calls\n        imeHandler?.beginKeyTextAccumulation()\n        defer {\n            _ = imeHandler?.endKeyTextAccumulation()\n        }\n\n        // Call interpretKeyEvents to allow IME processing\n        // This may call insertText (text committed) or setMarkedText (composing)\n        interpretKeyEvents([event])\n\n        // If we have accumulated text, it means insertText was called\n        // Send the composed text to the terminal\n        if let texts = imeHandler?.endKeyTextAccumulation(), !texts.isEmpty {\n            for text in texts {\n                text.withCString { ptr in\n                    var keyEvent = event.ghosttyKeyEvent(action)\n                    keyEvent.text = ptr\n                    keyEvent.composing = false\n                    ghostty_surface_key(surface.unsafeCValue, keyEvent)\n                }\n            }\n            return\n        }\n\n        // If we're still composing (have marked text), don't send key event\n        // OR if we had marked text before and pressed a key like backspace/ESC,\n        // we're still in composing mode\n        let isComposing = (imeHandler?.hasMarkedText ?? false) || markedTextBefore\n        if isComposing {\n            // ESC or backspace during composition shouldn't be sent to terminal\n            return\n        }\n\n        // Normal key event - no IME involvement\n        // Call ghostty_surface_key directly (like Ghostty does) to avoid\n        // potential issues with Swift wrapper conversions dropping events\n        var keyEvent = event.ghosttyKeyEvent(action)\n\n        // Set text field if we have printable characters\n        // Control characters (< 0x20) are encoded by Ghostty itself\n        if let chars = event.ghosttyCharacters,\n           let codepoint = chars.utf8.first,\n           codepoint >= 0x20 {\n            chars.withCString { textPtr in\n                keyEvent.text = textPtr\n                keyEvent.composing = false\n                ghostty_surface_key(surface.unsafeCValue, keyEvent)\n            }\n        } else {\n            keyEvent.text = nil\n            keyEvent.composing = false\n            ghostty_surface_key(surface.unsafeCValue, keyEvent)\n        }\n    }\n\n    func handleKeyUp(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        var keyEvent = event.ghosttyKeyEvent(GHOSTTY_ACTION_RELEASE)\n        keyEvent.text = nil\n\n        if let inputEvent = Ghostty.Input.KeyEvent(cValue: keyEvent) {\n            surface.sendKeyEvent(inputEvent)\n        }\n    }\n\n    func handleFlagsChanged(with event: NSEvent) {\n        guard let surface = surface?.unsafeCValue else { return }\n\n        // Determine which modifier key changed\n        let mods = Ghostty.ghosttyMods(event.modifierFlags)\n        let mod: UInt32\n\n        switch event.keyCode {\n        case 0x39: mod = GHOSTTY_MODS_CAPS.rawValue\n        case 0x38, 0x3C: mod = GHOSTTY_MODS_SHIFT.rawValue\n        case 0x3B, 0x3E: mod = GHOSTTY_MODS_CTRL.rawValue\n        case 0x3A, 0x3D: mod = GHOSTTY_MODS_ALT.rawValue\n        case 0x37, 0x36: mod = GHOSTTY_MODS_SUPER.rawValue\n        default: return\n        }\n\n        // Determine if press or release\n        let action: ghostty_input_action_e = (mods.rawValue & mod != 0)\n            ? GHOSTTY_ACTION_PRESS\n            : GHOSTTY_ACTION_RELEASE\n\n        // Send to Ghostty\n        var keyEvent = event.ghosttyKeyEvent(action)\n        keyEvent.text = nil\n        ghostty_surface_key(surface, keyEvent)\n    }\n\n    // MARK: - Mouse Input\n\n    func handleMouseDown(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .press,\n            button: .left,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleMouseUp(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .release,\n            button: .left,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleRightMouseDown(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .press,\n            button: .right,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleRightMouseUp(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .release,\n            button: .right,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleOtherMouseDown(with event: NSEvent) {\n        guard event.buttonNumber == 2 else { return }\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .press,\n            button: .middle,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleOtherMouseUp(with event: NSEvent) {\n        guard event.buttonNumber == 2 else { return }\n        guard let surface = surface else { return }\n\n        let mouseEvent = Ghostty.Input.MouseButtonEvent(\n            action: .release,\n            button: .middle,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMouseButton(mouseEvent)\n    }\n\n    func handleMouseMoved(with event: NSEvent, viewFrame: NSRect, convertPoint: (NSPoint, NSView?) -> NSPoint) {\n        guard let surface = surface else { return }\n\n        // Convert window coords to view coords\n        // Ghostty expects top-left origin (y inverted from AppKit)\n        let pos = convertPoint(event.locationInWindow, nil)\n        let mouseEvent = Ghostty.Input.MousePosEvent(\n            x: pos.x,\n            y: viewFrame.height - pos.y,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMousePos(mouseEvent)\n    }\n\n    func handleMouseEntered(with event: NSEvent, viewFrame: NSRect, convertPoint: (NSPoint, NSView?) -> NSPoint) {\n        guard let surface = surface else { return }\n\n        // Report mouse entering the viewport\n        let pos = convertPoint(event.locationInWindow, nil)\n        let mouseEvent = Ghostty.Input.MousePosEvent(\n            x: pos.x,\n            y: viewFrame.height - pos.y,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMousePos(mouseEvent)\n    }\n\n    func handleMouseExited(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        // Negative values signal cursor left viewport\n        let mouseEvent = Ghostty.Input.MousePosEvent(\n            x: -1,\n            y: -1,\n            mods: Ghostty.Input.Mods(nsFlags: event.modifierFlags)\n        )\n        surface.sendMousePos(mouseEvent)\n    }\n\n    // MARK: - Scroll Input\n\n    func handleScrollWheel(with event: NSEvent) {\n        guard let surface = surface else { return }\n\n        var x = event.scrollingDeltaX\n        var y = event.scrollingDeltaY\n        let precision = event.hasPreciseScrollingDeltas\n\n        if precision {\n            // 2x speed multiplier for precise scrolling (trackpad)\n            x *= 2\n            y *= 2\n        }\n\n        let scrollEvent = Ghostty.Input.MouseScrollEvent(\n            x: x,\n            y: y,\n            mods: Ghostty.Input.ScrollMods(\n                precision: precision,\n                momentum: Ghostty.Input.Momentum(event.momentumPhase)\n            )\n        )\n        surface.sendMouseScroll(scrollEvent)\n    }\n}\n\n// MARK: - NSEvent Extensions\n\nextension NSEvent {\n    /// Create a Ghostty key event from NSEvent\n    func ghosttyKeyEvent(_ action: ghostty_input_action_e) -> ghostty_input_key_s {\n        var keyEvent = ghostty_input_key_s()\n        keyEvent.action = action\n        keyEvent.keycode = UInt32(keyCode)\n        keyEvent.mods = Ghostty.ghosttyMods(modifierFlags)\n        keyEvent.consumed_mods = Ghostty.ghosttyMods(\n            modifierFlags.subtracting([.control, .command])\n        )\n\n        // Unshifted codepoint for key identification\n        if type == .keyDown || type == .keyUp,\n           let chars = characters(byApplyingModifiers: []),\n           let codepoint = chars.unicodeScalars.first {\n            keyEvent.unshifted_codepoint = codepoint.value\n        } else {\n            keyEvent.unshifted_codepoint = 0\n        }\n\n        keyEvent.text = nil\n        keyEvent.composing = false\n\n        return keyEvent\n    }\n\n    /// Get characters appropriate for Ghostty (excluding control chars and PUA)\n    var ghosttyCharacters: String? {\n        guard let characters = characters else { return nil }\n\n        if characters.count == 1,\n           let scalar = characters.unicodeScalars.first {\n            // Skip control characters (Ghostty handles internally)\n            if scalar.value < 0x20 {\n                return self.characters(byApplyingModifiers: modifierFlags.subtracting(.control))\n            }\n\n            // Skip Private Use Area (function keys)\n            if scalar.value >= 0xF700 && scalar.value <= 0xF8FF {\n                return nil\n            }\n        }\n\n        return characters\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyProgressState.swift",
    "content": "//\n//  GhosttyProgressState.swift\n//  CodMate\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport Foundation\nimport CGhostty\n\nenum GhosttyProgressState {\n    case remove\n    case set\n    case error\n    case indeterminate\n    case pause\n    case unknown\n\n    init(cState: ghostty_action_progress_report_state_e) {\n        switch cState {\n        case GHOSTTY_PROGRESS_STATE_REMOVE: self = .remove\n        case GHOSTTY_PROGRESS_STATE_SET: self = .set\n        case GHOSTTY_PROGRESS_STATE_ERROR: self = .error\n        case GHOSTTY_PROGRESS_STATE_INDETERMINATE: self = .indeterminate\n        case GHOSTTY_PROGRESS_STATE_PAUSE: self = .pause\n        default: self = .unknown\n        }\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyRenderingSetup.swift",
    "content": "//\n//  GhosttyRenderingSetup.swift\n//  CodMate\n//\n//  Handles Metal layer setup and rendering configuration for Ghostty terminal\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport Metal\nimport OSLog\nimport SwiftUI\nimport CGhostty\n\n/// Manages Metal rendering setup and configuration for Ghostty terminal\n@MainActor\nclass GhosttyRenderingSetup {\n    nonisolated private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"GhosttyRendering\")\n\n    // MARK: - Terminal Settings from AppStorage\n\n    @AppStorage(\"terminal.fontName\") private var terminalFontName = \"Menlo\"\n    @AppStorage(\"terminal.fontSize\") private var terminalFontSize = 12.0\n    @AppStorage(\"terminalBackgroundColor\") private var terminalBackgroundColor = \"#1e1e2e\"\n    @AppStorage(\"terminalForegroundColor\") private var terminalForegroundColor = \"#cdd6f4\"\n    @AppStorage(\"terminalCursorColor\") private var terminalCursorColor = \"#f5e0dc\"\n    @AppStorage(\"terminalSelectionBackground\") private var terminalSelectionBackground = \"#585b70\"\n    @AppStorage(\"terminalPalette\") private var terminalPalette = \"#45475a,#f38ba8,#a6e3a1,#f9e2af,#89b4fa,#f5c2e7,#94e2d5,#a6adc8,#585b70,#f37799,#89d88b,#ebd391,#74a8fc,#f2aede,#6bd7ca,#bac2de\"\n    @AppStorage(\"terminalSessionPersistence\") private var sessionPersistence = false\n\n    // MARK: - Layer Setup\n\n    /// Configure the Metal-backed layer for terminal rendering\n    ///\n    /// CRITICAL: Must set layer property BEFORE setting wantsLayer = true\n    /// This ensures Metal rendering works correctly\n    func setupLayer(for view: NSView) {\n        // Create Metal layer\n        let metalLayer = CAMetalLayer()\n\n        // CRITICAL: Validate Metal device availability\n        guard let metalDevice = MTLCreateSystemDefaultDevice() else {\n            Self.logger.error(\"FATAL: MTLCreateSystemDefaultDevice() returned nil - no Metal-compatible GPU available\")\n            fatalError(\"Cannot create Metal device - Metal support is required for Ghostty terminal\")\n        }\n\n        metalLayer.device = metalDevice\n        metalLayer.pixelFormat = .bgra8Unorm\n        metalLayer.framebufferOnly = true\n        metalLayer.contentsScale = NSScreen.main?.backingScaleFactor ?? 2.0\n\n        // IMPORTANT: Set layer before wantsLayer for proper Metal initialization\n        view.layer = metalLayer\n        view.wantsLayer = true\n        view.layerContentsRedrawPolicy = .duringViewResize\n\n        Self.logger.debug(\"Metal layer configured successfully with device: \\(metalDevice.name)\")\n    }\n\n    // MARK: - Surface Setup\n\n    /// Create and configure the Ghostty surface\n    func setupSurface(\n        view: NSView,\n        ghosttyApp: ghostty_app_t,\n        worktreePath: String,\n        initialBounds: NSRect,\n        window: NSWindow?,\n        paneId: String? = nil,\n        command: String? = nil,\n        userdata: UnsafeMutableRawPointer? = nil\n    ) -> ghostty_surface_t? {\n        // Validate working directory exists and is accessible\n        var isDirectory: ObjCBool = false\n        if !FileManager.default.fileExists(atPath: worktreePath, isDirectory: &isDirectory) {\n            Self.logger.error(\"Working directory does not exist: \\(worktreePath)\")\n            return nil\n        }\n        if !isDirectory.boolValue {\n            Self.logger.error(\"Working directory is not a directory: \\(worktreePath)\")\n            return nil\n        }\n        if !FileManager.default.isReadableFile(atPath: worktreePath) {\n            Self.logger.error(\"Working directory is not readable: \\(worktreePath)\")\n            return nil\n        }\n\n        Self.logger.info(\"Creating Ghostty surface with working directory: \\(worktreePath)\")\n\n        // Configure surface with working directory\n        var surfaceConfig = ghostty_surface_config_new()\n\n        // CRITICAL: Set platform information\n        surfaceConfig.platform_tag = GHOSTTY_PLATFORM_MACOS\n        surfaceConfig.platform.macos.nsview = Unmanaged.passUnretained(view).toOpaque()\n\n        // Set userdata (defaults to view if none provided)\n        surfaceConfig.userdata = userdata ?? Unmanaged.passUnretained(view).toOpaque()\n\n        // Set scale factor for retina displays\n        surfaceConfig.scale_factor = Double(window?.backingScaleFactor ?? 2.0)\n\n        // Set font size from Ghostty settings\n        surfaceConfig.font_size = Float(terminalFontSize)\n\n        // Set working directory\n        var workingDirPtr: UnsafeMutablePointer<CChar>?\n\n        if let workingDir = strdup(worktreePath) {\n            workingDirPtr = workingDir\n            surfaceConfig.working_directory = UnsafePointer(workingDir)\n        }\n\n        // NOTE: We do NOT use initial_input here.\n        // initial_input is processed before shell startup, causing commands to be echoed twice:\n        // 1. Pre-shell echo (as terminal is in echo mode before shell takes over)\n        // 2. Shell execution display (after prompt)\n        // Instead, GhosttyTerminalView sends commands via sendText() after shell startup delay.\n        _ = command  // Silence unused variable warning; command is passed but handled by caller\n\n        defer {\n            if let wd = workingDirPtr {\n                free(wd)\n            }\n        }\n\n        // Create the surface\n        // NOTE: subprocess spawns during ghostty_surface_new, so size warnings may appear\n        // if view frame isn't set yet - this is unavoidable with current API\n        guard let cSurface = ghostty_surface_new(ghosttyApp, &surfaceConfig) else {\n            Self.logger.error(\"ghostty_surface_new failed\")\n            return nil\n        }\n\n        // Immediately set size after creation to minimize \"small grid\" warnings\n        let scaledSize = view.convertToBacking(initialBounds.size.width > 0 ? initialBounds.size : NSSize(width: 800, height: 600))\n        ghostty_surface_set_size(\n            cSurface,\n            UInt32(scaledSize.width),\n            UInt32(scaledSize.height)\n        )\n\n        // Set content scale for retina displays\n        let scale = window?.backingScaleFactor ?? 1.0\n        ghostty_surface_set_content_scale(cSurface, scale, scale)\n\n        Self.logger.info(\"Ghostty surface created at: \\(worktreePath)\")\n\n        return cSurface\n    }\n\n    // MARK: - Appearance Observation\n\n    /// Setup observation for system appearance changes (light/dark mode)\n    /// Implementation copied from Ghostty's SurfaceView_AppKit.swift\n    func setupAppearanceObservation(for view: NSView, surface: Ghostty.Surface?) -> NSKeyValueObservation? {\n        return view.observe(\\.effectiveAppearance, options: [.new, .initial]) { view, change in\n            guard let appearance = change.newValue else { return }\n            guard let surface = surface?.unsafeCValue else { return }\n\n            let scheme: ghostty_color_scheme_e\n            switch (appearance.name) {\n            case .aqua, .vibrantLight:\n                scheme = GHOSTTY_COLOR_SCHEME_LIGHT\n\n            case .darkAqua, .vibrantDark:\n                scheme = GHOSTTY_COLOR_SCHEME_DARK\n\n            default:\n                scheme = GHOSTTY_COLOR_SCHEME_DARK\n            }\n\n            ghostty_surface_set_color_scheme(surface, scheme)\n            Self.logger.debug(\"Color scheme updated to: \\(scheme == GHOSTTY_COLOR_SCHEME_DARK ? \"dark\" : \"light\")\")\n        }\n    }\n\n    // MARK: - Scale and Size Updates\n\n    /// Update Metal layer content scale and surface scale factors\n    func updateBackingProperties(view: NSView, surface: ghostty_surface_t?, window: NSWindow?) {\n        guard let surface = surface else { return }\n\n        // Update Metal layer content scale\n        if let window = window {\n            CATransaction.begin()\n            CATransaction.setDisableActions(true)\n            view.layer?.contentsScale = window.backingScaleFactor\n            CATransaction.commit()\n        }\n\n        // Update surface scale factors\n        let fbFrame = view.convertToBacking(view.frame)\n        let xScale = fbFrame.size.width / view.frame.size.width\n        let yScale = fbFrame.size.height / view.frame.size.height\n        ghostty_surface_set_content_scale(surface, xScale, yScale)\n\n        // Update surface size (framebuffer dimensions changed)\n        ghostty_surface_set_size(\n            surface,\n            UInt32(fbFrame.size.width),\n            UInt32(fbFrame.size.height)\n        )\n    }\n\n    /// Update Metal layer frame and Ghostty surface size\n    func updateLayout(view: NSView, metalLayer: CAMetalLayer?, surface: ghostty_surface_t?, lastSize: inout CGSize) -> Bool {\n        // Update Metal layer frame to match view bounds\n        if let metalLayer = metalLayer {\n            metalLayer.frame = view.bounds\n        }\n\n        // Update Ghostty surface size during layout pass\n        // Only update if backing pixel size actually changed to prevent flicker\n        guard let surface = surface else { return false }\n        guard view.bounds.width > 0 && view.bounds.height > 0 else { return false }\n\n        var scaledSize = view.convertToBacking(view.bounds.size)\n        scaledSize = snapSizeToCell(surface: surface, scaledSize: scaledSize)\n\n        // Only update if size changed by at least 1 pixel\n        let widthChanged = abs(scaledSize.width - lastSize.width) >= 1.0\n        let heightChanged = abs(scaledSize.height - lastSize.height) >= 1.0\n\n        guard widthChanged || heightChanged else { return false }\n\n        lastSize = scaledSize\n        if let metalLayer = metalLayer {\n            metalLayer.drawableSize = scaledSize\n        }\n        ghostty_surface_set_size(\n            surface,\n            UInt32(scaledSize.width),\n            UInt32(scaledSize.height)\n        )\n        ghostty_surface_refresh(surface)\n\n        return true\n    }\n\n    /// Snap the desired pixel size down to the nearest full terminal cell to avoid partial-cell artifacts.\n    /// Snap size to whole pixels (scaledSize is already in pixel units).\n    func snapSizeToCell(surface: ghostty_surface_t, scaledSize: CGSize) -> CGSize {\n        CGSize(width: floor(scaledSize.width), height: floor(scaledSize.height))\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyTerminalView.swift",
    "content": "//\n//  GhosttyTerminalView.swift\n//  CodMate\n//\n//  NSView subclass that integrates Ghostty terminal rendering\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport Metal\nimport OSLog\nimport SwiftUI\nimport CGhostty\n\n/// NSView that embeds a Ghostty terminal surface with Metal rendering\n///\n/// This view handles:\n/// - Metal layer setup for terminal rendering\n/// - Input forwarding (keyboard, mouse, scroll)\n/// - Focus management\n/// - Surface lifecycle management\n@MainActor\npublic class GhosttyTerminalView: NSView {\n    // MARK: - Properties\n\n    private var ghosttyApp: ghostty_app_t?\n    private weak var ghosttyAppWrapper: Ghostty.App?\n    internal var surface: Ghostty.Surface?\n    private var surfaceReference: Ghostty.SurfaceReference?\n    private var surfaceUserdata: Ghostty.SurfaceUserdata?\n    private let worktreePath: String\n    private let paneId: String?\n    private let initialCommand: String?\n\n    /// Callback invoked when the terminal process exits\n    var onProcessExit: (() -> Void)?\n\n    /// Callback invoked when the terminal title changes\n    var onTitleChange: ((String) -> Void)?\n    \n    /// Callback when the surface has produced its first layout/draw (used to hide loading UI)\n    public var onReady: (() -> Void)?\n    \n    /// Callback for OSC 9;4 progress reports\n    var onProgressReport: ((GhosttyProgressState, Int?) -> Void)?\n    private var didSignalReady = false\n\n    /// Cell size in points for row-to-pixel conversion (used by scroll view)\n    var cellSize: NSSize = .zero\n\n    /// Current scrollbar state from Ghostty core (used by scroll view)\n    var scrollbar: Ghostty.Action.Scrollbar?\n\n    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"GhosttyTerminal\")\n\n    // MARK: - Handler Components\n\n    private var imeHandler: GhosttyIMEHandler!\n    private var inputHandler: GhosttyInputHandler!\n    private let renderingSetup = GhosttyRenderingSetup()\n\n    /// Observation for appearance changes\n    private var appearanceObservation: NSKeyValueObservation?\n\n    // MARK: - Initialization\n\n    /// Create a new Ghostty terminal view\n    ///\n    /// - Parameters:\n    ///   - frame: The initial frame for the view\n    ///   - worktreePath: Working directory for the terminal session\n    ///   - ghosttyApp: The shared Ghostty app instance (C pointer)\n    ///   - appWrapper: The Ghostty.App wrapper for surface tracking (optional)\n    ///   - paneId: Unique identifier for this pane (used for tmux session persistence)\n    ///   - command: Optional command to run instead of default shell\n    public init(frame: NSRect, worktreePath: String, ghosttyApp: ghostty_app_t, appWrapper: Ghostty.App? = nil, paneId: String? = nil, command: String? = nil) {\n        NSLog(\"[GhosttyTerminalView] init called with worktreePath: %@\", worktreePath)\n        self.worktreePath = worktreePath\n        self.ghosttyApp = ghosttyApp\n        self.ghosttyAppWrapper = appWrapper\n        self.paneId = paneId\n        self.initialCommand = command\n\n        // Use a reasonable default size if frame is zero\n        let initialFrame = frame.width > 0 && frame.height > 0 ? frame : NSRect(x: 0, y: 0, width: 800, height: 600)\n        NSLog(\"[GhosttyTerminalView] super.init with frame: %@\", NSStringFromRect(initialFrame))\n        super.init(frame: initialFrame)\n\n        registerForDraggedTypes([.fileURL, .tiff, .png])\n\n        // Initialize handlers before setup\n        NSLog(\"[GhosttyTerminalView] creating IME handler\")\n        self.imeHandler = GhosttyIMEHandler(view: self, surface: nil)\n        NSLog(\"[GhosttyTerminalView] creating input handler\")\n        self.inputHandler = GhosttyInputHandler(view: self, surface: nil, imeHandler: self.imeHandler)\n\n        NSLog(\"[GhosttyTerminalView] setupLayer\")\n        setupLayer()\n        NSLog(\"[GhosttyTerminalView] setupSurface\")\n        setupSurface()\n        NSLog(\"[GhosttyTerminalView] setupTrackingArea\")\n        setupTrackingArea()\n        NSLog(\"[GhosttyTerminalView] setupAppearanceObservation\")\n        setupAppearanceObservation()\n        NSLog(\"[GhosttyTerminalView] setupFrameObservation\")\n        setupFrameObservation()\n\n        // Send initial command after shell startup delay\n        // This avoids the double-echo issue caused by initial_input\n        if let command = initialCommand, !command.isEmpty {\n            scheduleInitialCommand(command)\n        }\n\n        NSLog(\"[GhosttyTerminalView] init complete\")\n    }\n\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) not supported\")\n    }\n\n    deinit {\n        NSLog(\"[GhosttyTerminalView] deinit called - view being deallocated\")\n        // Surface cleanup happens via Surface's deinit\n        // Note: Cannot access @MainActor properties in deinit\n        // Tracking areas are automatically cleaned up by NSView\n        // Appearance observation is automatically invalidated\n        NotificationCenter.default.removeObserver(self)\n\n        // Surface reference cleanup needs to happen on main actor\n        // We capture the values before the Task to avoid capturing self\n        let wrapper = self.ghosttyAppWrapper\n        let ref = self.surfaceReference\n        if let wrapper = wrapper, let ref = ref {\n            Task { @MainActor in\n                NSLog(\"[GhosttyTerminalView] deinit: unregistering surface\")\n                wrapper.unregisterSurface(ref)\n            }\n        }\n    }\n\n    // MARK: - Setup\n\n    /// Configure the Metal-backed layer for terminal rendering\n    private func setupLayer() {\n        renderingSetup.setupLayer(for: self)\n    }\n\n    /// Create and configure the Ghostty surface\n    private func setupSurface() {\n        guard let app = ghosttyApp else {\n            Self.logger.error(\"Cannot create surface: ghostty_app_t is nil\")\n            return\n        }\n\n        let surfaceUserdata = Ghostty.SurfaceUserdata(view: self)\n        let surfaceUserdataPointer = Unmanaged.passRetained(surfaceUserdata).toOpaque()\n\n        guard let cSurface = renderingSetup.setupSurface(\n            view: self,\n            ghosttyApp: app,\n            worktreePath: worktreePath,\n            initialBounds: bounds,\n            window: window,\n            paneId: paneId,\n            command: initialCommand,\n            userdata: surfaceUserdataPointer\n        ) else {\n            Unmanaged<Ghostty.SurfaceUserdata>.fromOpaque(surfaceUserdataPointer).release()\n            return\n        }\n\n        // Wrap in Swift Surface class\n        self.surface = Ghostty.Surface(cSurface: cSurface, userdataToRelease: surfaceUserdataPointer)\n        self.surfaceUserdata = surfaceUserdata\n\n        // Update handlers with surface\n        imeHandler.updateSurface(self.surface)\n        inputHandler.updateSurface(self.surface)\n\n        // Register surface with app wrapper for config update tracking\n        if let wrapper = ghosttyAppWrapper {\n            self.surfaceReference = wrapper.registerSurface(cSurface)\n        }\n    }\n\n    /// Setup mouse tracking area for the entire view\n    private func setupTrackingArea() {\n        let options: NSTrackingArea.Options = [\n            .mouseEnteredAndExited,\n            .mouseMoved,\n            .inVisibleRect,\n            .activeAlways  // Track even when not focused\n        ]\n\n        let trackingArea = NSTrackingArea(\n            rect: bounds,\n            options: options,\n            owner: self,\n            userInfo: nil\n        )\n        addTrackingArea(trackingArea)\n    }\n\n    /// Setup observation for system appearance changes (light/dark mode)\n    private func setupAppearanceObservation() {\n        appearanceObservation = renderingSetup.setupAppearanceObservation(for: self, surface: surface)\n    }\n\n    private func setupFrameObservation() {\n        // We rely on layout() + updateLayout to resize the surface.\n        self.postsFrameChangedNotifications = false\n        \n        // Listen for config reload notifications to trigger reflow on font size changes\n        NotificationCenter.default.addObserver(\n            self,\n            selector: #selector(handleConfigReload),\n            name: .ghosttyConfigDidReload,\n            object: nil\n        )\n    }\n    \n    @objc private func handleConfigReload() {\n        // Force layout update when font size or cursor style changes\n        // This ensures the surface size is recalculated with new settings\n        lastSurfaceSize = .zero\n        needsLayout = true\n        layout()\n        \n        // Also force a refresh to ensure the surface is redrawn\n        forceRefresh()\n    }\n\n    // MARK: - Initial Command\n\n    /// Flag to track if initial command has been sent (prevents duplicate sends)\n    private var initialCommandSent = false\n\n    /// Schedule the initial command to be sent after shell startup\n    /// Uses a delay to ensure shell has time to initialize and display its prompt\n    private func scheduleInitialCommand(_ command: String) {\n        // Use a delay to wait for shell startup\n        // 300ms is typically enough for shell to initialize and display prompt\n        Task { @MainActor [weak self] in\n            try? await Task.sleep(nanoseconds: 300_000_000)  // 300ms\n            self?.sendInitialCommandIfNeeded(command)\n        }\n    }\n\n    /// Send the initial command if not already sent\n    private func sendInitialCommandIfNeeded(_ command: String) {\n        guard !initialCommandSent else {\n            NSLog(\"[GhosttyTerminalView] initial command already sent, skipping\")\n            return\n        }\n        guard let surface = surface else {\n            NSLog(\"[GhosttyTerminalView] surface is nil, cannot send initial command\")\n            return\n        }\n\n        initialCommandSent = true\n\n        // Normalize command: ensure it ends with exactly one newline\n        var normalizedCommand = command\n        while normalizedCommand.hasSuffix(\"\\n\") || normalizedCommand.hasSuffix(\"\\r\") {\n            normalizedCommand.removeLast()\n        }\n\n        guard !normalizedCommand.isEmpty else {\n            NSLog(\"[GhosttyTerminalView] normalized command is empty, skipping\")\n            return\n        }\n\n        NSLog(\"[GhosttyTerminalView] sending initial command: %@\", normalizedCommand)\n        surface.sendText(normalizedCommand + \"\\n\")\n    }\n\n    // MARK: - NSView Overrides\n\n    public override var acceptsFirstResponder: Bool {\n        return true\n    }\n\n    public override func becomeFirstResponder() -> Bool {\n        let result = super.becomeFirstResponder()\n        if result, let surface = surface?.unsafeCValue {\n            ghostty_surface_set_focus(surface, true)\n        }\n        return result\n    }\n\n    public override func resignFirstResponder() -> Bool {\n        let result = super.resignFirstResponder()\n        if result, let surface = surface?.unsafeCValue {\n            ghostty_surface_set_focus(surface, false)\n        }\n        return result\n    }\n\n    public override func updateTrackingAreas() {\n        super.updateTrackingAreas()\n\n        // Remove old tracking areas\n        trackingAreas.forEach { removeTrackingArea($0) }\n\n        // Recreate with current bounds\n        setupTrackingArea()\n    }\n\n    public override func viewDidChangeBackingProperties() {\n        super.viewDidChangeBackingProperties()\n        renderingSetup.updateBackingProperties(view: self, surface: surface?.unsafeCValue, window: window)\n    }\n\n    public override func viewDidMoveToWindow() {\n        super.viewDidMoveToWindow()\n        // Single refresh when view moves to window\n        if window != nil {\n            DispatchQueue.main.async { [weak self] in\n                self?.forceRefresh()\n            }\n        }\n    }\n\n    // Track last size sent to Ghostty to avoid redundant updates\n    private var lastSurfaceSize: CGSize = .zero\n\n    // Override safe area insets to use full available space, including rounded corners\n    // This matches Ghostty's SurfaceScrollView implementation\n    public override var safeAreaInsets: NSEdgeInsets {\n        return NSEdgeInsetsZero\n    }\n\n    public override func setFrameSize(_ newSize: NSSize) {\n        super.setFrameSize(newSize)\n\n        // Force layout to be called to fix up subviews\n        // This matches Ghostty's SurfaceScrollView.setFrameSize\n        needsLayout = true\n    }\n\n    public override func layout() {\n        super.layout()\n        let didUpdate = renderingSetup.updateLayout(\n            view: self,\n            metalLayer: layer as? CAMetalLayer,\n            surface: surface?.unsafeCValue,\n            lastSize: &lastSurfaceSize\n        )\n        if didUpdate {\n            NSLog(\"[GhosttyTerminalView] layout: size updated to %@\", NSStringFromSize(bounds.size))\n            if !didSignalReady {\n                didSignalReady = true\n                NSLog(\"[GhosttyTerminalView] layout: signaling onReady\")\n                onReady?()\n            }\n        }\n    }\n\n    // MARK: - Keyboard Input\n\n    public override func keyDown(with event: NSEvent) {\n        inputHandler.handleKeyDown(with: event) { [weak self] events in\n            self?.interpretKeyEvents(events)\n        }\n    }\n\n    public override func keyUp(with event: NSEvent) {\n        inputHandler.handleKeyUp(with: event)\n    }\n\n    public override func flagsChanged(with event: NSEvent) {\n        inputHandler.handleFlagsChanged(with: event)\n    }\n\n    public override func doCommand(by selector: Selector) {\n        // Override to suppress NSBeep when interpretKeyEvents encounters unhandled commands\n        // Without this, keys like delete at beginning of line, cmd+c with no selection, etc. cause beeps\n        // Terminal handles all input via Ghostty, so we silently ignore unhandled commands\n    }\n\n    @objc func paste(_ sender: Any?) {\n        if handlePasteboardAttachments(NSPasteboard.general, appendTrailingSpace: false) {\n            return\n        }\n        if let surface = surface, surface.perform(action: \"paste\") {\n            return\n        }\n        if let text = NSPasteboard.general.string(forType: .string),\n           !text.isEmpty {\n            surface?.sendText(text)\n        }\n    }\n\n    public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {\n        let pasteboard = sender.draggingPasteboard\n        if canHandlePasteboard(pasteboard) {\n            return .copy\n        }\n        return []\n    }\n\n    public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {\n        return handlePasteboardAttachments(sender.draggingPasteboard, appendTrailingSpace: true)\n    }\n\n    // MARK: - Mouse Input\n\n    public override func mouseDown(with event: NSEvent) {\n        inputHandler.handleMouseDown(with: event)\n    }\n\n    public override func mouseUp(with event: NSEvent) {\n        inputHandler.handleMouseUp(with: event)\n    }\n\n    public override func rightMouseDown(with event: NSEvent) {\n        inputHandler.handleRightMouseDown(with: event)\n    }\n\n    public override func rightMouseUp(with event: NSEvent) {\n        inputHandler.handleRightMouseUp(with: event)\n    }\n\n    public override func otherMouseDown(with event: NSEvent) {\n        inputHandler.handleOtherMouseDown(with: event)\n    }\n\n    public override func otherMouseUp(with event: NSEvent) {\n        inputHandler.handleOtherMouseUp(with: event)\n    }\n\n    public override func mouseMoved(with event: NSEvent) {\n        inputHandler.handleMouseMoved(with: event, viewFrame: frame) { [weak self] point, view in\n            self?.convert(point, from: view) ?? .zero\n        }\n    }\n\n    public override func mouseDragged(with event: NSEvent) {\n        mouseMoved(with: event)\n    }\n\n    public override func rightMouseDragged(with event: NSEvent) {\n        mouseMoved(with: event)\n    }\n\n    public override func otherMouseDragged(with event: NSEvent) {\n        mouseMoved(with: event)\n    }\n\n    public override func mouseEntered(with event: NSEvent) {\n        super.mouseEntered(with: event)\n        inputHandler.handleMouseEntered(with: event, viewFrame: frame) { [weak self] point, view in\n            self?.convert(point, from: view) ?? .zero\n        }\n    }\n\n    public override func mouseExited(with event: NSEvent) {\n        inputHandler.handleMouseExited(with: event)\n    }\n\n    public override func scrollWheel(with event: NSEvent) {\n        inputHandler.handleScrollWheel(with: event)\n    }\n\n    // MARK: - Process Lifecycle\n\n    /// Check if the terminal process has exited\n    var processExited: Bool {\n        guard let surface = surface?.unsafeCValue else { return true }\n        return ghostty_surface_process_exited(surface)\n    }\n\n    /// Check if closing this terminal needs confirmation\n    public var needsConfirmQuit: Bool {\n        guard let surface = surface else { return false }\n        return surface.needsConfirmQuit\n    }\n\n    /// Get current terminal grid size\n    func terminalSize() -> Ghostty.Surface.TerminalSize? {\n        guard let surface = surface else { return nil }\n        return surface.terminalSize()\n    }\n\n    /// Send text to the terminal as if typed (used for initial command injection).\n    @MainActor\n    public func sendText(_ text: String) {\n        surface?.sendText(text)\n    }\n\n    /// Force the terminal surface to refresh/redraw\n    /// Useful after tmux reattaches or when view becomes visible\n    func forceRefresh() {\n        guard let surface = surface?.unsafeCValue else { return }\n\n        // Force a size update to trigger tmux redraw\n        let scaledSize = convertToBacking(bounds.size)\n        ghostty_surface_set_size(\n            surface,\n            UInt32(scaledSize.width),\n            UInt32(scaledSize.height)\n        )\n\n        ghostty_surface_refresh(surface)\n        ghostty_surface_draw(surface)\n\n        // Trigger app tick to process any pending updates\n        ghosttyAppWrapper?.appTick()\n\n        // Force Metal layer to redraw\n        if let metalLayer = layer as? CAMetalLayer {\n            metalLayer.setNeedsDisplay()\n        }\n        layer?.setNeedsDisplay()\n        needsDisplay = true\n        needsLayout = true\n        displayIfNeeded()\n    }\n\n    // MARK: - Paste / Drop Helpers\n\n    @MainActor\n    func handlePasteCommandIfNeeded(_ event: NSEvent) -> Bool {\n        guard event.modifierFlags.contains(.command),\n              event.charactersIgnoringModifiers?.lowercased() == \"v\" else {\n            return false\n        }\n        paste(nil)\n        return true\n    }\n\n    @MainActor\n    func handlePasteboardAttachments(_ pasteboard: NSPasteboard, appendTrailingSpace: Bool) -> Bool {\n        if let urls = extractFileURLs(from: pasteboard), !urls.isEmpty {\n            pasteFileURLs(urls, appendTrailingSpace: appendTrailingSpace)\n            return true\n        }\n        if let image = NSImage(pasteboard: pasteboard),\n           let url = writeImageToTemp(image) {\n            pasteFileURLs([url], appendTrailingSpace: appendTrailingSpace)\n            return true\n        }\n        return false\n    }\n\n    @MainActor\n    func canHandlePasteboard(_ pasteboard: NSPasteboard) -> Bool {\n        if let urls = extractFileURLs(from: pasteboard), !urls.isEmpty {\n            return true\n        }\n        if NSImage(pasteboard: pasteboard) != nil {\n            return true\n        }\n        return false\n    }\n\n    private func extractFileURLs(from pasteboard: NSPasteboard) -> [URL]? {\n        guard let objects = pasteboard.readObjects(forClasses: [NSURL.self], options: [\n            .urlReadingFileURLsOnly: true\n        ]) as? [URL] else {\n            return nil\n        }\n        return objects.filter { $0.isFileURL }\n    }\n\n    @MainActor\n    private func pasteFileURLs(_ urls: [URL], appendTrailingSpace: Bool) {\n        let escaped = urls.map { shellEscapeForPaste($0.path) }\n        guard !escaped.isEmpty else { return }\n        var text = escaped.joined(separator: \" \")\n        if appendTrailingSpace {\n            text += \" \"\n        }\n        surface?.sendText(text)\n    }\n\n    private func shellEscapeForPaste(_ path: String) -> String {\n        let escaped = path.replacingOccurrences(of: \"'\", with: \"'\\\\''\")\n        return \"'\" + escaped + \"'\"\n    }\n\n    private func writeImageToTemp(_ image: NSImage) -> URL? {\n        guard let data = image.pngData() else { return nil }\n        let dir = URL(fileURLWithPath: NSTemporaryDirectory())\n            .appendingPathComponent(\"codmate-ghostty\", isDirectory: true)\n        do {\n            try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)\n        } catch {\n            return nil\n        }\n        let name = \"paste-\\(Int(Date().timeIntervalSince1970))-\\(UUID().uuidString.prefix(6)).png\"\n        let url = dir.appendingPathComponent(name)\n        do {\n            try data.write(to: url, options: .atomic)\n            return url\n        } catch {\n            return nil\n        }\n    }\n}\n\nprivate extension NSImage {\n    func pngData() -> Data? {\n        guard let tiff = tiffRepresentation,\n              let rep = NSBitmapImageRep(data: tiff) else { return nil }\n        return rep.representation(using: .png, properties: [:])\n    }\n}\n\n// MARK: - NSTextInputClient Implementation\n\n/// NSTextInputClient protocol conformance for IME (Input Method Editor) support\n/// Use @preconcurrency to suppress Swift 6 actor isolation warnings since NSTextInputClient\n/// is an Objective-C protocol that predates Swift concurrency\nextension GhosttyTerminalView: @preconcurrency NSTextInputClient {\n    public func insertText(_ string: Any, replacementRange: NSRange) {\n        imeHandler.insertText(string, replacementRange: replacementRange)\n    }\n\n    public func setMarkedText(_ string: Any, selectedRange: NSRange, replacementRange: NSRange) {\n        imeHandler.setMarkedText(string, selectedRange: selectedRange, replacementRange: replacementRange)\n    }\n\n    public func unmarkText() {\n        imeHandler.unmarkText()\n    }\n\n    public func selectedRange() -> NSRange {\n        return imeHandler.selectedRange()\n    }\n\n    public func markedRange() -> NSRange {\n        return imeHandler.markedRange()\n    }\n\n    public func hasMarkedText() -> Bool {\n        return imeHandler.hasMarkedText\n    }\n\n    public func attributedSubstring(forProposedRange range: NSRange, actualRange: NSRangePointer?) -> NSAttributedString? {\n        return imeHandler.attributedSubstring(forProposedRange: range, actualRange: actualRange)\n    }\n\n    public func validAttributesForMarkedText() -> [NSAttributedString.Key] {\n        return imeHandler.validAttributesForMarkedText()\n    }\n\n    public func firstRect(forCharacterRange range: NSRange, actualRange: NSRangePointer?) -> NSRect {\n        return imeHandler.firstRect(\n            forCharacterRange: range,\n            actualRange: actualRange,\n            viewFrame: frame,\n            window: window,\n            surface: surface?.unsafeCValue\n        )\n    }\n\n    public func characterIndex(for point: NSPoint) -> Int {\n        return imeHandler.characterIndex(for: point)\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/GhosttyThemeLoader.swift",
    "content": "import Foundation\nimport os.log\n\n/// Utility for loading and managing Ghostty terminal themes\n@MainActor\npublic struct GhosttyThemeLoader {\n    private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier ?? \"ai.umate.codmate\", category: \"GhosttyThemeLoader\")\n    \n    /// Curated list of popular/well-known themes to show in the picker\n    /// Users can still use any theme by typing its name, but this list provides quick access\n    public static let curatedThemeNames: [String] = [\n        // Default/System themes\n        \"Xcode Dark\",\n        \"Xcode Light\",\n        \"Dark\",\n        \"Light\",\n        \n        // Popular color schemes\n        \"Atom One Dark\",\n        \"Atom One Light\",\n        \"Nord\",\n        \"Nord Light\",\n        \"Dracula\",\n        \"Monokai Pro\",\n        \"Monokai Pro Light\",\n        \"Solarized Dark Higher Contrast\",\n        \"Solarized Light\",\n        \"Gruvbox Dark\",\n        \"Gruvbox Light\",\n        \"One Dark\",\n        \"One Light\",\n    ]\n    \n    /// Load all available theme names from the Package resources\n    public static func loadAvailableThemes() -> [String] {\n        guard let themesURL = Bundle.module.url(forResource: \"themes\", withExtension: nil, subdirectory: nil) else {\n            logger.warning(\"Ghostty themes resource not found\")\n            return curatedThemeNames\n        }\n        \n        let themesPath = themesURL.path\n        let fm = FileManager.default\n        \n        guard let themeFiles = try? fm.contentsOfDirectory(atPath: themesPath) else {\n            logger.warning(\"Unable to read themes from \\(themesPath)\")\n            return curatedThemeNames\n        }\n        \n        // Filter out directories and hidden files, sort alphabetically\n        let availableThemes = themeFiles.filter { file in\n            let path = (themesPath as NSString).appendingPathComponent(file)\n            var isDir: ObjCBool = false\n            guard fm.fileExists(atPath: path, isDirectory: &isDir) else { return false }\n            return !isDir.boolValue && !file.hasPrefix(\".\")\n        }.sorted()\n        \n        // Return curated themes that exist, plus any additional themes\n        // Prioritize curated themes at the top\n        var result: [String] = []\n        var seen = Set<String>()\n        \n        // Add curated themes first (if they exist)\n        for theme in curatedThemeNames {\n            if availableThemes.contains(theme) {\n                result.append(theme)\n                seen.insert(theme)\n            }\n        }\n        \n        // Add a separator if we have both curated and other themes\n        if !result.isEmpty && result.count < availableThemes.count {\n            // Add other popular themes that aren't in curated list\n            let additionalPopular = availableThemes.filter { theme in\n                !seen.contains(theme) && (\n                    theme.contains(\"Dark\") || theme.contains(\"Light\") ||\n                    theme.contains(\"Nord\") || theme.contains(\"Dracula\") ||\n                    theme.contains(\"Monokai\") || theme.contains(\"Solarized\") ||\n                    theme.contains(\"Gruvbox\") || theme.contains(\"Atom\")\n                )\n            }\n            if !additionalPopular.isEmpty {\n                result.append(contentsOf: additionalPopular.sorted())\n                additionalPopular.forEach { seen.insert($0) }\n            }\n        }\n        \n        logger.info(\"Loaded \\(result.count) themes (curated: \\(curatedThemeNames.count), total available: \\(availableThemes.count))\")\n        return result\n    }\n    \n    /// Check if a theme exists\n    public static func themeExists(_ themeName: String) -> Bool {\n        guard let themesURL = Bundle.module.url(forResource: \"themes\", withExtension: nil, subdirectory: nil) else {\n            return false\n        }\n        \n        let themePath = (themesURL.path as NSString).appendingPathComponent(themeName)\n        return FileManager.default.fileExists(atPath: themePath)\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/TerminalScrollView.swift",
    "content": "//\n//  TerminalScrollView.swift\n//  CodMate\n//\n//  NSScrollView wrapper for terminal with native macOS scrollbar support.\n//  Adapted from Ghostty's SurfaceScrollView.swift\n//\n//  This file is adapted from Aizen (https://github.com/vivy-company/aizen)\n//  which provided the initial Ghostty embedding implementation.\n//\n\nimport AppKit\nimport Combine\nimport CGhostty\n\n/// Wraps a Ghostty terminal view in an NSScrollView to provide native macOS scrollbar support.\n///\n/// ## Coordinate System\n/// AppKit uses a +Y-up coordinate system (origin at bottom-left), while terminals conceptually\n/// use +Y-down (row 0 at top). This class handles the inversion when converting between row\n/// offsets and pixel positions.\n///\n/// ## Architecture\n/// - `scrollView`: The outermost NSScrollView that manages scrollbar rendering and behavior\n/// - `documentView`: A blank NSView whose height represents total scrollback (in pixels)\n/// - `surfaceView`: The actual Ghostty terminal renderer, positioned to fill the visible rect\npublic class TerminalScrollView: NSView {\n    private let scrollView: NSScrollView\n    private let documentView: NSView\n    public let surfaceView: GhosttyTerminalView\n    private var observers: [NSObjectProtocol] = []\n    private var isLiveScrolling = false\n\n    /// The last row position sent via scroll_to_row action. Used to avoid\n    /// sending redundant actions when the user drags the scrollbar but stays\n    /// on the same row.\n    private var lastSentRow: Int?\n\n    public init(contentSize: CGSize, surfaceView: GhosttyTerminalView) {\n        self.surfaceView = surfaceView\n\n        // The scroll view is our outermost view that controls all our scrollbar\n        // rendering and behavior.\n        scrollView = NSScrollView()\n        scrollView.hasVerticalScroller = true\n        scrollView.hasHorizontalScroller = false\n        scrollView.autohidesScrollers = false\n        scrollView.usesPredominantAxisScrolling = true\n        // Always use the overlay style. See mouseMoved for how we make\n        // it usable without a scroll wheel or gestures.\n        scrollView.scrollerStyle = .overlay\n        // hide default background to show blur effect properly\n        scrollView.drawsBackground = false\n        // don't let the content view clip its subviews\n        scrollView.contentView.clipsToBounds = false\n\n        // The document view is what the scrollview is actually going\n        // to be directly scrolling. We set it up to a \"blank\" NSView\n        // with the desired content size.\n        documentView = NSView(frame: NSRect(origin: .zero, size: contentSize))\n        scrollView.documentView = documentView\n\n        // The document view contains our actual surface as a child.\n        documentView.addSubview(surfaceView)\n\n        super.init(frame: .zero)\n\n        registerForDraggedTypes([.fileURL, .tiff, .png])\n\n        // Our scroll view is our only view\n        addSubview(scrollView)\n\n        // Apply initial scrollbar settings\n        synchronizeAppearance()\n\n        // We listen for scroll events through bounds notifications on our NSClipView.\n        scrollView.contentView.postsBoundsChangedNotifications = true\n        observers.append(NotificationCenter.default.addObserver(\n            forName: NSView.boundsDidChangeNotification,\n            object: scrollView.contentView,\n            queue: .main\n        ) { [weak self] notification in\n            self?.handleScrollChange(notification)\n        })\n\n        // Listen for scrollbar updates from Ghostty\n        observers.append(NotificationCenter.default.addObserver(\n            forName: .ghosttyDidUpdateScrollbar,\n            object: surfaceView,\n            queue: .main\n        ) { [weak self] notification in\n            self?.handleScrollbarUpdate(notification)\n        })\n\n        // Listen for live scroll events\n        observers.append(NotificationCenter.default.addObserver(\n            forName: NSScrollView.willStartLiveScrollNotification,\n            object: scrollView,\n            queue: .main\n        ) { [weak self] _ in\n            self?.isLiveScrolling = true\n        })\n\n        observers.append(NotificationCenter.default.addObserver(\n            forName: NSScrollView.didEndLiveScrollNotification,\n            object: scrollView,\n            queue: .main\n        ) { [weak self] _ in\n            self?.isLiveScrolling = false\n        })\n\n        observers.append(NotificationCenter.default.addObserver(\n            forName: NSScrollView.didLiveScrollNotification,\n            object: scrollView,\n            queue: .main\n        ) { [weak self] _ in\n            self?.handleLiveScroll()\n        })\n\n        observers.append(NotificationCenter.default.addObserver(\n            forName: NSScroller.preferredScrollerStyleDidChangeNotification,\n            object: nil,\n            queue: nil\n        ) { [weak self] _ in\n            self?.handleScrollerStyleChange()\n        })\n    }\n\n    required init?(coder: NSCoder) {\n        fatalError(\"init(coder:) not implemented\")\n    }\n\n    deinit {\n        observers.forEach { NotificationCenter.default.removeObserver($0) }\n    }\n\n    // The entire bounds is a safe area, so we override any default insets.\n    public override var safeAreaInsets: NSEdgeInsets { return NSEdgeInsetsZero }\n\n    public override func layout() {\n        super.layout()\n\n        // Fill entire bounds with scroll view\n        scrollView.frame = bounds\n        surfaceView.frame.size = scrollView.bounds.size\n\n        // We only set the width of the documentView here, as the height depends\n        // on the scrollbar state and is updated in synchronizeScrollView\n        documentView.frame.size.width = scrollView.bounds.width\n\n        // When our scrollview changes make sure our scroller and surface views are synchronized\n        synchronizeScrollView()\n        synchronizeSurfaceView()\n        synchronizeCoreSurface()\n    }\n\n    // MARK: - Scrolling\n\n    private func synchronizeAppearance() {\n        // Update scroller appearance based on terminal background\n        let hasLightBackground = scrollView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua\n        scrollView.appearance = NSAppearance(named: hasLightBackground ? .aqua : .darkAqua)\n        updateTrackingAreas()\n    }\n\n    /// Positions the surface view to fill the currently visible rectangle.\n    private func synchronizeSurfaceView() {\n        let visibleRect = scrollView.contentView.documentVisibleRect\n        surfaceView.frame.origin = visibleRect.origin\n    }\n\n    /// Inform the actual pty of our size change.\n    private func synchronizeCoreSurface() {\n        let width = scrollView.contentSize.width\n        let height = surfaceView.frame.height\n        if width > 0 && height > 0 {\n            surfaceView.sizeDidChange(CGSize(width: width, height: height))\n        }\n    }\n\n    /// Sizes the document view and scrolls the content view according to the scrollbar state\n    private func synchronizeScrollView() {\n        // Update the document height to give our scroller the correct proportions\n        documentView.frame.size.height = documentHeight()\n\n        // Only update our actual scroll position if we're not actively scrolling.\n        if !isLiveScrolling {\n            // Convert row units to pixels using cell height, ignore zero height.\n            let cellHeight = surfaceView.cellSize.height\n            if cellHeight > 0, let scrollbar = surfaceView.scrollbar {\n                // Invert coordinate system: terminal offset is from top, AppKit position from bottom\n                let offsetY =\n                    CGFloat(scrollbar.total - scrollbar.offset - scrollbar.len) * cellHeight\n                scrollView.contentView.scroll(to: CGPoint(x: 0, y: offsetY))\n\n                // Track the current row position to avoid redundant movements when we\n                // move the scrollbar.\n                lastSentRow = Int(scrollbar.offset)\n            }\n        }\n\n        // Always update our scrolled view with the latest dimensions\n        scrollView.reflectScrolledClipView(scrollView.contentView)\n    }\n\n    // MARK: - Notifications\n\n    /// Handles bounds changes in the scroll view's clip view, keeping the surface view synchronized.\n    private func handleScrollChange(_ notification: Notification) {\n        synchronizeSurfaceView()\n    }\n\n    /// Handles scrollbar style changes\n    private func handleScrollerStyleChange() {\n        scrollView.scrollerStyle = .overlay\n        synchronizeCoreSurface()\n    }\n\n    /// Handles live scroll events (user actively dragging the scrollbar).\n    private func handleLiveScroll() {\n        // If our cell height is currently zero then we avoid a div by zero below\n        let cellHeight = surfaceView.cellSize.height\n        guard cellHeight > 0 else { return }\n\n        // AppKit views are +Y going up, so we calculate from the bottom\n        let visibleRect = scrollView.contentView.documentVisibleRect\n        let documentHeight = documentView.frame.height\n        let scrollOffset = documentHeight - visibleRect.origin.y - visibleRect.height\n        let row = Int(scrollOffset / cellHeight)\n\n        // Only send action if the row changed to avoid action spam\n        guard row != lastSentRow else { return }\n        lastSentRow = row\n\n        // Use the keybinding action to scroll.\n        _ = surfaceView.surface?.perform(action: \"scroll_to_row:\\(row)\")\n    }\n\n    /// Handles scrollbar state updates from the terminal core.\n    private func handleScrollbarUpdate(_ notification: Notification) {\n        guard let scrollbar = notification.userInfo?[Notification.Name.ScrollbarKey] as? Ghostty.Action.Scrollbar else {\n            return\n        }\n        surfaceView.scrollbar = scrollbar\n        synchronizeScrollView()\n    }\n\n    // MARK: - Calculations\n\n    /// Calculate the appropriate document view height given a scrollbar state\n    private func documentHeight() -> CGFloat {\n        let contentHeight = scrollView.contentSize.height\n        let cellHeight = surfaceView.cellSize.height\n        if cellHeight > 0, let scrollbar = surfaceView.scrollbar {\n            // The document view must have the same vertical padding around the\n            // scrollback grid as the content view has around the terminal grid\n            let documentGridHeight = CGFloat(scrollbar.total) * cellHeight\n            let padding = contentHeight - (CGFloat(scrollbar.len) * cellHeight)\n            return documentGridHeight + padding\n        }\n        return contentHeight\n    }\n\n    // MARK: - Mouse events\n\n    public override func mouseMoved(with: NSEvent) {\n        // When the OS preferred style is .legacy, the user should be able to\n        // click and drag the scroller without using scroll wheels or gestures,\n        // so we flash it when the mouse is moved over the scrollbar area.\n        guard NSScroller.preferredScrollerStyle == .legacy else { return }\n        scrollView.flashScrollers()\n    }\n\n    public override func updateTrackingAreas() {\n        // To update our tracking area we just recreate it all.\n        trackingAreas.forEach { removeTrackingArea($0) }\n\n        super.updateTrackingAreas()\n\n        // Our tracking area is the scroller frame\n        guard let scroller = scrollView.verticalScroller else { return }\n        addTrackingArea(NSTrackingArea(\n            rect: convert(scroller.bounds, from: scroller),\n            options: [\n                .mouseMoved,\n                .activeInKeyWindow,\n            ],\n            owner: self,\n            userInfo: nil))\n    }\n\n    public override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {\n        let pasteboard = sender.draggingPasteboard\n        if surfaceView.canHandlePasteboard(pasteboard) {\n            return .copy\n        }\n        return []\n    }\n\n    public override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {\n        return surfaceView.handlePasteboardAttachments(sender.draggingPasteboard, appendTrailingSpace: true)\n    }\n}\n\n// MARK: - GhosttyTerminalView Extension\n\nextension GhosttyTerminalView {\n    /// Notify the terminal of a size change (used by scroll view wrapper)\n    func sizeDidChange(_ size: CGSize) {\n        guard let surface = surface?.unsafeCValue else { return }\n        let scaledSize = convertToBacking(size)\n        ghostty_surface_set_size(\n            surface,\n            UInt32(scaledSize.width),\n            UInt32(scaledSize.height)\n        )\n    }\n}\n"
  },
  {
    "path": "ghostty/Sources/GhosttyKit/TerminalTextCleaner.swift",
    "content": "//\n//  TerminalTextCleaner.swift\n//  GhosttyKit\n//\n\nimport Foundation\n\nstruct TerminalCopySettings {\n    var trimTrailingWhitespace: Bool = true\n    var collapseBlankLines: Bool = false\n    var stripShellPrompts: Bool = false\n    var flattenCommands: Bool = false\n    var removeBoxDrawing: Bool = false\n    var stripAnsiCodes: Bool = true\n}\n\nnonisolated struct TerminalTextCleaner {\n    private static let boxDrawingCharacterClass = \"[│┃╎╏┆┇┊┋╽╿￨｜]\"\n    private static let knownCommandPrefixes: [String] = [\n        \"sudo\", \"./\", \"~/\", \"apt\", \"brew\", \"git\", \"python\", \"pip\", \"pnpm\", \"npm\", \"yarn\", \"cargo\",\n        \"bundle\", \"rails\", \"go\", \"make\", \"xcodebuild\", \"swift\", \"kubectl\", \"docker\", \"podman\", \"aws\",\n        \"gcloud\", \"az\", \"ls\", \"cd\", \"cat\", \"echo\", \"env\", \"export\", \"open\", \"node\", \"java\", \"ruby\",\n        \"perl\", \"bash\", \"zsh\", \"fish\", \"pwsh\", \"sh\",\n    ]\n\n    static func cleanText(_ text: String, settings: TerminalCopySettings) -> String {\n        var result = text\n\n        if settings.stripAnsiCodes {\n            result = stripAnsiCodes(result)\n        }\n\n        if settings.removeBoxDrawing {\n            if let cleaned = stripBoxDrawingCharacters(in: result) {\n                result = cleaned\n            }\n        }\n\n        if settings.stripShellPrompts {\n            if let stripped = stripPromptPrefixes(result) {\n                result = stripped\n            }\n        }\n\n        if settings.flattenCommands {\n            if let flattened = flattenMultilineCommand(result) {\n                result = flattened\n            }\n        }\n\n        if settings.trimTrailingWhitespace {\n            result = trimTrailingWhitespace(result)\n        }\n\n        if settings.collapseBlankLines {\n            result = collapseBlankLines(result)\n        }\n\n        return result\n    }\n\n    // MARK: - ANSI Codes\n\n    static func stripAnsiCodes(_ text: String) -> String {\n        // Match ANSI escape sequences: ESC[ followed by params and command\n        // Covers: colors, cursor movement, clearing, etc.\n        let patterns = [\n            #\"\\x1b\\[[0-9;]*[A-Za-z]\"#,  // CSI sequences (colors, cursor, etc.)\n            #\"\\x1b\\][^\\x07]*\\x07\"#,      // OSC sequences (title, etc.)\n            #\"\\x1b\\][^\\x1b]*\\x1b\\\\\"#,    // OSC with ST terminator\n            #\"\\x1b[PX^_][^\\x1b]*\\x1b\\\\\"#, // DCS, SOS, PM, APC sequences\n            #\"\\x1b[@-Z\\\\-_]\"#,           // Fe escape sequences\n        ]\n\n        var result = text\n        for pattern in patterns {\n            result = result.replacingOccurrences(\n                of: pattern,\n                with: \"\",\n                options: .regularExpression\n            )\n        }\n        return result\n    }\n\n    // MARK: - Trailing Whitespace\n\n    static func trimTrailingWhitespace(_ text: String) -> String {\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false)\n        let trimmed = lines.map { line -> String in\n            var s = String(line)\n            while s.last?.isWhitespace == true && s.last != \"\\n\" {\n                s.removeLast()\n            }\n            return s\n        }\n        return trimmed.joined(separator: \"\\n\")\n    }\n\n    // MARK: - Blank Lines\n\n    static func collapseBlankLines(_ text: String) -> String {\n        text.replacingOccurrences(\n            of: #\"\\n{3,}\"#,\n            with: \"\\n\\n\",\n            options: .regularExpression\n        )\n    }\n\n    // MARK: - Shell Prompts\n\n    static func stripPromptPrefixes(_ text: String) -> String? {\n        let lines = text.split(omittingEmptySubsequences: false, whereSeparator: \\.isNewline)\n        let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }\n        guard !nonEmptyLines.isEmpty else { return nil }\n\n        var strippedCount = 0\n        var rebuilt: [String] = []\n        rebuilt.reserveCapacity(lines.count)\n\n        for line in lines {\n            if let stripped = stripPrompt(in: line) {\n                strippedCount += 1\n                rebuilt.append(stripped)\n            } else {\n                rebuilt.append(String(line))\n            }\n        }\n\n        let majorityThreshold = nonEmptyLines.count / 2 + 1\n        let shouldStrip = nonEmptyLines.count == 1 ? strippedCount == 1 : strippedCount >= majorityThreshold\n        guard shouldStrip else { return nil }\n\n        let result = rebuilt.joined(separator: \"\\n\")\n        return result == text ? nil : result\n    }\n\n    private static func stripPrompt(in line: Substring) -> String? {\n        let leadingWhitespace = line.prefix { $0.isWhitespace }\n        let remainder = line.dropFirst(leadingWhitespace.count)\n\n        guard let first = remainder.first, first == \"#\" || first == \"$\" else { return nil }\n\n        let afterPrompt = remainder.dropFirst().drop { $0.isWhitespace }\n        guard isLikelyPromptCommand(afterPrompt) else { return nil }\n\n        return String(leadingWhitespace) + String(afterPrompt)\n    }\n\n    private static func isLikelyPromptCommand(_ content: Substring) -> Bool {\n        let trimmed = String(content.trimmingCharacters(in: .whitespaces))\n        guard !trimmed.isEmpty else { return false }\n        if let last = trimmed.last, [\".\", \"?\", \"!\"].contains(last) { return false }\n\n        let hasCommandPunctuation =\n            trimmed.contains(where: { \"-./~$\".contains($0) }) || trimmed.contains(where: \\.isNumber)\n        let firstToken = trimmed.split(separator: \" \").first?.lowercased() ?? \"\"\n        let startsWithKnown = knownCommandPrefixes.contains(where: { firstToken.hasPrefix($0) })\n\n        guard hasCommandPunctuation || startsWithKnown else { return false }\n        return isLikelyCommandLine(trimmed[...])\n    }\n\n    // MARK: - Command Flattening\n\n    static func flattenMultilineCommand(_ text: String) -> String? {\n        guard text.contains(\"\\n\") else { return nil }\n\n        let lines = text.split(whereSeparator: { $0.isNewline })\n        guard lines.count >= 2, lines.count <= 10 else { return nil }\n\n        // Check for command-like patterns\n        let hasLineContinuation = text.contains(\"\\\\\\n\")\n        let hasLineJoinerAtEOL = text.range(\n            of: #\"(?m)(\\\\|[|&]{1,2}|;)\\s*$\"#,\n            options: .regularExpression) != nil\n        let hasIndentedPipeline = text.range(\n            of: #\"(?m)^\\s*[|&]{1,2}\\s+\\S\"#,\n            options: .regularExpression) != nil\n        let hasExplicitLineJoin = hasLineContinuation || hasLineJoinerAtEOL || hasIndentedPipeline\n\n        // Only flatten if it looks like a command\n        guard hasExplicitLineJoin || looksLikeCommand(text, lines: lines) else { return nil }\n\n        let flattened = flatten(text)\n        return flattened == text ? nil : flattened\n    }\n\n    private static func looksLikeCommand(_ text: String, lines: [Substring]) -> Bool {\n        let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }\n\n        // Check for strong command signals\n        let strongSignals = text.contains(\"\\\\\\n\")\n            || text.range(of: #\"[|&]{1,2}\"#, options: .regularExpression) != nil\n            || text.range(of: #\"(^|\\n)\\s*\\$\"#, options: .regularExpression) != nil\n\n        if strongSignals { return true }\n\n        // Check if lines look like commands\n        let commandLineCount = nonEmptyLines.count(where: isLikelyCommandLine(_:))\n        if commandLineCount == nonEmptyLines.count { return true }\n\n        // Check for known command prefixes\n        let hasKnownPrefix = lines.contains { line in\n            let trimmed = line.trimmingCharacters(in: .whitespaces)\n            guard let firstToken = trimmed.split(separator: \" \").first else { return false }\n            let lower = firstToken.lowercased()\n            return knownCommandPrefixes.contains(where: { lower.hasPrefix($0) })\n        }\n\n        return hasKnownPrefix\n    }\n\n    private static func isLikelyCommandLine(_ lineSubstr: Substring) -> Bool {\n        let line = lineSubstr.trimmingCharacters(in: .whitespaces)\n        guard !line.isEmpty else { return false }\n        if line.hasPrefix(\"[[\") { return true }\n        if line.last == \".\" { return false }\n        let pattern = #\"^(sudo\\s+)?[A-Za-z0-9./~_-]+(?:\\s+|\\z)\"#\n        return line.range(of: pattern, options: .regularExpression) != nil\n    }\n\n    private static func flatten(_ text: String) -> String {\n        var result = text\n\n        // Join uppercase segment line breaks\n        result = result.replacingOccurrences(\n            of: #\"(?<!\\n)([A-Z0-9_.-])\\s*\\n\\s*([A-Z0-9_.-])(?!\\n)\"#,\n            with: \"$1$2\",\n            options: .regularExpression)\n\n        // Join path line breaks\n        result = result.replacingOccurrences(\n            of: #\"(?<=[/~])\\s*\\n\\s*([A-Za-z0-9._-])\"#,\n            with: \"$1\",\n            options: .regularExpression)\n\n        // Replace backslash continuations\n        result = result.replacingOccurrences(of: #\"\\\\\\s*\\n\"#, with: \" \", options: .regularExpression)\n\n        // Collapse newlines to spaces\n        result = result.replacingOccurrences(of: #\"\\n+\"#, with: \" \", options: .regularExpression)\n\n        // Collapse multiple spaces\n        result = result.replacingOccurrences(of: #\"\\s+\"#, with: \" \", options: .regularExpression)\n\n        return result.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    // MARK: - Box Drawing\n\n    static func stripBoxDrawingCharacters(in text: String) -> String? {\n        let boxRegex = try? NSRegularExpression(pattern: boxDrawingCharacterClass, options: [])\n        if boxRegex?.firstMatch(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count)) == nil {\n            return nil\n        }\n\n        var result = text\n\n        if result.contains(\"│ │\") {\n            result = result.replacingOccurrences(of: \"│ │\", with: \" \")\n        }\n\n        let lines = result.split(omittingEmptySubsequences: false, whereSeparator: \\.isNewline)\n        let nonEmptyLines = lines.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty }\n\n        if !nonEmptyLines.isEmpty {\n            let leadingPattern = #\"^\\s*\\#(boxDrawingCharacterClass)+ ?\"#\n            let trailingPattern = #\" ?\\#(boxDrawingCharacterClass)+\\s*$\"#\n            let majorityThreshold = nonEmptyLines.count / 2 + 1\n\n            let leadingMatches = nonEmptyLines.count(where: {\n                $0.range(of: leadingPattern, options: .regularExpression) != nil\n            })\n            let trailingMatches = nonEmptyLines.count(where: {\n                $0.range(of: trailingPattern, options: .regularExpression) != nil\n            })\n\n            let stripLeading = leadingMatches >= majorityThreshold\n            let stripTrailing = trailingMatches >= majorityThreshold\n\n            if stripLeading || stripTrailing {\n                var rebuilt: [String] = []\n                rebuilt.reserveCapacity(lines.count)\n\n                for line in lines {\n                    var lineStr = String(line)\n                    if stripLeading {\n                        lineStr = lineStr.replacingOccurrences(\n                            of: leadingPattern,\n                            with: \"\",\n                            options: .regularExpression)\n                    }\n                    if stripTrailing {\n                        lineStr = lineStr.replacingOccurrences(\n                            of: trailingPattern,\n                            with: \"\",\n                            options: .regularExpression)\n                    }\n                    rebuilt.append(lineStr)\n                }\n\n                result = rebuilt.joined(separator: \"\\n\")\n            }\n        }\n\n        // Clean up box chars in mid-token positions\n        let boxAfterPipePattern = #\"\\|\\s*\\#(boxDrawingCharacterClass)+\\s*\"#\n        result = result.replacingOccurrences(\n            of: boxAfterPipePattern,\n            with: \"| \",\n            options: .regularExpression)\n\n        let boxMidTokenPattern = #\"(\\S)\\s*\\#(boxDrawingCharacterClass)+\\s*(\\S)\"#\n        result = result.replacingOccurrences(\n            of: boxMidTokenPattern,\n            with: \"$1 $2\",\n            options: .regularExpression)\n\n        result = result.replacingOccurrences(\n            of: #\"\\s*\\#(boxDrawingCharacterClass)+\\s*\"#,\n            with: \" \",\n            options: .regularExpression)\n\n        let collapsed = result.replacingOccurrences(\n            of: #\" {2,}\"#,\n            with: \" \",\n            options: .regularExpression)\n\n        let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed == text ? nil : trimmed\n    }\n}\n"
  },
  {
    "path": "ghostty/Vendor/VERSION",
    "content": "9fb03ba55c9e53901193187d5c43341f5b1b430d\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/allocator.h",
    "content": "/**\n * @file allocator.h\n *\n * Memory management interface for libghostty-vt.\n */\n\n#ifndef GHOSTTY_VT_ALLOCATOR_H\n#define GHOSTTY_VT_ALLOCATOR_H\n\n#include <stdbool.h>\n#include <stddef.h>\n#include <stdint.h>\n\n/** @defgroup allocator Memory Management\n *\n * libghostty-vt does require memory allocation for various operations,\n * but is resilient to allocation failures and will gracefully handle\n * out-of-memory situations by returning error codes.\n *\n * The exact memory management semantics are documented in the relevant\n * functions and data structures.\n *\n * libghostty-vt uses explicit memory allocation via an allocator\n * interface provided by GhosttyAllocator. The interface is based on the\n * [Zig](https://ziglang.org) allocator interface, since this has been\n * shown to be a flexible and powerful interface in practice and enables\n * a wide variety of allocation strategies.\n *\n * **For the common case, you can pass NULL as the allocator for any\n * function that accepts one,** and libghostty will use a default allocator.\n * The default allocator will be libc malloc/free if libc is linked. \n * Otherwise, a custom allocator is used (currently Zig's SMP allocator)\n * that doesn't require any external dependencies.\n *\n * ## Basic Usage\n *\n * For simple use cases, you can ignore this interface entirely by passing NULL\n * as the allocator parameter to functions that accept one. This will use the\n * default allocator (typically libc malloc/free, if libc is linked, but\n * we provide our own default allocator if libc isn't linked).\n *\n * To use a custom allocator:\n * 1. Implement the GhosttyAllocatorVtable function pointers\n * 2. Create a GhosttyAllocator struct with your vtable and context\n * 3. Pass the allocator to functions that accept one\n *\n * @{\n */\n\n/**\n * Function table for custom memory allocator operations.\n * \n * This vtable defines the interface for a custom memory allocator. All\n * function pointers must be valid and non-NULL.\n *\n * @ingroup allocator\n *\n * If you're not going to use a custom allocator, you can ignore all of\n * this. All functions that take an allocator pointer allow NULL to use a\n * default allocator.\n *\n * The interface is based on the Zig allocator interface. I'll say up front\n * that it is easy to look at this interface and think \"wow, this is really\n * overcomplicated\". The reason for this complexity is well thought out by\n * the Zig folks, and it enables a diverse set of allocation strategies\n * as shown by the Zig ecosystem. As a consolation, please note that many\n * of the arguments are only needed for advanced use cases and can be\n * safely ignored in simple implementations. For example, if you look at \n * the Zig implementation of the libc allocator in `lib/std/heap.zig`\n * (search for CAllocator), you'll see it is very simple.\n *\n * We chose to align with the Zig allocator interface because:\n *\n *   1. It is a proven interface that serves a wide variety of use cases\n *      in the real world via the Zig ecosystem. It's shown to work.\n *\n *   2. Our core implementation itself is Zig, and this lets us very\n *      cheaply and easily convert between C and Zig allocators.\n *\n * NOTE(mitchellh): In the future, we can have default implementations of\n * resize/remap and allow those to be null.\n */\ntypedef struct {\n    /**\n     * Return a pointer to `len` bytes with specified `alignment`, or return\n     * `NULL` indicating the allocation failed.\n     *\n     * @param ctx The allocator context\n     * @param len Number of bytes to allocate\n     * @param alignment Required alignment for the allocation. Guaranteed to\n     *   be a power of two between 1 and 16 inclusive.\n     * @param ret_addr First return address of the allocation call stack (0 if not provided)\n     * @return Pointer to allocated memory, or NULL if allocation failed\n     */\n    void* (*alloc)(void *ctx, size_t len, uint8_t alignment, uintptr_t ret_addr);\n    \n    /**\n     * Attempt to expand or shrink memory in place.\n     *\n     * `memory_len` must equal the length requested from the most recent\n     * successful call to `alloc`, `resize`, or `remap`. `alignment` must\n     * equal the same value that was passed as the `alignment` parameter to\n     * the original `alloc` call.\n     *\n     * `new_len` must be greater than zero.\n     *\n     * @param ctx The allocator context\n     * @param memory Pointer to the memory block to resize\n     * @param memory_len Current size of the memory block\n     * @param alignment Alignment (must match original allocation)\n     * @param new_len New requested size\n     * @param ret_addr First return address of the allocation call stack (0 if not provided)\n     * @return true if resize was successful in-place, false if relocation would be required\n     */\n    bool (*resize)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);\n    \n    /**\n     * Attempt to expand or shrink memory, allowing relocation.\n     *\n     * `memory_len` must equal the length requested from the most recent\n     * successful call to `alloc`, `resize`, or `remap`. `alignment` must\n     * equal the same value that was passed as the `alignment` parameter to\n     * the original `alloc` call.\n     *\n     * A non-`NULL` return value indicates the resize was successful. The\n     * allocation may have same address, or may have been relocated. In either\n     * case, the allocation now has size of `new_len`. A `NULL` return value\n     * indicates that the resize would be equivalent to allocating new memory,\n     * copying the bytes from the old memory, and then freeing the old memory.\n     * In such case, it is more efficient for the caller to perform the copy.\n     *\n     * `new_len` must be greater than zero.\n     *\n     * @param ctx The allocator context\n     * @param memory Pointer to the memory block to remap\n     * @param memory_len Current size of the memory block\n     * @param alignment Alignment (must match original allocation)\n     * @param new_len New requested size\n     * @param ret_addr First return address of the allocation call stack (0 if not provided)\n     * @return Pointer to resized memory (may be relocated), or NULL if manual copy is needed\n     */\n    void* (*remap)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, size_t new_len, uintptr_t ret_addr);\n    \n    /**\n     * Free and invalidate a region of memory.\n     *\n     * `memory_len` must equal the length requested from the most recent\n     * successful call to `alloc`, `resize`, or `remap`. `alignment` must\n     * equal the same value that was passed as the `alignment` parameter to\n     * the original `alloc` call.\n     *\n     * @param ctx The allocator context\n     * @param memory Pointer to the memory block to free\n     * @param memory_len Size of the memory block\n     * @param alignment Alignment (must match original allocation)\n     * @param ret_addr First return address of the allocation call stack (0 if not provided)\n     */\n    void (*free)(void *ctx, void *memory, size_t memory_len, uint8_t alignment, uintptr_t ret_addr);\n} GhosttyAllocatorVtable;\n\n/**\n * Custom memory allocator.\n *\n * For functions that take an allocator pointer, a NULL pointer indicates\n * that the default allocator should be used. The default allocator will \n * be libc malloc/free if we're linking to libc. If libc isn't linked,\n * a custom allocator is used (currently Zig's SMP allocator).\n *\n * @ingroup allocator\n *\n * Usage example:\n * @code\n * GhosttyAllocator allocator = {\n *     .vtable = &my_allocator_vtable,\n *     .ctx = my_allocator_state\n * };\n * @endcode\n */\ntypedef struct GhosttyAllocator {\n    /**\n     * Opaque context pointer passed to all vtable functions.\n     * This allows the allocator implementation to maintain state\n     * or reference external resources needed for memory management.\n     */\n    void *ctx;\n\n    /**\n     * Pointer to the allocator's vtable containing function pointers\n     * for memory operations (alloc, resize, remap, free).\n     */\n    const GhosttyAllocatorVtable *vtable;\n} GhosttyAllocator;\n\n/** @} */\n\n#endif /* GHOSTTY_VT_ALLOCATOR_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/color.h",
    "content": "/**\n * @file color.h\n *\n * Color types and utilities.\n */\n\n#ifndef GHOSTTY_VT_COLOR_H\n#define GHOSTTY_VT_COLOR_H\n\n#include <stdint.h>\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n/**\n * RGB color value.\n *\n * @ingroup sgr\n */\ntypedef struct {\n  uint8_t r; /**< Red component (0-255) */\n  uint8_t g; /**< Green component (0-255) */\n  uint8_t b; /**< Blue component (0-255) */\n} GhosttyColorRgb;\n\n/**\n * Palette color index (0-255).\n *\n * @ingroup sgr\n */\ntypedef uint8_t GhosttyColorPaletteIndex;\n\n/** @addtogroup sgr\n * @{\n */\n\n/** Black color (0) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BLACK 0\n/** Red color (1) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_RED 1\n/** Green color (2) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_GREEN 2\n/** Yellow color (3) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_YELLOW 3\n/** Blue color (4) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BLUE 4\n/** Magenta color (5) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_MAGENTA 5\n/** Cyan color (6) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_CYAN 6\n/** White color (7) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_WHITE 7\n/** Bright black color (8) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_BLACK 8\n/** Bright red color (9) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_RED 9\n/** Bright green color (10) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_GREEN 10\n/** Bright yellow color (11) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_YELLOW 11\n/** Bright blue color (12) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_BLUE 12\n/** Bright magenta color (13) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_MAGENTA 13\n/** Bright cyan color (14) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_CYAN 14\n/** Bright white color (15) @ingroup sgr */\n#define GHOSTTY_COLOR_NAMED_BRIGHT_WHITE 15\n\n/** @} */\n\n/**\n * Get the RGB color components.\n *\n * This function extracts the individual red, green, and blue components\n * from a GhosttyColorRgb value. Primarily useful in WebAssembly environments\n * where accessing struct fields directly is difficult.\n *\n * @param color The RGB color value\n * @param r Pointer to store the red component (0-255)\n * @param g Pointer to store the green component (0-255)\n * @param b Pointer to store the blue component (0-255)\n *\n * @ingroup sgr\n */\nvoid ghostty_color_rgb_get(GhosttyColorRgb color,\n                           uint8_t* r,\n                           uint8_t* g,\n                           uint8_t* b);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* GHOSTTY_VT_COLOR_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/key/encoder.h",
    "content": "/**\n * @file encoder.h\n *\n * Key event encoding to terminal escape sequences.\n */\n\n#ifndef GHOSTTY_VT_KEY_ENCODER_H\n#define GHOSTTY_VT_KEY_ENCODER_H\n\n#include <stddef.h>\n#include <stdint.h>\n#include <ghostty/vt/result.h>\n#include <ghostty/vt/allocator.h>\n#include <ghostty/vt/key/event.h>\n\n/**\n * Opaque handle to a key encoder instance.\n *\n * This handle represents a key encoder that converts key events into terminal\n * escape sequences.\n *\n * @ingroup key\n */\ntypedef struct GhosttyKeyEncoder *GhosttyKeyEncoder;\n\n/**\n * Kitty keyboard protocol flags.\n *\n * Bitflags representing the various modes of the Kitty keyboard protocol.\n * These can be combined using bitwise OR operations. Valid values all\n * start with `GHOSTTY_KITTY_KEY_`.\n *\n * @ingroup key\n */\ntypedef uint8_t GhosttyKittyKeyFlags;\n\n/** Kitty keyboard protocol disabled (all flags off) */\n#define GHOSTTY_KITTY_KEY_DISABLED 0\n\n/** Disambiguate escape codes */\n#define GHOSTTY_KITTY_KEY_DISAMBIGUATE (1 << 0)\n\n/** Report key press and release events */\n#define GHOSTTY_KITTY_KEY_REPORT_EVENTS (1 << 1)\n\n/** Report alternate key codes */\n#define GHOSTTY_KITTY_KEY_REPORT_ALTERNATES (1 << 2)\n\n/** Report all key events including those normally handled by the terminal */\n#define GHOSTTY_KITTY_KEY_REPORT_ALL (1 << 3)\n\n/** Report associated text with key events */\n#define GHOSTTY_KITTY_KEY_REPORT_ASSOCIATED (1 << 4)\n\n/** All Kitty keyboard protocol flags enabled */\n#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)\n\n/**\n * macOS option key behavior.\n *\n * Determines whether the \"option\" key on macOS is treated as \"alt\" or not.\n * See the Ghostty `macos-option-as-alt` configuration option for more details.\n *\n * @ingroup key\n */\ntypedef enum {\n    /** Option key is not treated as alt */\n    GHOSTTY_OPTION_AS_ALT_FALSE = 0,\n    /** Option key is treated as alt */\n    GHOSTTY_OPTION_AS_ALT_TRUE = 1,\n    /** Only left option key is treated as alt */\n    GHOSTTY_OPTION_AS_ALT_LEFT = 2,\n    /** Only right option key is treated as alt */\n    GHOSTTY_OPTION_AS_ALT_RIGHT = 3,\n} GhosttyOptionAsAlt;\n\n/**\n * Key encoder option identifiers.\n *\n * These values are used with ghostty_key_encoder_setopt() to configure\n * the behavior of the key encoder.\n *\n * @ingroup key\n */\ntypedef enum {\n    /** Terminal DEC mode 1: cursor key application mode (value: bool) */\n    GHOSTTY_KEY_ENCODER_OPT_CURSOR_KEY_APPLICATION = 0,\n    \n    /** Terminal DEC mode 66: keypad key application mode (value: bool) */\n    GHOSTTY_KEY_ENCODER_OPT_KEYPAD_KEY_APPLICATION = 1,\n    \n    /** Terminal DEC mode 1035: ignore keypad with numlock (value: bool) */\n    GHOSTTY_KEY_ENCODER_OPT_IGNORE_KEYPAD_WITH_NUMLOCK = 2,\n    \n    /** Terminal DEC mode 1036: alt sends escape prefix (value: bool) */\n    GHOSTTY_KEY_ENCODER_OPT_ALT_ESC_PREFIX = 3,\n    \n    /** xterm modifyOtherKeys mode 2 (value: bool) */\n    GHOSTTY_KEY_ENCODER_OPT_MODIFY_OTHER_KEYS_STATE_2 = 4,\n    \n    /** Kitty keyboard protocol flags (value: GhosttyKittyKeyFlags bitmask) */\n    GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS = 5,\n    \n    /** macOS option-as-alt setting (value: GhosttyOptionAsAlt) */\n    GHOSTTY_KEY_ENCODER_OPT_MACOS_OPTION_AS_ALT = 6,\n} GhosttyKeyEncoderOption;\n\n/**\n * Create a new key encoder instance.\n *\n * Creates a new key encoder with default options. The encoder can be configured\n * using ghostty_key_encoder_setopt() and must be freed using\n * ghostty_key_encoder_free() when no longer needed.\n *\n * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n * @param encoder Pointer to store the created encoder handle\n * @return GHOSTTY_SUCCESS on success, or an error code on failure\n *\n * @ingroup key\n */\nGhosttyResult ghostty_key_encoder_new(const GhosttyAllocator *allocator, GhosttyKeyEncoder *encoder);\n\n/**\n * Free a key encoder instance.\n *\n * Releases all resources associated with the key encoder. After this call,\n * the encoder handle becomes invalid and must not be used.\n *\n * @param encoder The encoder handle to free (may be NULL)\n *\n * @ingroup key\n */\nvoid ghostty_key_encoder_free(GhosttyKeyEncoder encoder);\n\n/**\n * Set an option on the key encoder.\n *\n * Configures the behavior of the key encoder. Options control various aspects\n * of encoding such as terminal modes (cursor key application mode, keypad mode),\n * protocol selection (Kitty keyboard protocol flags), and platform-specific\n * behaviors (macOS option-as-alt).\n *\n * A null pointer value does nothing. It does not reset the value to the\n * default. The setopt call will do nothing.\n *\n * @param encoder The encoder handle, must not be NULL\n * @param option The option to set\n * @param value Pointer to the value to set (type depends on the option)\n *\n * @ingroup key\n */\nvoid ghostty_key_encoder_setopt(GhosttyKeyEncoder encoder, GhosttyKeyEncoderOption option, const void *value);\n\n/**\n * Encode a key event into a terminal escape sequence.\n *\n * Converts a key event into the appropriate terminal escape sequence based on\n * the encoder's current options. The sequence is written to the provided buffer.\n *\n * Not all key events produce output. For example, unmodified modifier keys\n * typically don't generate escape sequences. Check the out_len parameter to\n * determine if any data was written.\n *\n * If the output buffer is too small, this function returns GHOSTTY_OUT_OF_MEMORY\n * and out_len will contain the required buffer size. The caller can then\n * allocate a larger buffer and call the function again.\n *\n * @param encoder The encoder handle, must not be NULL\n * @param event The key event to encode, must not be NULL\n * @param out_buf Buffer to write the encoded sequence to\n * @param out_buf_size Size of the output buffer in bytes\n * @param out_len Pointer to store the number of bytes written (may be NULL)\n * @return GHOSTTY_SUCCESS on success, GHOSTTY_OUT_OF_MEMORY if buffer too small, or other error code\n *\n * ## Example: Calculate required buffer size\n *\n * @code{.c}\n * // Query the required size with a NULL buffer (always returns OUT_OF_MEMORY)\n * size_t required = 0;\n * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, NULL, 0, &required);\n * assert(result == GHOSTTY_OUT_OF_MEMORY);\n * \n * // Allocate buffer of required size\n * char *buf = malloc(required);\n * \n * // Encode with properly sized buffer\n * size_t written = 0;\n * result = ghostty_key_encoder_encode(encoder, event, buf, required, &written);\n * assert(result == GHOSTTY_SUCCESS);\n * \n * // Use the encoded sequence...\n * \n * free(buf);\n * @endcode\n *\n * ## Example: Direct encoding with static buffer\n *\n * @code{.c}\n * // Most escape sequences are short, so a static buffer often suffices\n * char buf[128];\n * size_t written = 0;\n * GhosttyResult result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);\n * \n * if (result == GHOSTTY_SUCCESS) {\n *   // Write the encoded sequence to the terminal\n *   write(pty_fd, buf, written);\n * } else if (result == GHOSTTY_OUT_OF_MEMORY) {\n *   // Buffer too small, written contains required size\n *   char *dynamic_buf = malloc(written);\n *   result = ghostty_key_encoder_encode(encoder, event, dynamic_buf, written, &written);\n *   assert(result == GHOSTTY_SUCCESS);\n *   write(pty_fd, dynamic_buf, written);\n *   free(dynamic_buf);\n * }\n * @endcode\n *\n * @ingroup key\n */\nGhosttyResult ghostty_key_encoder_encode(GhosttyKeyEncoder encoder, GhosttyKeyEvent event, char *out_buf, size_t out_buf_size, size_t *out_len);\n\n#endif /* GHOSTTY_VT_KEY_ENCODER_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/key/event.h",
    "content": "/**\n * @file event.h\n *\n * Key event representation and manipulation.\n */\n\n#ifndef GHOSTTY_VT_KEY_EVENT_H\n#define GHOSTTY_VT_KEY_EVENT_H\n\n#include <stdbool.h>\n#include <stddef.h>\n#include <stdint.h>\n#include <ghostty/vt/result.h>\n#include <ghostty/vt/allocator.h>\n\n/**\n * Opaque handle to a key event.\n * \n * This handle represents a keyboard input event containing information about\n * the physical key pressed, modifiers, and generated text.\n *\n * @ingroup key\n */\ntypedef struct GhosttyKeyEvent *GhosttyKeyEvent;\n\n/**\n * Keyboard input event types.\n *\n * @ingroup key\n */\ntypedef enum {\n    /** Key was released */\n    GHOSTTY_KEY_ACTION_RELEASE = 0,\n    /** Key was pressed */\n    GHOSTTY_KEY_ACTION_PRESS = 1,\n    /** Key is being repeated (held down) */\n    GHOSTTY_KEY_ACTION_REPEAT = 2,\n} GhosttyKeyAction;\n\n/**\n * Keyboard modifier keys bitmask.\n *\n * A bitmask representing all keyboard modifiers. This tracks which modifier keys \n * are pressed and, where supported by the platform, which side (left or right) \n * of each modifier is active.\n *\n * Use the GHOSTTY_MODS_* constants to test and set individual modifiers.\n *\n * Modifier side bits are only meaningful when the corresponding modifier bit is set.\n * Not all platforms support distinguishing between left and right modifier \n * keys and Ghostty is built to expect that some platforms may not provide this\n * information.\n *\n * @ingroup key\n */\ntypedef uint16_t GhosttyMods;\n\n/** Shift key is pressed */\n#define GHOSTTY_MODS_SHIFT (1 << 0)\n/** Control key is pressed */\n#define GHOSTTY_MODS_CTRL (1 << 1)\n/** Alt/Option key is pressed */\n#define GHOSTTY_MODS_ALT (1 << 2)\n/** Super/Command/Windows key is pressed */\n#define GHOSTTY_MODS_SUPER (1 << 3)\n/** Caps Lock is active */\n#define GHOSTTY_MODS_CAPS_LOCK (1 << 4)\n/** Num Lock is active */\n#define GHOSTTY_MODS_NUM_LOCK (1 << 5)\n\n/**\n * Right shift is pressed (0 = left, 1 = right).\n * Only meaningful when GHOSTTY_MODS_SHIFT is set.\n */\n#define GHOSTTY_MODS_SHIFT_SIDE (1 << 6)\n/**\n * Right ctrl is pressed (0 = left, 1 = right).\n * Only meaningful when GHOSTTY_MODS_CTRL is set.\n */\n#define GHOSTTY_MODS_CTRL_SIDE (1 << 7)\n/**\n * Right alt is pressed (0 = left, 1 = right).\n * Only meaningful when GHOSTTY_MODS_ALT is set.\n */\n#define GHOSTTY_MODS_ALT_SIDE (1 << 8)\n/**\n * Right super is pressed (0 = left, 1 = right).\n * Only meaningful when GHOSTTY_MODS_SUPER is set.\n */\n#define GHOSTTY_MODS_SUPER_SIDE (1 << 9)\n\n/**\n * Physical key codes.\n *\n * The set of key codes that Ghostty is aware of. These represent physical keys \n * on the keyboard and are layout-independent. For example, the \"a\" key on a US \n * keyboard is the same as the \"ф\" key on a Russian keyboard, but both will \n * report the same key_a value.\n *\n * Layout-dependent strings are provided separately as UTF-8 text and are produced \n * by the platform. These values are based on the W3C UI Events KeyboardEvent code \n * standard. See: https://www.w3.org/TR/uievents-code\n *\n * @ingroup key\n */\ntypedef enum {\n    GHOSTTY_KEY_UNIDENTIFIED = 0,\n\n    // Writing System Keys (W3C § 3.1.1)\n    GHOSTTY_KEY_BACKQUOTE,\n    GHOSTTY_KEY_BACKSLASH,\n    GHOSTTY_KEY_BRACKET_LEFT,\n    GHOSTTY_KEY_BRACKET_RIGHT,\n    GHOSTTY_KEY_COMMA,\n    GHOSTTY_KEY_DIGIT_0,\n    GHOSTTY_KEY_DIGIT_1,\n    GHOSTTY_KEY_DIGIT_2,\n    GHOSTTY_KEY_DIGIT_3,\n    GHOSTTY_KEY_DIGIT_4,\n    GHOSTTY_KEY_DIGIT_5,\n    GHOSTTY_KEY_DIGIT_6,\n    GHOSTTY_KEY_DIGIT_7,\n    GHOSTTY_KEY_DIGIT_8,\n    GHOSTTY_KEY_DIGIT_9,\n    GHOSTTY_KEY_EQUAL,\n    GHOSTTY_KEY_INTL_BACKSLASH,\n    GHOSTTY_KEY_INTL_RO,\n    GHOSTTY_KEY_INTL_YEN,\n    GHOSTTY_KEY_A,\n    GHOSTTY_KEY_B,\n    GHOSTTY_KEY_C,\n    GHOSTTY_KEY_D,\n    GHOSTTY_KEY_E,\n    GHOSTTY_KEY_F,\n    GHOSTTY_KEY_G,\n    GHOSTTY_KEY_H,\n    GHOSTTY_KEY_I,\n    GHOSTTY_KEY_J,\n    GHOSTTY_KEY_K,\n    GHOSTTY_KEY_L,\n    GHOSTTY_KEY_M,\n    GHOSTTY_KEY_N,\n    GHOSTTY_KEY_O,\n    GHOSTTY_KEY_P,\n    GHOSTTY_KEY_Q,\n    GHOSTTY_KEY_R,\n    GHOSTTY_KEY_S,\n    GHOSTTY_KEY_T,\n    GHOSTTY_KEY_U,\n    GHOSTTY_KEY_V,\n    GHOSTTY_KEY_W,\n    GHOSTTY_KEY_X,\n    GHOSTTY_KEY_Y,\n    GHOSTTY_KEY_Z,\n    GHOSTTY_KEY_MINUS,\n    GHOSTTY_KEY_PERIOD,\n    GHOSTTY_KEY_QUOTE,\n    GHOSTTY_KEY_SEMICOLON,\n    GHOSTTY_KEY_SLASH,\n\n    // Functional Keys (W3C § 3.1.2)\n    GHOSTTY_KEY_ALT_LEFT,\n    GHOSTTY_KEY_ALT_RIGHT,\n    GHOSTTY_KEY_BACKSPACE,\n    GHOSTTY_KEY_CAPS_LOCK,\n    GHOSTTY_KEY_CONTEXT_MENU,\n    GHOSTTY_KEY_CONTROL_LEFT,\n    GHOSTTY_KEY_CONTROL_RIGHT,\n    GHOSTTY_KEY_ENTER,\n    GHOSTTY_KEY_META_LEFT,\n    GHOSTTY_KEY_META_RIGHT,\n    GHOSTTY_KEY_SHIFT_LEFT,\n    GHOSTTY_KEY_SHIFT_RIGHT,\n    GHOSTTY_KEY_SPACE,\n    GHOSTTY_KEY_TAB,\n    GHOSTTY_KEY_CONVERT,\n    GHOSTTY_KEY_KANA_MODE,\n    GHOSTTY_KEY_NON_CONVERT,\n\n    // Control Pad Section (W3C § 3.2)\n    GHOSTTY_KEY_DELETE,\n    GHOSTTY_KEY_END,\n    GHOSTTY_KEY_HELP,\n    GHOSTTY_KEY_HOME,\n    GHOSTTY_KEY_INSERT,\n    GHOSTTY_KEY_PAGE_DOWN,\n    GHOSTTY_KEY_PAGE_UP,\n\n    // Arrow Pad Section (W3C § 3.3)\n    GHOSTTY_KEY_ARROW_DOWN,\n    GHOSTTY_KEY_ARROW_LEFT,\n    GHOSTTY_KEY_ARROW_RIGHT,\n    GHOSTTY_KEY_ARROW_UP,\n\n    // Numpad Section (W3C § 3.4)\n    GHOSTTY_KEY_NUM_LOCK,\n    GHOSTTY_KEY_NUMPAD_0,\n    GHOSTTY_KEY_NUMPAD_1,\n    GHOSTTY_KEY_NUMPAD_2,\n    GHOSTTY_KEY_NUMPAD_3,\n    GHOSTTY_KEY_NUMPAD_4,\n    GHOSTTY_KEY_NUMPAD_5,\n    GHOSTTY_KEY_NUMPAD_6,\n    GHOSTTY_KEY_NUMPAD_7,\n    GHOSTTY_KEY_NUMPAD_8,\n    GHOSTTY_KEY_NUMPAD_9,\n    GHOSTTY_KEY_NUMPAD_ADD,\n    GHOSTTY_KEY_NUMPAD_BACKSPACE,\n    GHOSTTY_KEY_NUMPAD_CLEAR,\n    GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,\n    GHOSTTY_KEY_NUMPAD_COMMA,\n    GHOSTTY_KEY_NUMPAD_DECIMAL,\n    GHOSTTY_KEY_NUMPAD_DIVIDE,\n    GHOSTTY_KEY_NUMPAD_ENTER,\n    GHOSTTY_KEY_NUMPAD_EQUAL,\n    GHOSTTY_KEY_NUMPAD_MEMORY_ADD,\n    GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,\n    GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,\n    GHOSTTY_KEY_NUMPAD_MEMORY_STORE,\n    GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,\n    GHOSTTY_KEY_NUMPAD_MULTIPLY,\n    GHOSTTY_KEY_NUMPAD_PAREN_LEFT,\n    GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,\n    GHOSTTY_KEY_NUMPAD_SUBTRACT,\n    GHOSTTY_KEY_NUMPAD_SEPARATOR,\n    GHOSTTY_KEY_NUMPAD_UP,\n    GHOSTTY_KEY_NUMPAD_DOWN,\n    GHOSTTY_KEY_NUMPAD_RIGHT,\n    GHOSTTY_KEY_NUMPAD_LEFT,\n    GHOSTTY_KEY_NUMPAD_BEGIN,\n    GHOSTTY_KEY_NUMPAD_HOME,\n    GHOSTTY_KEY_NUMPAD_END,\n    GHOSTTY_KEY_NUMPAD_INSERT,\n    GHOSTTY_KEY_NUMPAD_DELETE,\n    GHOSTTY_KEY_NUMPAD_PAGE_UP,\n    GHOSTTY_KEY_NUMPAD_PAGE_DOWN,\n\n    // Function Section (W3C § 3.5)\n    GHOSTTY_KEY_ESCAPE,\n    GHOSTTY_KEY_F1,\n    GHOSTTY_KEY_F2,\n    GHOSTTY_KEY_F3,\n    GHOSTTY_KEY_F4,\n    GHOSTTY_KEY_F5,\n    GHOSTTY_KEY_F6,\n    GHOSTTY_KEY_F7,\n    GHOSTTY_KEY_F8,\n    GHOSTTY_KEY_F9,\n    GHOSTTY_KEY_F10,\n    GHOSTTY_KEY_F11,\n    GHOSTTY_KEY_F12,\n    GHOSTTY_KEY_F13,\n    GHOSTTY_KEY_F14,\n    GHOSTTY_KEY_F15,\n    GHOSTTY_KEY_F16,\n    GHOSTTY_KEY_F17,\n    GHOSTTY_KEY_F18,\n    GHOSTTY_KEY_F19,\n    GHOSTTY_KEY_F20,\n    GHOSTTY_KEY_F21,\n    GHOSTTY_KEY_F22,\n    GHOSTTY_KEY_F23,\n    GHOSTTY_KEY_F24,\n    GHOSTTY_KEY_F25,\n    GHOSTTY_KEY_FN,\n    GHOSTTY_KEY_FN_LOCK,\n    GHOSTTY_KEY_PRINT_SCREEN,\n    GHOSTTY_KEY_SCROLL_LOCK,\n    GHOSTTY_KEY_PAUSE,\n\n    // Media Keys (W3C § 3.6)\n    GHOSTTY_KEY_BROWSER_BACK,\n    GHOSTTY_KEY_BROWSER_FAVORITES,\n    GHOSTTY_KEY_BROWSER_FORWARD,\n    GHOSTTY_KEY_BROWSER_HOME,\n    GHOSTTY_KEY_BROWSER_REFRESH,\n    GHOSTTY_KEY_BROWSER_SEARCH,\n    GHOSTTY_KEY_BROWSER_STOP,\n    GHOSTTY_KEY_EJECT,\n    GHOSTTY_KEY_LAUNCH_APP_1,\n    GHOSTTY_KEY_LAUNCH_APP_2,\n    GHOSTTY_KEY_LAUNCH_MAIL,\n    GHOSTTY_KEY_MEDIA_PLAY_PAUSE,\n    GHOSTTY_KEY_MEDIA_SELECT,\n    GHOSTTY_KEY_MEDIA_STOP,\n    GHOSTTY_KEY_MEDIA_TRACK_NEXT,\n    GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,\n    GHOSTTY_KEY_POWER,\n    GHOSTTY_KEY_SLEEP,\n    GHOSTTY_KEY_AUDIO_VOLUME_DOWN,\n    GHOSTTY_KEY_AUDIO_VOLUME_MUTE,\n    GHOSTTY_KEY_AUDIO_VOLUME_UP,\n    GHOSTTY_KEY_WAKE_UP,\n\n    // Legacy, Non-standard, and Special Keys (W3C § 3.7)\n    GHOSTTY_KEY_COPY,\n    GHOSTTY_KEY_CUT,\n    GHOSTTY_KEY_PASTE,\n} GhosttyKey;\n\n/**\n * Create a new key event instance.\n * \n * Creates a new key event with default values. The event must be freed using\n * ghostty_key_event_free() when no longer needed.\n * \n * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n * @param event Pointer to store the created key event handle\n * @return GHOSTTY_SUCCESS on success, or an error code on failure\n * \n * @ingroup key\n */\nGhosttyResult ghostty_key_event_new(const GhosttyAllocator *allocator, GhosttyKeyEvent *event);\n\n/**\n * Free a key event instance.\n * \n * Releases all resources associated with the key event. After this call,\n * the event handle becomes invalid and must not be used.\n * \n * @param event The key event handle to free (may be NULL)\n * \n * @ingroup key\n */\nvoid ghostty_key_event_free(GhosttyKeyEvent event);\n\n/**\n * Set the key action (press, release, repeat).\n *\n * @param event The key event handle, must not be NULL\n * @param action The action to set\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_action(GhosttyKeyEvent event, GhosttyKeyAction action);\n\n/**\n * Get the key action (press, release, repeat).\n *\n * @param event The key event handle, must not be NULL\n * @return The key action\n *\n * @ingroup key\n */\nGhosttyKeyAction ghostty_key_event_get_action(GhosttyKeyEvent event);\n\n/**\n * Set the physical key code.\n *\n * @param event The key event handle, must not be NULL\n * @param key The physical key code to set\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_key(GhosttyKeyEvent event, GhosttyKey key);\n\n/**\n * Get the physical key code.\n *\n * @param event The key event handle, must not be NULL\n * @return The physical key code\n *\n * @ingroup key\n */\nGhosttyKey ghostty_key_event_get_key(GhosttyKeyEvent event);\n\n/**\n * Set the modifier keys bitmask.\n *\n * @param event The key event handle, must not be NULL\n * @param mods The modifier keys bitmask to set\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_mods(GhosttyKeyEvent event, GhosttyMods mods);\n\n/**\n * Get the modifier keys bitmask.\n *\n * @param event The key event handle, must not be NULL\n * @return The modifier keys bitmask\n *\n * @ingroup key\n */\nGhosttyMods ghostty_key_event_get_mods(GhosttyKeyEvent event);\n\n/**\n * Set the consumed modifiers bitmask.\n *\n * @param event The key event handle, must not be NULL\n * @param consumed_mods The consumed modifiers bitmask to set\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_consumed_mods(GhosttyKeyEvent event, GhosttyMods consumed_mods);\n\n/**\n * Get the consumed modifiers bitmask.\n *\n * @param event The key event handle, must not be NULL\n * @return The consumed modifiers bitmask\n *\n * @ingroup key\n */\nGhosttyMods ghostty_key_event_get_consumed_mods(GhosttyKeyEvent event);\n\n/**\n * Set whether the key event is part of a composition sequence.\n *\n * @param event The key event handle, must not be NULL\n * @param composing Whether the key event is part of a composition sequence\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_composing(GhosttyKeyEvent event, bool composing);\n\n/**\n * Get whether the key event is part of a composition sequence.\n *\n * @param event The key event handle, must not be NULL\n * @return Whether the key event is part of a composition sequence\n *\n * @ingroup key\n */\nbool ghostty_key_event_get_composing(GhosttyKeyEvent event);\n\n/**\n * Set the UTF-8 text generated by the key event.\n *\n * The key event does NOT take ownership of the text pointer. The caller\n * must ensure the string remains valid for the lifetime needed by the event.\n *\n * @param event The key event handle, must not be NULL\n * @param utf8 The UTF-8 text to set (or NULL for empty)\n * @param len Length of the UTF-8 text in bytes\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_utf8(GhosttyKeyEvent event, const char *utf8, size_t len);\n\n/**\n * Get the UTF-8 text generated by the key event.\n *\n * The returned pointer is valid until the event is freed or the UTF-8 text is modified.\n *\n * @param event The key event handle, must not be NULL\n * @param len Pointer to store the length of the UTF-8 text in bytes (may be NULL)\n * @return The UTF-8 text (or NULL for empty)\n *\n * @ingroup key\n */\nconst char *ghostty_key_event_get_utf8(GhosttyKeyEvent event, size_t *len);\n\n/**\n * Set the unshifted Unicode codepoint.\n *\n * @param event The key event handle, must not be NULL\n * @param codepoint The unshifted Unicode codepoint to set\n *\n * @ingroup key\n */\nvoid ghostty_key_event_set_unshifted_codepoint(GhosttyKeyEvent event, uint32_t codepoint);\n\n/**\n * Get the unshifted Unicode codepoint.\n *\n * @param event The key event handle, must not be NULL\n * @return The unshifted Unicode codepoint\n *\n * @ingroup key\n */\nuint32_t ghostty_key_event_get_unshifted_codepoint(GhosttyKeyEvent event);\n\n#endif /* GHOSTTY_VT_KEY_EVENT_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/key.h",
    "content": "/**\n * @file key.h\n *\n * Key encoding module - encode key events into terminal escape sequences.\n */\n\n#ifndef GHOSTTY_VT_KEY_H\n#define GHOSTTY_VT_KEY_H\n\n/** @defgroup key Key Encoding\n *\n * Utilities for encoding key events into terminal escape sequences,\n * supporting both legacy encoding as well as Kitty Keyboard Protocol.\n *\n * ## Basic Usage\n *\n * 1. Create an encoder instance with ghostty_key_encoder_new()\n * 2. Configure encoder options with ghostty_key_encoder_setopt().\n * 3. For each key event:\n *    - Create a key event with ghostty_key_event_new()\n *    - Set event properties (action, key, modifiers, etc.)\n *    - Encode with ghostty_key_encoder_encode()\n *    - Free the event with ghostty_key_event_free()\n *    - Note: You can also reuse the same key event multiple times by\n *      changing its properties.\n * 4. Free the encoder with ghostty_key_encoder_free() when done\n *\n * ## Example\n *\n * @code{.c}\n * #include <assert.h>\n * #include <stdio.h>\n * #include <ghostty/vt.h>\n * \n * int main() {\n *   // Create encoder\n *   GhosttyKeyEncoder encoder;\n *   GhosttyResult result = ghostty_key_encoder_new(NULL, &encoder);\n *   assert(result == GHOSTTY_SUCCESS);\n * \n *   // Enable Kitty keyboard protocol with all features\n *   ghostty_key_encoder_setopt(encoder, GHOSTTY_KEY_ENCODER_OPT_KITTY_FLAGS, \n *                              &(uint8_t){GHOSTTY_KITTY_KEY_ALL});\n * \n *   // Create and configure key event for Ctrl+C press\n *   GhosttyKeyEvent event;\n *   result = ghostty_key_event_new(NULL, &event);\n *   assert(result == GHOSTTY_SUCCESS);\n *   ghostty_key_event_set_action(event, GHOSTTY_KEY_ACTION_PRESS);\n *   ghostty_key_event_set_key(event, GHOSTTY_KEY_C);\n *   ghostty_key_event_set_mods(event, GHOSTTY_MODS_CTRL);\n * \n *   // Encode the key event\n *   char buf[128];\n *   size_t written = 0;\n *   result = ghostty_key_encoder_encode(encoder, event, buf, sizeof(buf), &written);\n *   assert(result == GHOSTTY_SUCCESS);\n * \n *   // Use the encoded sequence (e.g., write to terminal)\n *   fwrite(buf, 1, written, stdout);\n * \n *   // Cleanup\n *   ghostty_key_event_free(event);\n *   ghostty_key_encoder_free(encoder);\n *   return 0;\n * }\n * @endcode\n *\n * For a complete working example, see example/c-vt-key-encode in the\n * repository.\n *\n * @{\n */\n\n#include <ghostty/vt/key/event.h>\n#include <ghostty/vt/key/encoder.h>\n\n/** @} */\n\n#endif /* GHOSTTY_VT_KEY_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/osc.h",
    "content": "/**\n * @file osc.h\n *\n * OSC (Operating System Command) sequence parser and command handling.\n */\n\n#ifndef GHOSTTY_VT_OSC_H\n#define GHOSTTY_VT_OSC_H\n\n#include <stdbool.h>\n#include <stddef.h>\n#include <stdint.h>\n#include <ghostty/vt/result.h>\n#include <ghostty/vt/allocator.h>\n\n/**\n * Opaque handle to an OSC parser instance.\n * \n * This handle represents an OSC (Operating System Command) parser that can\n * be used to parse the contents of OSC sequences.\n *\n * @ingroup osc\n */\ntypedef struct GhosttyOscParser *GhosttyOscParser;\n\n/**\n * Opaque handle to a single OSC command.\n * \n * This handle represents a parsed OSC (Operating System Command) command.\n * The command can be queried for its type and associated data.\n *\n * @ingroup osc\n */\ntypedef struct GhosttyOscCommand *GhosttyOscCommand;\n\n/** @defgroup osc OSC Parser\n *\n * OSC (Operating System Command) sequence parser and command handling.\n *\n * The parser operates in a streaming fashion, processing input byte-by-byte\n * to handle OSC sequences that may arrive in fragments across multiple reads.\n * This interface makes it easy to integrate into most environments and avoids\n * over-allocating buffers.\n *\n * ## Basic Usage\n *\n * 1. Create a parser instance with ghostty_osc_new()\n * 2. Feed bytes to the parser using ghostty_osc_next() \n * 3. Finalize parsing with ghostty_osc_end() to get the command\n * 4. Query command type and extract data using ghostty_osc_command_type()\n *    and ghostty_osc_command_data()\n * 5. Free the parser with ghostty_osc_free() when done\n *\n * @{\n */\n\n/**\n * OSC command types.\n *\n * @ingroup osc\n */\ntypedef enum {\n  GHOSTTY_OSC_COMMAND_INVALID = 0,\n  GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE = 1,\n  GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_ICON = 2,\n  GHOSTTY_OSC_COMMAND_PROMPT_START = 3,\n  GHOSTTY_OSC_COMMAND_PROMPT_END = 4,\n  GHOSTTY_OSC_COMMAND_END_OF_INPUT = 5,\n  GHOSTTY_OSC_COMMAND_END_OF_COMMAND = 6,\n  GHOSTTY_OSC_COMMAND_CLIPBOARD_CONTENTS = 7,\n  GHOSTTY_OSC_COMMAND_REPORT_PWD = 8,\n  GHOSTTY_OSC_COMMAND_MOUSE_SHAPE = 9,\n  GHOSTTY_OSC_COMMAND_COLOR_OPERATION = 10,\n  GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL = 11,\n  GHOSTTY_OSC_COMMAND_SHOW_DESKTOP_NOTIFICATION = 12,\n  GHOSTTY_OSC_COMMAND_HYPERLINK_START = 13,\n  GHOSTTY_OSC_COMMAND_HYPERLINK_END = 14,\n  GHOSTTY_OSC_COMMAND_CONEMU_SLEEP = 15,\n  GHOSTTY_OSC_COMMAND_CONEMU_SHOW_MESSAGE_BOX = 16,\n  GHOSTTY_OSC_COMMAND_CONEMU_CHANGE_TAB_TITLE = 17,\n  GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT = 18,\n  GHOSTTY_OSC_COMMAND_CONEMU_WAIT_INPUT = 19,\n  GHOSTTY_OSC_COMMAND_CONEMU_GUIMACRO = 20,\n} GhosttyOscCommandType;\n\n/**\n * OSC command data types.\n * \n * These values specify what type of data to extract from an OSC command\n * using `ghostty_osc_command_data`.\n *\n * @ingroup osc\n */\ntypedef enum {\n  /** Invalid data type. Never results in any data extraction. */\n  GHOSTTY_OSC_DATA_INVALID = 0,\n  \n  /** \n   * Window title string data.\n   *\n   * Valid for: GHOSTTY_OSC_COMMAND_CHANGE_WINDOW_TITLE\n   *\n   * Output type: const char ** (pointer to null-terminated string)\n   *\n   * Lifetime: Valid until the next call to any ghostty_osc_* function with \n   * the same parser instance. Memory is owned by the parser.\n   */\n  GHOSTTY_OSC_DATA_CHANGE_WINDOW_TITLE_STR = 1,\n} GhosttyOscCommandData;\n\n/**\n * Create a new OSC parser instance.\n * \n * Creates a new OSC (Operating System Command) parser using the provided\n * allocator. The parser must be freed using ghostty_vt_osc_free() when\n * no longer needed.\n * \n * @param allocator Pointer to the allocator to use for memory management, or NULL to use the default allocator\n * @param parser Pointer to store the created parser handle\n * @return GHOSTTY_SUCCESS on success, or an error code on failure\n * \n * @ingroup osc\n */\nGhosttyResult ghostty_osc_new(const GhosttyAllocator *allocator, GhosttyOscParser *parser);\n\n/**\n * Free an OSC parser instance.\n * \n * Releases all resources associated with the OSC parser. After this call,\n * the parser handle becomes invalid and must not be used.\n * \n * @param parser The parser handle to free (may be NULL)\n * \n * @ingroup osc\n */\nvoid ghostty_osc_free(GhosttyOscParser parser);\n\n/**\n * Reset an OSC parser instance to its initial state.\n * \n * Resets the parser state, clearing any partially parsed OSC sequences\n * and returning the parser to its initial state. This is useful for\n * reusing a parser instance or recovering from parse errors.\n * \n * @param parser The parser handle to reset, must not be null.\n * \n * @ingroup osc\n */\nvoid ghostty_osc_reset(GhosttyOscParser parser);\n\n/**\n * Parse the next byte in an OSC sequence.\n * \n * Processes a single byte as part of an OSC sequence. The parser maintains\n * internal state to track the progress through the sequence. Call this\n * function for each byte in the sequence data.\n *\n * When finished pumping the parser with bytes, call ghostty_osc_end\n * to get the final result.\n * \n * @param parser The parser handle, must not be null.\n * @param byte The next byte to parse\n * \n * @ingroup osc\n */\nvoid ghostty_osc_next(GhosttyOscParser parser, uint8_t byte);\n\n/**\n * Finalize OSC parsing and retrieve the parsed command.\n * \n * Call this function after feeding all bytes of an OSC sequence to the parser\n * using ghostty_osc_next() with the exception of the terminating character\n * (ESC or ST). This function finalizes the parsing process and returns the \n * parsed OSC command.\n *\n * The return value is never NULL. Invalid commands will return a command\n * with type GHOSTTY_OSC_COMMAND_INVALID.\n * \n * The terminator parameter specifies the byte that terminated the OSC sequence\n * (typically 0x07 for BEL or 0x5C for ST after ESC). This information is\n * preserved in the parsed command so that responses can use the same terminator\n * format for better compatibility with the calling program. For commands that\n * do not require a response, this parameter is ignored and the resulting\n * command will not retain the terminator information.\n * \n * The returned command handle is valid until the next call to any \n * `ghostty_osc_*` function with the same parser instance with the exception\n * of command introspection functions such as `ghostty_osc_command_type`.\n * \n * @param parser The parser handle, must not be null.\n * @param terminator The terminating byte of the OSC sequence (0x07 for BEL, 0x5C for ST)\n * @return Handle to the parsed OSC command\n * \n * @ingroup osc\n */\nGhosttyOscCommand ghostty_osc_end(GhosttyOscParser parser, uint8_t terminator);\n\n/**\n * Get the type of an OSC command.\n * \n * Returns the type identifier for the given OSC command. This can be used\n * to determine what kind of command was parsed and what data might be\n * available from it.\n * \n * @param command The OSC command handle to query (may be NULL)\n * @return The command type, or GHOSTTY_OSC_COMMAND_INVALID if command is NULL\n * \n * @ingroup osc\n */\nGhosttyOscCommandType ghostty_osc_command_type(GhosttyOscCommand command);\n\n/**\n * Extract data from an OSC command.\n * \n * Extracts typed data from the given OSC command based on the specified\n * data type. The output pointer must be of the appropriate type for the\n * requested data kind. Valid command types, output types, and memory\n * safety information are documented in the `GhosttyOscCommandData` enum.\n *\n * @param command The OSC command handle to query (may be NULL)\n * @param data The type of data to extract\n * @param out Pointer to store the extracted data (type depends on data parameter)\n * @return true if data extraction was successful, false otherwise\n * \n * @ingroup osc\n */\nbool ghostty_osc_command_data(GhosttyOscCommand command, GhosttyOscCommandData data, void *out);\n\n/** @} */\n\n#endif /* GHOSTTY_VT_OSC_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/paste.h",
    "content": "/**\n * @file paste.h\n *\n * Paste utilities - validate and encode paste data for terminal input.\n */\n\n#ifndef GHOSTTY_VT_PASTE_H\n#define GHOSTTY_VT_PASTE_H\n\n/** @defgroup paste Paste Utilities\n *\n * Utilities for validating paste data safety.\n *\n * ## Basic Usage\n *\n * Use ghostty_paste_is_safe() to check if paste data contains potentially\n * dangerous sequences before sending it to the terminal.\n *\n * ## Example\n *\n * @code{.c}\n * #include <stdio.h>\n * #include <string.h>\n * #include <ghostty/vt.h>\n * \n * int main() {\n *   const char* safe_data = \"hello world\";\n *   const char* unsafe_data = \"rm -rf /\\n\";\n * \n *   if (ghostty_paste_is_safe(safe_data, strlen(safe_data))) {\n *     printf(\"Safe to paste\\n\");\n *   }\n * \n *   if (!ghostty_paste_is_safe(unsafe_data, strlen(unsafe_data))) {\n *     printf(\"Unsafe! Contains newline\\n\");\n *   }\n * \n *   return 0;\n * }\n * @endcode\n *\n * @{\n */\n\n#include <stdbool.h>\n#include <stddef.h>\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n/**\n * Check if paste data is safe to paste into the terminal.\n *\n * Data is considered unsafe if it contains:\n * - Newlines (`\\n`) which can inject commands\n * - The bracketed paste end sequence (`\\x1b[201~`) which can be used\n *   to exit bracketed paste mode and inject commands\n *\n * This check is conservative and considers data unsafe regardless of\n * current terminal state.\n *\n * @param data The paste data to check (must not be NULL)\n * @param len The length of the data in bytes\n * @return true if the data is safe to paste, false otherwise\n */\nbool ghostty_paste_is_safe(const char* data, size_t len);\n\n#ifdef __cplusplus\n}\n#endif\n\n/** @} */\n\n#endif /* GHOSTTY_VT_PASTE_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/result.h",
    "content": "/**\n * @file result.h\n *\n * Result codes for libghostty-vt operations.\n */\n\n#ifndef GHOSTTY_VT_RESULT_H\n#define GHOSTTY_VT_RESULT_H\n\n/**\n * Result codes for libghostty-vt operations.\n */\ntypedef enum {\n    /** Operation completed successfully */\n    GHOSTTY_SUCCESS = 0,\n    /** Operation failed due to failed allocation */\n    GHOSTTY_OUT_OF_MEMORY = -1,\n    /** Operation failed due to invalid value */\n    GHOSTTY_INVALID_VALUE = -2,\n} GhosttyResult;\n\n#endif /* GHOSTTY_VT_RESULT_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/sgr.h",
    "content": "/**\n * @file sgr.h\n *\n * SGR (Select Graphic Rendition) attribute parsing and handling.\n */\n\n#ifndef GHOSTTY_VT_SGR_H\n#define GHOSTTY_VT_SGR_H\n\n/** @defgroup sgr SGR Parser\n *\n * SGR (Select Graphic Rendition) attribute parser.\n *\n * SGR sequences are the syntax used to set styling attributes such as\n * bold, italic, underline, and colors for text in terminal emulators.\n * For example, you may be familiar with sequences like `ESC[1;31m`. The\n * `1;31` is the SGR attribute list.\n *\n * The parser processes SGR parameters from CSI sequences (e.g., `ESC[1;31m`)\n * and returns individual text attributes like bold, italic, colors, etc.\n * It supports both semicolon (`;`) and colon (`:`) separators, possibly mixed,\n * and handles various color formats including 8-color, 16-color, 256-color,\n * X11 named colors, and RGB in multiple formats.\n *\n * ## Basic Usage\n *\n * 1. Create a parser instance with ghostty_sgr_new()\n * 2. Set SGR parameters with ghostty_sgr_set_params()\n * 3. Iterate through attributes using ghostty_sgr_next()\n * 4. Free the parser with ghostty_sgr_free() when done\n *\n * ## Example\n *\n * @code{.c}\n * #include <assert.h>\n * #include <stdio.h>\n * #include <ghostty/vt.h>\n *\n * int main() {\n *   // Create parser\n *   GhosttySgrParser parser;\n *   GhosttyResult result = ghostty_sgr_new(NULL, &parser);\n *   assert(result == GHOSTTY_SUCCESS);\n *\n *   // Parse \"bold, red foreground\" sequence: ESC[1;31m\n *   uint16_t params[] = {1, 31};\n *   result = ghostty_sgr_set_params(parser, params, NULL, 2);\n *   assert(result == GHOSTTY_SUCCESS);\n *\n *   // Iterate through attributes\n *   GhosttySgrAttribute attr;\n *   while (ghostty_sgr_next(parser, &attr)) {\n *     switch (attr.tag) {\n *       case GHOSTTY_SGR_ATTR_BOLD:\n *         printf(\"Bold enabled\\n\");\n *         break;\n *       case GHOSTTY_SGR_ATTR_FG_8:\n *         printf(\"Foreground color: %d\\n\", attr.value.fg_8);\n *         break;\n *       default:\n *         break;\n *     }\n *   }\n *\n *   // Cleanup\n *   ghostty_sgr_free(parser);\n *   return 0;\n * }\n * @endcode\n *\n * @{\n */\n\n#include <ghostty/vt/allocator.h>\n#include <ghostty/vt/color.h>\n#include <ghostty/vt/result.h>\n#include <stdbool.h>\n#include <stddef.h>\n#include <stdint.h>\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n/**\n * Opaque handle to an SGR parser instance.\n *\n * This handle represents an SGR (Select Graphic Rendition) parser that can\n * be used to parse SGR sequences and extract individual text attributes.\n *\n * @ingroup sgr\n */\ntypedef struct GhosttySgrParser* GhosttySgrParser;\n\n/**\n * SGR attribute tags.\n *\n * These values identify the type of an SGR attribute in a tagged union.\n * Use the tag to determine which field in the attribute value union to access.\n *\n * @ingroup sgr\n */\ntypedef enum {\n  GHOSTTY_SGR_ATTR_UNSET = 0,\n  GHOSTTY_SGR_ATTR_UNKNOWN = 1,\n  GHOSTTY_SGR_ATTR_BOLD = 2,\n  GHOSTTY_SGR_ATTR_RESET_BOLD = 3,\n  GHOSTTY_SGR_ATTR_ITALIC = 4,\n  GHOSTTY_SGR_ATTR_RESET_ITALIC = 5,\n  GHOSTTY_SGR_ATTR_FAINT = 6,\n  GHOSTTY_SGR_ATTR_UNDERLINE = 7,\n  GHOSTTY_SGR_ATTR_RESET_UNDERLINE = 8,\n  GHOSTTY_SGR_ATTR_UNDERLINE_COLOR = 9,\n  GHOSTTY_SGR_ATTR_UNDERLINE_COLOR_256 = 10,\n  GHOSTTY_SGR_ATTR_RESET_UNDERLINE_COLOR = 11,\n  GHOSTTY_SGR_ATTR_OVERLINE = 12,\n  GHOSTTY_SGR_ATTR_RESET_OVERLINE = 13,\n  GHOSTTY_SGR_ATTR_BLINK = 14,\n  GHOSTTY_SGR_ATTR_RESET_BLINK = 15,\n  GHOSTTY_SGR_ATTR_INVERSE = 16,\n  GHOSTTY_SGR_ATTR_RESET_INVERSE = 17,\n  GHOSTTY_SGR_ATTR_INVISIBLE = 18,\n  GHOSTTY_SGR_ATTR_RESET_INVISIBLE = 19,\n  GHOSTTY_SGR_ATTR_STRIKETHROUGH = 20,\n  GHOSTTY_SGR_ATTR_RESET_STRIKETHROUGH = 21,\n  GHOSTTY_SGR_ATTR_DIRECT_COLOR_FG = 22,\n  GHOSTTY_SGR_ATTR_DIRECT_COLOR_BG = 23,\n  GHOSTTY_SGR_ATTR_BG_8 = 24,\n  GHOSTTY_SGR_ATTR_FG_8 = 25,\n  GHOSTTY_SGR_ATTR_RESET_FG = 26,\n  GHOSTTY_SGR_ATTR_RESET_BG = 27,\n  GHOSTTY_SGR_ATTR_BRIGHT_BG_8 = 28,\n  GHOSTTY_SGR_ATTR_BRIGHT_FG_8 = 29,\n  GHOSTTY_SGR_ATTR_BG_256 = 30,\n  GHOSTTY_SGR_ATTR_FG_256 = 31,\n} GhosttySgrAttributeTag;\n\n/**\n * Underline style types.\n *\n * @ingroup sgr\n */\ntypedef enum {\n  GHOSTTY_SGR_UNDERLINE_NONE = 0,\n  GHOSTTY_SGR_UNDERLINE_SINGLE = 1,\n  GHOSTTY_SGR_UNDERLINE_DOUBLE = 2,\n  GHOSTTY_SGR_UNDERLINE_CURLY = 3,\n  GHOSTTY_SGR_UNDERLINE_DOTTED = 4,\n  GHOSTTY_SGR_UNDERLINE_DASHED = 5,\n} GhosttySgrUnderline;\n\n/**\n * Unknown SGR attribute data.\n *\n * Contains the full parameter list and the partial list where parsing\n * encountered an unknown or invalid sequence.\n *\n * @ingroup sgr\n */\ntypedef struct {\n  const uint16_t* full_ptr;\n  size_t full_len;\n  const uint16_t* partial_ptr;\n  size_t partial_len;\n} GhosttySgrUnknown;\n\n/**\n * SGR attribute value union.\n *\n * This union contains all possible attribute values. Use the tag field\n * to determine which union member is active. Attributes without associated\n * data (like bold, italic) don't use the union value.\n *\n * @ingroup sgr\n */\ntypedef union {\n  GhosttySgrUnknown unknown;\n  GhosttySgrUnderline underline;\n  GhosttyColorRgb underline_color;\n  GhosttyColorPaletteIndex underline_color_256;\n  GhosttyColorRgb direct_color_fg;\n  GhosttyColorRgb direct_color_bg;\n  GhosttyColorPaletteIndex bg_8;\n  GhosttyColorPaletteIndex fg_8;\n  GhosttyColorPaletteIndex bright_bg_8;\n  GhosttyColorPaletteIndex bright_fg_8;\n  GhosttyColorPaletteIndex bg_256;\n  GhosttyColorPaletteIndex fg_256;\n  uint64_t _padding[8];\n} GhosttySgrAttributeValue;\n\n/**\n * SGR attribute (tagged union).\n *\n * A complete SGR attribute with both its type tag and associated value.\n * Always check the tag field to determine which value union member is valid.\n *\n * Attributes without associated data (e.g., GHOSTTY_SGR_ATTR_BOLD) can be\n * identified by tag alone; the value union is not used for these and\n * the memory in the value field is undefined.\n *\n * @ingroup sgr\n */\ntypedef struct {\n  GhosttySgrAttributeTag tag;\n  GhosttySgrAttributeValue value;\n} GhosttySgrAttribute;\n\n/**\n * Create a new SGR parser instance.\n *\n * Creates a new SGR (Select Graphic Rendition) parser using the provided\n * allocator. The parser must be freed using ghostty_sgr_free() when\n * no longer needed.\n *\n * @param allocator Pointer to the allocator to use for memory management, or\n * NULL to use the default allocator\n * @param parser Pointer to store the created parser handle\n * @return GHOSTTY_SUCCESS on success, or an error code on failure\n *\n * @ingroup sgr\n */\nGhosttyResult ghostty_sgr_new(const GhosttyAllocator* allocator,\n                              GhosttySgrParser* parser);\n\n/**\n * Free an SGR parser instance.\n *\n * Releases all resources associated with the SGR parser. After this call,\n * the parser handle becomes invalid and must not be used. This includes\n * any attributes previously returned by ghostty_sgr_next().\n *\n * @param parser The parser handle to free (may be NULL)\n *\n * @ingroup sgr\n */\nvoid ghostty_sgr_free(GhosttySgrParser parser);\n\n/**\n * Reset an SGR parser instance to the beginning of the parameter list.\n *\n * Resets the parser's iteration state without clearing the parameters.\n * After calling this, ghostty_sgr_next() will start from the beginning\n * of the parameter list again.\n *\n * @param parser The parser handle to reset, must not be NULL\n *\n * @ingroup sgr\n */\nvoid ghostty_sgr_reset(GhosttySgrParser parser);\n\n/**\n * Set SGR parameters for parsing.\n *\n * Sets the SGR parameter list to parse. Parameters are the numeric values\n * from a CSI SGR sequence (e.g., for `ESC[1;31m`, params would be {1, 31}).\n *\n * The separators array optionally specifies the separator type for each\n * parameter position. Each byte should be either ';' for semicolon or ':'\n * for colon. This is needed for certain color formats that use colon\n * separators (e.g., `ESC[4:3m` for curly underline). Any invalid separator\n * values are treated as semicolons. The separators array must have the same\n * length as the params array, if it is not NULL.\n *\n * If separators is NULL, all parameters are assumed to be semicolon-separated.\n *\n * This function makes an internal copy of the parameter and separator data,\n * so the caller can safely free or modify the input arrays after this call.\n *\n * After calling this function, the parser is automatically reset and ready\n * to iterate from the beginning.\n *\n * @param parser The parser handle, must not be NULL\n * @param params Array of SGR parameter values\n * @param separators Optional array of separator characters (';' or ':'), or\n * NULL\n * @param len Number of parameters (and separators if provided)\n * @return GHOSTTY_SUCCESS on success, or an error code on failure\n *\n * @ingroup sgr\n */\nGhosttyResult ghostty_sgr_set_params(GhosttySgrParser parser,\n                                     const uint16_t* params,\n                                     const char* separators,\n                                     size_t len);\n\n/**\n * Get the next SGR attribute.\n *\n * Parses and returns the next attribute from the parameter list.\n * Call this function repeatedly until it returns false to process\n * all attributes in the sequence.\n *\n * @param parser The parser handle, must not be NULL\n * @param attr Pointer to store the next attribute\n * @return true if an attribute was returned, false if no more attributes\n *\n * @ingroup sgr\n */\nbool ghostty_sgr_next(GhosttySgrParser parser, GhosttySgrAttribute* attr);\n\n/**\n * Get the full parameter list from an unknown SGR attribute.\n *\n * This function retrieves the full parameter list that was provided to the\n * parser when an unknown attribute was encountered. Primarily useful in\n * WebAssembly environments where accessing struct fields directly is difficult.\n *\n * @param unknown The unknown attribute data\n * @param ptr Pointer to store the pointer to the parameter array (may be NULL)\n * @return The length of the full parameter array\n *\n * @ingroup sgr\n */\nsize_t ghostty_sgr_unknown_full(GhosttySgrUnknown unknown,\n                                const uint16_t** ptr);\n\n/**\n * Get the partial parameter list from an unknown SGR attribute.\n *\n * This function retrieves the partial parameter list where parsing stopped\n * when an unknown attribute was encountered. Primarily useful in WebAssembly\n * environments where accessing struct fields directly is difficult.\n *\n * @param unknown The unknown attribute data\n * @param ptr Pointer to store the pointer to the parameter array (may be NULL)\n * @return The length of the partial parameter array\n *\n * @ingroup sgr\n */\nsize_t ghostty_sgr_unknown_partial(GhosttySgrUnknown unknown,\n                                   const uint16_t** ptr);\n\n/**\n * Get the tag from an SGR attribute.\n *\n * This function extracts the tag that identifies which type of attribute\n * this is. Primarily useful in WebAssembly environments where accessing\n * struct fields directly is difficult.\n *\n * @param attr The SGR attribute\n * @return The attribute tag\n *\n * @ingroup sgr\n */\nGhosttySgrAttributeTag ghostty_sgr_attribute_tag(GhosttySgrAttribute attr);\n\n/**\n * Get the value from an SGR attribute.\n *\n * This function returns a pointer to the value union from an SGR attribute. Use\n * the tag to determine which field of the union is valid. Primarily useful in\n * WebAssembly environments where accessing struct fields directly is difficult.\n *\n * @param attr Pointer to the SGR attribute\n * @return Pointer to the attribute value union\n *\n * @ingroup sgr\n */\nGhosttySgrAttributeValue* ghostty_sgr_attribute_value(\n    GhosttySgrAttribute* attr);\n\n#ifdef __wasm__\n/**\n * Allocate memory for an SGR attribute (WebAssembly only).\n *\n * This is a convenience function for WebAssembly environments to allocate\n * memory for an SGR attribute structure that can be passed to ghostty_sgr_next.\n *\n * @return Pointer to the allocated attribute structure\n *\n * @ingroup wasm\n */\nGhosttySgrAttribute* ghostty_wasm_alloc_sgr_attribute(void);\n\n/**\n * Free memory for an SGR attribute (WebAssembly only).\n *\n * Frees memory allocated by ghostty_wasm_alloc_sgr_attribute.\n *\n * @param attr Pointer to the attribute structure to free\n *\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_sgr_attribute(GhosttySgrAttribute* attr);\n#endif\n\n#ifdef __cplusplus\n}\n#endif\n\n/** @} */\n\n#endif /* GHOSTTY_VT_SGR_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt/wasm.h",
    "content": "/**\n * @file wasm.h\n *\n * WebAssembly utility functions for libghostty-vt.\n */\n\n#ifndef GHOSTTY_VT_WASM_H\n#define GHOSTTY_VT_WASM_H\n\n#ifdef __wasm__\n\n#include <stddef.h>\n#include <stdint.h>\n\n/** @defgroup wasm WebAssembly Utilities\n *\n * Convenience functions for allocating various types in WebAssembly builds.\n * **These are only available the libghostty-vt wasm module.**\n *\n * Ghostty relies on pointers to various types for ABI compatibility, and\n * creating those pointers in Wasm can be tedious. These functions provide\n * a purely additive set of utilities that simplify memory management in\n * Wasm environments without changing the core C library API.\n *\n * @note These functions always use the default allocator. If you need\n * custom allocation strategies, you should allocate types manually using\n * your custom allocator. This is a very rare use case in the WebAssembly\n * world so these are optimized for simplicity.\n *\n * ## Example Usage\n *\n * Here's a simple example of using the Wasm utilities with the key encoder:\n *\n * @code\n * const { exports } = wasmInstance;\n * const view = new DataView(wasmMemory.buffer);\n *\n * // Create key encoder\n * const encoderPtr = exports.ghostty_wasm_alloc_opaque();\n * exports.ghostty_key_encoder_new(null, encoderPtr);\n * const encoder = view.getUint32(encoder, true);\n *\n * // Configure encoder with Kitty protocol flags\n * const flagsPtr = exports.ghostty_wasm_alloc_u8();\n * view.setUint8(flagsPtr, 0x1F);\n * exports.ghostty_key_encoder_setopt(encoder, 5, flagsPtr);\n *\n * // Allocate output buffer and size pointer\n * const bufferSize = 32;\n * const bufPtr = exports.ghostty_wasm_alloc_u8_array(bufferSize);\n * const writtenPtr = exports.ghostty_wasm_alloc_usize();\n *\n * // Encode the key event\n * exports.ghostty_key_encoder_encode(\n *     encoder, eventPtr, bufPtr, bufferSize, writtenPtr\n * );\n *\n * // Read encoded output\n * const bytesWritten = view.getUint32(writtenPtr, true);\n * const encoded = new Uint8Array(wasmMemory.buffer, bufPtr, bytesWritten);\n * @endcode\n *\n * @remark The code above is pretty ugly! This is the lowest level interface\n * to the libghostty-vt Wasm module. In practice, this should be wrapped\n * in a higher-level API that abstracts away all this.\n *\n * @{\n */\n\n/**\n * Allocate an opaque pointer. This can be used for any opaque pointer\n * types such as GhosttyKeyEncoder, GhosttyKeyEvent, etc.\n *\n * @return Pointer to allocated opaque pointer, or NULL if allocation failed\n * @ingroup wasm\n */\nvoid** ghostty_wasm_alloc_opaque(void);\n\n/**\n * Free an opaque pointer allocated by ghostty_wasm_alloc_opaque().\n *\n * @param ptr Pointer to free, or NULL (NULL is safely ignored)\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_opaque(void **ptr);\n\n/**\n * Allocate an array of uint8_t values.\n *\n * @param len Number of uint8_t elements to allocate\n * @return Pointer to allocated array, or NULL if allocation failed\n * @ingroup wasm\n */\nuint8_t* ghostty_wasm_alloc_u8_array(size_t len);\n\n/**\n * Free an array allocated by ghostty_wasm_alloc_u8_array().\n *\n * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)\n * @param len Length of the array (must match the length passed to alloc)\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_u8_array(uint8_t *ptr, size_t len);\n\n/**\n * Allocate an array of uint16_t values.\n *\n * @param len Number of uint16_t elements to allocate\n * @return Pointer to allocated array, or NULL if allocation failed\n * @ingroup wasm\n */\nuint16_t* ghostty_wasm_alloc_u16_array(size_t len);\n\n/**\n * Free an array allocated by ghostty_wasm_alloc_u16_array().\n *\n * @param ptr Pointer to the array to free, or NULL (NULL is safely ignored)\n * @param len Length of the array (must match the length passed to alloc)\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_u16_array(uint16_t *ptr, size_t len);\n\n/**\n * Allocate a single uint8_t value.\n *\n * @return Pointer to allocated uint8_t, or NULL if allocation failed\n * @ingroup wasm\n */\nuint8_t* ghostty_wasm_alloc_u8(void);\n\n/**\n * Free a uint8_t allocated by ghostty_wasm_alloc_u8().\n *\n * @param ptr Pointer to free, or NULL (NULL is safely ignored)\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_u8(uint8_t *ptr);\n\n/**\n * Allocate a single size_t value.\n *\n * @return Pointer to allocated size_t, or NULL if allocation failed\n * @ingroup wasm\n */\nsize_t* ghostty_wasm_alloc_usize(void);\n\n/**\n * Free a size_t allocated by ghostty_wasm_alloc_usize().\n *\n * @param ptr Pointer to free, or NULL (NULL is safely ignored)\n * @ingroup wasm\n */\nvoid ghostty_wasm_free_usize(size_t *ptr);\n\n/** @} */\n\n#endif /* __wasm__ */\n\n#endif /* GHOSTTY_VT_WASM_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty/vt.h",
    "content": "/**\n * @file vt.h\n *\n * libghostty-vt - Virtual terminal emulator library\n * \n * This library provides functionality for parsing and handling terminal\n * escape sequences as well as maintaining terminal state such as styles,\n * cursor position, screen, scrollback, and more.\n *\n * WARNING: This is an incomplete, work-in-progress API. It is not yet\n * stable and is definitely going to change. \n */\n\n/**\n * @mainpage libghostty-vt - Virtual Terminal Emulator Library\n *\n * libghostty-vt is a C library which implements a modern terminal emulator,\n * extracted from the [Ghostty](https://ghostty.org) terminal emulator.\n *\n * libghostty-vt contains the logic for handling the core parts of a terminal\n * emulator: parsing terminal escape sequences, maintaining terminal state,\n * encoding input events, etc. It can handle scrollback, line wrapping, \n * reflow on resize, and more.\n *\n * @warning This library is currently in development and the API is not yet stable.\n * Breaking changes are expected in future versions. Use with caution in production code.\n *\n * @section groups_sec API Reference\n *\n * The API is organized into the following groups:\n * - @ref key \"Key Encoding\" - Encode key events into terminal sequences\n * - @ref osc \"OSC Parser\" - Parse OSC (Operating System Command) sequences\n * - @ref sgr \"SGR Parser\" - Parse SGR (Select Graphic Rendition) sequences\n * - @ref paste \"Paste Utilities\" - Validate paste data safety\n * - @ref allocator \"Memory Management\" - Memory management and custom allocators\n * - @ref wasm \"WebAssembly Utilities\" - WebAssembly convenience functions\n *\n * @section examples_sec Examples\n *\n * Complete working examples:\n * - @ref c-vt/src/main.c - OSC parser example\n * - @ref c-vt-key-encode/src/main.c - Key encoding example\n * - @ref c-vt-paste/src/main.c - Paste safety check example\n * - @ref c-vt-sgr/src/main.c - SGR parser example\n *\n */\n\n/** @example c-vt/src/main.c\n * This example demonstrates how to use the OSC parser to parse an OSC sequence,\n * extract command information, and retrieve command-specific data like window titles.\n */\n\n/** @example c-vt-key-encode/src/main.c\n * This example demonstrates how to use the key encoder to convert key events\n * into terminal escape sequences using the Kitty keyboard protocol.\n */\n\n/** @example c-vt-paste/src/main.c\n * This example demonstrates how to use the paste utilities to check if\n * paste data is safe before sending it to the terminal.\n */\n\n/** @example c-vt-sgr/src/main.c\n * This example demonstrates how to use the SGR parser to parse terminal\n * styling sequences and extract text attributes like colors and underline styles.\n */\n\n#ifndef GHOSTTY_VT_H\n#define GHOSTTY_VT_H\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n#include <ghostty/vt/result.h>\n#include <ghostty/vt/allocator.h>\n#include <ghostty/vt/osc.h>\n#include <ghostty/vt/sgr.h>\n#include <ghostty/vt/key.h>\n#include <ghostty/vt/paste.h>\n#include <ghostty/vt/wasm.h>\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* GHOSTTY_VT_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/ghostty.h",
    "content": "// Ghostty embedding API. The documentation for the embedding API is\n// only within the Zig source files that define the implementations. This\n// isn't meant to be a general purpose embedding API (yet) so there hasn't\n// been documentation or example work beyond that.\n//\n// The only consumer of this API is the macOS app, but the API is built to\n// be more general purpose.\n#ifndef GHOSTTY_H\n#define GHOSTTY_H\n\n#ifdef __cplusplus\nextern \"C\" {\n#endif\n\n#include <stdbool.h>\n#include <stddef.h>\n#include <stdint.h>\n#include <sys/types.h>\n\n//-------------------------------------------------------------------\n// Macros\n\n#define GHOSTTY_SUCCESS 0\n\n//-------------------------------------------------------------------\n// Types\n\n// Opaque types\ntypedef void* ghostty_app_t;\ntypedef void* ghostty_config_t;\ntypedef void* ghostty_surface_t;\ntypedef void* ghostty_inspector_t;\n\n// All the types below are fully defined and must be kept in sync with\n// their Zig counterparts. Any changes to these types MUST have an associated\n// Zig change.\ntypedef enum {\n  GHOSTTY_PLATFORM_INVALID,\n  GHOSTTY_PLATFORM_MACOS,\n  GHOSTTY_PLATFORM_IOS,\n} ghostty_platform_e;\n\ntypedef enum {\n  GHOSTTY_CLIPBOARD_STANDARD,\n  GHOSTTY_CLIPBOARD_SELECTION,\n} ghostty_clipboard_e;\n\ntypedef struct {\n  const char *mime;\n  const char *data;\n} ghostty_clipboard_content_s;\n\ntypedef enum {\n  GHOSTTY_CLIPBOARD_REQUEST_PASTE,\n  GHOSTTY_CLIPBOARD_REQUEST_OSC_52_READ,\n  GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE,\n} ghostty_clipboard_request_e;\n\ntypedef enum {\n  GHOSTTY_MOUSE_RELEASE,\n  GHOSTTY_MOUSE_PRESS,\n} ghostty_input_mouse_state_e;\n\ntypedef enum {\n  GHOSTTY_MOUSE_UNKNOWN,\n  GHOSTTY_MOUSE_LEFT,\n  GHOSTTY_MOUSE_RIGHT,\n  GHOSTTY_MOUSE_MIDDLE,\n} ghostty_input_mouse_button_e;\n\ntypedef enum {\n  GHOSTTY_MOUSE_MOMENTUM_NONE,\n  GHOSTTY_MOUSE_MOMENTUM_BEGAN,\n  GHOSTTY_MOUSE_MOMENTUM_STATIONARY,\n  GHOSTTY_MOUSE_MOMENTUM_CHANGED,\n  GHOSTTY_MOUSE_MOMENTUM_ENDED,\n  GHOSTTY_MOUSE_MOMENTUM_CANCELLED,\n  GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN,\n} ghostty_input_mouse_momentum_e;\n\ntypedef enum {\n  GHOSTTY_COLOR_SCHEME_LIGHT = 0,\n  GHOSTTY_COLOR_SCHEME_DARK = 1,\n} ghostty_color_scheme_e;\n\n// This is a packed struct (see src/input/mouse.zig) but the C standard\n// afaik doesn't let us reliably define packed structs so we build it up\n// from scratch.\ntypedef int ghostty_input_scroll_mods_t;\n\ntypedef enum {\n  GHOSTTY_MODS_NONE = 0,\n  GHOSTTY_MODS_SHIFT = 1 << 0,\n  GHOSTTY_MODS_CTRL = 1 << 1,\n  GHOSTTY_MODS_ALT = 1 << 2,\n  GHOSTTY_MODS_SUPER = 1 << 3,\n  GHOSTTY_MODS_CAPS = 1 << 4,\n  GHOSTTY_MODS_NUM = 1 << 5,\n  GHOSTTY_MODS_SHIFT_RIGHT = 1 << 6,\n  GHOSTTY_MODS_CTRL_RIGHT = 1 << 7,\n  GHOSTTY_MODS_ALT_RIGHT = 1 << 8,\n  GHOSTTY_MODS_SUPER_RIGHT = 1 << 9,\n} ghostty_input_mods_e;\n\ntypedef enum {\n  GHOSTTY_BINDING_FLAGS_CONSUMED = 1 << 0,\n  GHOSTTY_BINDING_FLAGS_ALL = 1 << 1,\n  GHOSTTY_BINDING_FLAGS_GLOBAL = 1 << 2,\n  GHOSTTY_BINDING_FLAGS_PERFORMABLE = 1 << 3,\n} ghostty_binding_flags_e;\n\ntypedef enum {\n  GHOSTTY_ACTION_RELEASE,\n  GHOSTTY_ACTION_PRESS,\n  GHOSTTY_ACTION_REPEAT,\n} ghostty_input_action_e;\n\n// Based on: https://www.w3.org/TR/uievents-code/\ntypedef enum {\n  GHOSTTY_KEY_UNIDENTIFIED,\n\n  // \"Writing System Keys\" § 3.1.1\n  GHOSTTY_KEY_BACKQUOTE,\n  GHOSTTY_KEY_BACKSLASH,\n  GHOSTTY_KEY_BRACKET_LEFT,\n  GHOSTTY_KEY_BRACKET_RIGHT,\n  GHOSTTY_KEY_COMMA,\n  GHOSTTY_KEY_DIGIT_0,\n  GHOSTTY_KEY_DIGIT_1,\n  GHOSTTY_KEY_DIGIT_2,\n  GHOSTTY_KEY_DIGIT_3,\n  GHOSTTY_KEY_DIGIT_4,\n  GHOSTTY_KEY_DIGIT_5,\n  GHOSTTY_KEY_DIGIT_6,\n  GHOSTTY_KEY_DIGIT_7,\n  GHOSTTY_KEY_DIGIT_8,\n  GHOSTTY_KEY_DIGIT_9,\n  GHOSTTY_KEY_EQUAL,\n  GHOSTTY_KEY_INTL_BACKSLASH,\n  GHOSTTY_KEY_INTL_RO,\n  GHOSTTY_KEY_INTL_YEN,\n  GHOSTTY_KEY_A,\n  GHOSTTY_KEY_B,\n  GHOSTTY_KEY_C,\n  GHOSTTY_KEY_D,\n  GHOSTTY_KEY_E,\n  GHOSTTY_KEY_F,\n  GHOSTTY_KEY_G,\n  GHOSTTY_KEY_H,\n  GHOSTTY_KEY_I,\n  GHOSTTY_KEY_J,\n  GHOSTTY_KEY_K,\n  GHOSTTY_KEY_L,\n  GHOSTTY_KEY_M,\n  GHOSTTY_KEY_N,\n  GHOSTTY_KEY_O,\n  GHOSTTY_KEY_P,\n  GHOSTTY_KEY_Q,\n  GHOSTTY_KEY_R,\n  GHOSTTY_KEY_S,\n  GHOSTTY_KEY_T,\n  GHOSTTY_KEY_U,\n  GHOSTTY_KEY_V,\n  GHOSTTY_KEY_W,\n  GHOSTTY_KEY_X,\n  GHOSTTY_KEY_Y,\n  GHOSTTY_KEY_Z,\n  GHOSTTY_KEY_MINUS,\n  GHOSTTY_KEY_PERIOD,\n  GHOSTTY_KEY_QUOTE,\n  GHOSTTY_KEY_SEMICOLON,\n  GHOSTTY_KEY_SLASH,\n\n  // \"Functional Keys\" § 3.1.2\n  GHOSTTY_KEY_ALT_LEFT,\n  GHOSTTY_KEY_ALT_RIGHT,\n  GHOSTTY_KEY_BACKSPACE,\n  GHOSTTY_KEY_CAPS_LOCK,\n  GHOSTTY_KEY_CONTEXT_MENU,\n  GHOSTTY_KEY_CONTROL_LEFT,\n  GHOSTTY_KEY_CONTROL_RIGHT,\n  GHOSTTY_KEY_ENTER,\n  GHOSTTY_KEY_META_LEFT,\n  GHOSTTY_KEY_META_RIGHT,\n  GHOSTTY_KEY_SHIFT_LEFT,\n  GHOSTTY_KEY_SHIFT_RIGHT,\n  GHOSTTY_KEY_SPACE,\n  GHOSTTY_KEY_TAB,\n  GHOSTTY_KEY_CONVERT,\n  GHOSTTY_KEY_KANA_MODE,\n  GHOSTTY_KEY_NON_CONVERT,\n\n  // \"Control Pad Section\" § 3.2\n  GHOSTTY_KEY_DELETE,\n  GHOSTTY_KEY_END,\n  GHOSTTY_KEY_HELP,\n  GHOSTTY_KEY_HOME,\n  GHOSTTY_KEY_INSERT,\n  GHOSTTY_KEY_PAGE_DOWN,\n  GHOSTTY_KEY_PAGE_UP,\n\n  // \"Arrow Pad Section\" § 3.3\n  GHOSTTY_KEY_ARROW_DOWN,\n  GHOSTTY_KEY_ARROW_LEFT,\n  GHOSTTY_KEY_ARROW_RIGHT,\n  GHOSTTY_KEY_ARROW_UP,\n\n  // \"Numpad Section\" § 3.4\n  GHOSTTY_KEY_NUM_LOCK,\n  GHOSTTY_KEY_NUMPAD_0,\n  GHOSTTY_KEY_NUMPAD_1,\n  GHOSTTY_KEY_NUMPAD_2,\n  GHOSTTY_KEY_NUMPAD_3,\n  GHOSTTY_KEY_NUMPAD_4,\n  GHOSTTY_KEY_NUMPAD_5,\n  GHOSTTY_KEY_NUMPAD_6,\n  GHOSTTY_KEY_NUMPAD_7,\n  GHOSTTY_KEY_NUMPAD_8,\n  GHOSTTY_KEY_NUMPAD_9,\n  GHOSTTY_KEY_NUMPAD_ADD,\n  GHOSTTY_KEY_NUMPAD_BACKSPACE,\n  GHOSTTY_KEY_NUMPAD_CLEAR,\n  GHOSTTY_KEY_NUMPAD_CLEAR_ENTRY,\n  GHOSTTY_KEY_NUMPAD_COMMA,\n  GHOSTTY_KEY_NUMPAD_DECIMAL,\n  GHOSTTY_KEY_NUMPAD_DIVIDE,\n  GHOSTTY_KEY_NUMPAD_ENTER,\n  GHOSTTY_KEY_NUMPAD_EQUAL,\n  GHOSTTY_KEY_NUMPAD_MEMORY_ADD,\n  GHOSTTY_KEY_NUMPAD_MEMORY_CLEAR,\n  GHOSTTY_KEY_NUMPAD_MEMORY_RECALL,\n  GHOSTTY_KEY_NUMPAD_MEMORY_STORE,\n  GHOSTTY_KEY_NUMPAD_MEMORY_SUBTRACT,\n  GHOSTTY_KEY_NUMPAD_MULTIPLY,\n  GHOSTTY_KEY_NUMPAD_PAREN_LEFT,\n  GHOSTTY_KEY_NUMPAD_PAREN_RIGHT,\n  GHOSTTY_KEY_NUMPAD_SUBTRACT,\n  GHOSTTY_KEY_NUMPAD_SEPARATOR,\n  GHOSTTY_KEY_NUMPAD_UP,\n  GHOSTTY_KEY_NUMPAD_DOWN,\n  GHOSTTY_KEY_NUMPAD_RIGHT,\n  GHOSTTY_KEY_NUMPAD_LEFT,\n  GHOSTTY_KEY_NUMPAD_BEGIN,\n  GHOSTTY_KEY_NUMPAD_HOME,\n  GHOSTTY_KEY_NUMPAD_END,\n  GHOSTTY_KEY_NUMPAD_INSERT,\n  GHOSTTY_KEY_NUMPAD_DELETE,\n  GHOSTTY_KEY_NUMPAD_PAGE_UP,\n  GHOSTTY_KEY_NUMPAD_PAGE_DOWN,\n\n  // \"Function Section\" § 3.5\n  GHOSTTY_KEY_ESCAPE,\n  GHOSTTY_KEY_F1,\n  GHOSTTY_KEY_F2,\n  GHOSTTY_KEY_F3,\n  GHOSTTY_KEY_F4,\n  GHOSTTY_KEY_F5,\n  GHOSTTY_KEY_F6,\n  GHOSTTY_KEY_F7,\n  GHOSTTY_KEY_F8,\n  GHOSTTY_KEY_F9,\n  GHOSTTY_KEY_F10,\n  GHOSTTY_KEY_F11,\n  GHOSTTY_KEY_F12,\n  GHOSTTY_KEY_F13,\n  GHOSTTY_KEY_F14,\n  GHOSTTY_KEY_F15,\n  GHOSTTY_KEY_F16,\n  GHOSTTY_KEY_F17,\n  GHOSTTY_KEY_F18,\n  GHOSTTY_KEY_F19,\n  GHOSTTY_KEY_F20,\n  GHOSTTY_KEY_F21,\n  GHOSTTY_KEY_F22,\n  GHOSTTY_KEY_F23,\n  GHOSTTY_KEY_F24,\n  GHOSTTY_KEY_F25,\n  GHOSTTY_KEY_FN,\n  GHOSTTY_KEY_FN_LOCK,\n  GHOSTTY_KEY_PRINT_SCREEN,\n  GHOSTTY_KEY_SCROLL_LOCK,\n  GHOSTTY_KEY_PAUSE,\n\n  // \"Media Keys\" § 3.6\n  GHOSTTY_KEY_BROWSER_BACK,\n  GHOSTTY_KEY_BROWSER_FAVORITES,\n  GHOSTTY_KEY_BROWSER_FORWARD,\n  GHOSTTY_KEY_BROWSER_HOME,\n  GHOSTTY_KEY_BROWSER_REFRESH,\n  GHOSTTY_KEY_BROWSER_SEARCH,\n  GHOSTTY_KEY_BROWSER_STOP,\n  GHOSTTY_KEY_EJECT,\n  GHOSTTY_KEY_LAUNCH_APP_1,\n  GHOSTTY_KEY_LAUNCH_APP_2,\n  GHOSTTY_KEY_LAUNCH_MAIL,\n  GHOSTTY_KEY_MEDIA_PLAY_PAUSE,\n  GHOSTTY_KEY_MEDIA_SELECT,\n  GHOSTTY_KEY_MEDIA_STOP,\n  GHOSTTY_KEY_MEDIA_TRACK_NEXT,\n  GHOSTTY_KEY_MEDIA_TRACK_PREVIOUS,\n  GHOSTTY_KEY_POWER,\n  GHOSTTY_KEY_SLEEP,\n  GHOSTTY_KEY_AUDIO_VOLUME_DOWN,\n  GHOSTTY_KEY_AUDIO_VOLUME_MUTE,\n  GHOSTTY_KEY_AUDIO_VOLUME_UP,\n  GHOSTTY_KEY_WAKE_UP,\n\n  // \"Legacy, Non-standard, and Special Keys\" § 3.7\n  GHOSTTY_KEY_COPY,\n  GHOSTTY_KEY_CUT,\n  GHOSTTY_KEY_PASTE,\n} ghostty_input_key_e;\n\ntypedef struct {\n  ghostty_input_action_e action;\n  ghostty_input_mods_e mods;\n  ghostty_input_mods_e consumed_mods;\n  uint32_t keycode;\n  const char* text;\n  uint32_t unshifted_codepoint;\n  bool composing;\n} ghostty_input_key_s;\n\ntypedef enum {\n  GHOSTTY_TRIGGER_PHYSICAL,\n  GHOSTTY_TRIGGER_UNICODE,\n  GHOSTTY_TRIGGER_CATCH_ALL,\n} ghostty_input_trigger_tag_e;\n\ntypedef union {\n  ghostty_input_key_e translated;\n  ghostty_input_key_e physical;\n  uint32_t unicode;\n  // catch_all has no payload\n} ghostty_input_trigger_key_u;\n\ntypedef struct {\n  ghostty_input_trigger_tag_e tag;\n  ghostty_input_trigger_key_u key;\n  ghostty_input_mods_e mods;\n} ghostty_input_trigger_s;\n\ntypedef struct {\n  const char* action_key;\n  const char* action;\n  const char* title;\n  const char* description;\n} ghostty_command_s;\n\ntypedef enum {\n  GHOSTTY_BUILD_MODE_DEBUG,\n  GHOSTTY_BUILD_MODE_RELEASE_SAFE,\n  GHOSTTY_BUILD_MODE_RELEASE_FAST,\n  GHOSTTY_BUILD_MODE_RELEASE_SMALL,\n} ghostty_build_mode_e;\n\ntypedef struct {\n  ghostty_build_mode_e build_mode;\n  const char* version;\n  uintptr_t version_len;\n} ghostty_info_s;\n\ntypedef struct {\n  const char* message;\n} ghostty_diagnostic_s;\n\ntypedef struct {\n  const char* ptr;\n  uintptr_t len;\n  bool sentinel;\n} ghostty_string_s;\n\ntypedef struct {\n  double tl_px_x;\n  double tl_px_y;\n  uint32_t offset_start;\n  uint32_t offset_len;\n  const char* text;\n  uintptr_t text_len;\n} ghostty_text_s;\n\ntypedef enum {\n  GHOSTTY_POINT_ACTIVE,\n  GHOSTTY_POINT_VIEWPORT,\n  GHOSTTY_POINT_SCREEN,\n  GHOSTTY_POINT_SURFACE,\n} ghostty_point_tag_e;\n\ntypedef enum {\n  GHOSTTY_POINT_COORD_EXACT,\n  GHOSTTY_POINT_COORD_TOP_LEFT,\n  GHOSTTY_POINT_COORD_BOTTOM_RIGHT,\n} ghostty_point_coord_e;\n\ntypedef struct {\n  ghostty_point_tag_e tag;\n  ghostty_point_coord_e coord;\n  uint32_t x;\n  uint32_t y;\n} ghostty_point_s;\n\ntypedef struct {\n  ghostty_point_s top_left;\n  ghostty_point_s bottom_right;\n  bool rectangle;\n} ghostty_selection_s;\n\ntypedef struct {\n  const char* key;\n  const char* value;\n} ghostty_env_var_s;\n\ntypedef struct {\n  void* nsview;\n} ghostty_platform_macos_s;\n\ntypedef struct {\n  void* uiview;\n} ghostty_platform_ios_s;\n\ntypedef union {\n  ghostty_platform_macos_s macos;\n  ghostty_platform_ios_s ios;\n} ghostty_platform_u;\n\ntypedef enum {\n  GHOSTTY_SURFACE_CONTEXT_WINDOW = 0,\n  GHOSTTY_SURFACE_CONTEXT_TAB = 1,\n  GHOSTTY_SURFACE_CONTEXT_SPLIT = 2,\n} ghostty_surface_context_e;\n\ntypedef struct {\n  ghostty_platform_e platform_tag;\n  ghostty_platform_u platform;\n  void* userdata;\n  double scale_factor;\n  float font_size;\n  const char* working_directory;\n  const char* command;\n  ghostty_env_var_s* env_vars;\n  size_t env_var_count;\n  const char* initial_input;\n  bool wait_after_command;\n  ghostty_surface_context_e context;\n} ghostty_surface_config_s;\n\ntypedef struct {\n  uint16_t columns;\n  uint16_t rows;\n  uint32_t width_px;\n  uint32_t height_px;\n  uint32_t cell_width_px;\n  uint32_t cell_height_px;\n} ghostty_surface_size_s;\n\n// Config types\n\n// config.Color\ntypedef struct {\n  uint8_t r;\n  uint8_t g;\n  uint8_t b;\n} ghostty_config_color_s;\n\n// config.ColorList\ntypedef struct {\n  const ghostty_config_color_s* colors;\n  size_t len;\n} ghostty_config_color_list_s;\n\n// config.RepeatableCommand\ntypedef struct {\n  const ghostty_command_s* commands;\n  size_t len;\n} ghostty_config_command_list_s;\n\n// config.Palette\ntypedef struct {\n  ghostty_config_color_s colors[256];\n} ghostty_config_palette_s;\n\n// config.QuickTerminalSize\ntypedef enum {\n  GHOSTTY_QUICK_TERMINAL_SIZE_NONE,\n  GHOSTTY_QUICK_TERMINAL_SIZE_PERCENTAGE,\n  GHOSTTY_QUICK_TERMINAL_SIZE_PIXELS,\n} ghostty_quick_terminal_size_tag_e;\n\ntypedef union {\n  float percentage;\n  uint32_t pixels;\n} ghostty_quick_terminal_size_value_u;\n\ntypedef struct {\n  ghostty_quick_terminal_size_tag_e tag;\n  ghostty_quick_terminal_size_value_u value;\n} ghostty_quick_terminal_size_s;\n\ntypedef struct {\n  ghostty_quick_terminal_size_s primary;\n  ghostty_quick_terminal_size_s secondary;\n} ghostty_config_quick_terminal_size_s;\n\n// apprt.Target.Key\ntypedef enum {\n  GHOSTTY_TARGET_APP,\n  GHOSTTY_TARGET_SURFACE,\n} ghostty_target_tag_e;\n\ntypedef union {\n  ghostty_surface_t surface;\n} ghostty_target_u;\n\ntypedef struct {\n  ghostty_target_tag_e tag;\n  ghostty_target_u target;\n} ghostty_target_s;\n\n// apprt.action.SplitDirection\ntypedef enum {\n  GHOSTTY_SPLIT_DIRECTION_RIGHT,\n  GHOSTTY_SPLIT_DIRECTION_DOWN,\n  GHOSTTY_SPLIT_DIRECTION_LEFT,\n  GHOSTTY_SPLIT_DIRECTION_UP,\n} ghostty_action_split_direction_e;\n\n// apprt.action.GotoSplit\ntypedef enum {\n  GHOSTTY_GOTO_SPLIT_PREVIOUS,\n  GHOSTTY_GOTO_SPLIT_NEXT,\n  GHOSTTY_GOTO_SPLIT_UP,\n  GHOSTTY_GOTO_SPLIT_LEFT,\n  GHOSTTY_GOTO_SPLIT_DOWN,\n  GHOSTTY_GOTO_SPLIT_RIGHT,\n} ghostty_action_goto_split_e;\n\n// apprt.action.GotoWindow\ntypedef enum {\n  GHOSTTY_GOTO_WINDOW_PREVIOUS,\n  GHOSTTY_GOTO_WINDOW_NEXT,\n} ghostty_action_goto_window_e;\n\n// apprt.action.ResizeSplit.Direction\ntypedef enum {\n  GHOSTTY_RESIZE_SPLIT_UP,\n  GHOSTTY_RESIZE_SPLIT_DOWN,\n  GHOSTTY_RESIZE_SPLIT_LEFT,\n  GHOSTTY_RESIZE_SPLIT_RIGHT,\n} ghostty_action_resize_split_direction_e;\n\n// apprt.action.ResizeSplit\ntypedef struct {\n  uint16_t amount;\n  ghostty_action_resize_split_direction_e direction;\n} ghostty_action_resize_split_s;\n\n// apprt.action.MoveTab\ntypedef struct {\n  ssize_t amount;\n} ghostty_action_move_tab_s;\n\n// apprt.action.GotoTab\ntypedef enum {\n  GHOSTTY_GOTO_TAB_PREVIOUS = -1,\n  GHOSTTY_GOTO_TAB_NEXT = -2,\n  GHOSTTY_GOTO_TAB_LAST = -3,\n} ghostty_action_goto_tab_e;\n\n// apprt.action.Fullscreen\ntypedef enum {\n  GHOSTTY_FULLSCREEN_NATIVE,\n  GHOSTTY_FULLSCREEN_NON_NATIVE,\n  GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU,\n  GHOSTTY_FULLSCREEN_NON_NATIVE_PADDED_NOTCH,\n} ghostty_action_fullscreen_e;\n\n// apprt.action.FloatWindow\ntypedef enum {\n  GHOSTTY_FLOAT_WINDOW_ON,\n  GHOSTTY_FLOAT_WINDOW_OFF,\n  GHOSTTY_FLOAT_WINDOW_TOGGLE,\n} ghostty_action_float_window_e;\n\n// apprt.action.SecureInput\ntypedef enum {\n  GHOSTTY_SECURE_INPUT_ON,\n  GHOSTTY_SECURE_INPUT_OFF,\n  GHOSTTY_SECURE_INPUT_TOGGLE,\n} ghostty_action_secure_input_e;\n\n// apprt.action.Inspector\ntypedef enum {\n  GHOSTTY_INSPECTOR_TOGGLE,\n  GHOSTTY_INSPECTOR_SHOW,\n  GHOSTTY_INSPECTOR_HIDE,\n} ghostty_action_inspector_e;\n\n// apprt.action.QuitTimer\ntypedef enum {\n  GHOSTTY_QUIT_TIMER_START,\n  GHOSTTY_QUIT_TIMER_STOP,\n} ghostty_action_quit_timer_e;\n\n// apprt.action.Readonly\ntypedef enum {\n  GHOSTTY_READONLY_OFF,\n  GHOSTTY_READONLY_ON,\n} ghostty_action_readonly_e;\n\n// apprt.action.DesktopNotification.C\ntypedef struct {\n  const char* title;\n  const char* body;\n} ghostty_action_desktop_notification_s;\n\n// apprt.action.SetTitle.C\ntypedef struct {\n  const char* title;\n} ghostty_action_set_title_s;\n\n// apprt.action.PromptTitle\ntypedef enum {\n  GHOSTTY_PROMPT_TITLE_SURFACE,\n  GHOSTTY_PROMPT_TITLE_TAB,\n} ghostty_action_prompt_title_e;\n\n// apprt.action.Pwd.C\ntypedef struct {\n  const char* pwd;\n} ghostty_action_pwd_s;\n\n// terminal.MouseShape\ntypedef enum {\n  GHOSTTY_MOUSE_SHAPE_DEFAULT,\n  GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU,\n  GHOSTTY_MOUSE_SHAPE_HELP,\n  GHOSTTY_MOUSE_SHAPE_POINTER,\n  GHOSTTY_MOUSE_SHAPE_PROGRESS,\n  GHOSTTY_MOUSE_SHAPE_WAIT,\n  GHOSTTY_MOUSE_SHAPE_CELL,\n  GHOSTTY_MOUSE_SHAPE_CROSSHAIR,\n  GHOSTTY_MOUSE_SHAPE_TEXT,\n  GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT,\n  GHOSTTY_MOUSE_SHAPE_ALIAS,\n  GHOSTTY_MOUSE_SHAPE_COPY,\n  GHOSTTY_MOUSE_SHAPE_MOVE,\n  GHOSTTY_MOUSE_SHAPE_NO_DROP,\n  GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED,\n  GHOSTTY_MOUSE_SHAPE_GRAB,\n  GHOSTTY_MOUSE_SHAPE_GRABBING,\n  GHOSTTY_MOUSE_SHAPE_ALL_SCROLL,\n  GHOSTTY_MOUSE_SHAPE_COL_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_ROW_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_N_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_E_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_S_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_W_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_NE_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_NW_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_SE_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_SW_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_EW_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_NS_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_NESW_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE,\n  GHOSTTY_MOUSE_SHAPE_ZOOM_IN,\n  GHOSTTY_MOUSE_SHAPE_ZOOM_OUT,\n} ghostty_action_mouse_shape_e;\n\n// apprt.action.MouseVisibility\ntypedef enum {\n  GHOSTTY_MOUSE_VISIBLE,\n  GHOSTTY_MOUSE_HIDDEN,\n} ghostty_action_mouse_visibility_e;\n\n// apprt.action.MouseOverLink\ntypedef struct {\n  const char* url;\n  size_t len;\n} ghostty_action_mouse_over_link_s;\n\n// apprt.action.SizeLimit\ntypedef struct {\n  uint32_t min_width;\n  uint32_t min_height;\n  uint32_t max_width;\n  uint32_t max_height;\n} ghostty_action_size_limit_s;\n\n// apprt.action.InitialSize\ntypedef struct {\n  uint32_t width;\n  uint32_t height;\n} ghostty_action_initial_size_s;\n\n// apprt.action.CellSize\ntypedef struct {\n  uint32_t width;\n  uint32_t height;\n} ghostty_action_cell_size_s;\n\n// renderer.Health\ntypedef enum {\n  GHOSTTY_RENDERER_HEALTH_OK,\n  GHOSTTY_RENDERER_HEALTH_UNHEALTHY,\n} ghostty_action_renderer_health_e;\n\n// apprt.action.KeySequence\ntypedef struct {\n  bool active;\n  ghostty_input_trigger_s trigger;\n} ghostty_action_key_sequence_s;\n\n// apprt.action.KeyTable.Tag\ntypedef enum {\n  GHOSTTY_KEY_TABLE_ACTIVATE,\n  GHOSTTY_KEY_TABLE_DEACTIVATE,\n  GHOSTTY_KEY_TABLE_DEACTIVATE_ALL,\n} ghostty_action_key_table_tag_e;\n\n// apprt.action.KeyTable.CValue\ntypedef union {\n  struct {\n    const char *name;\n    size_t len;\n  } activate;\n} ghostty_action_key_table_u;\n\n// apprt.action.KeyTable.C\ntypedef struct {\n  ghostty_action_key_table_tag_e tag;\n  ghostty_action_key_table_u value;\n} ghostty_action_key_table_s;\n\n// apprt.action.ColorKind\ntypedef enum {\n  GHOSTTY_ACTION_COLOR_KIND_FOREGROUND = -1,\n  GHOSTTY_ACTION_COLOR_KIND_BACKGROUND = -2,\n  GHOSTTY_ACTION_COLOR_KIND_CURSOR = -3,\n} ghostty_action_color_kind_e;\n\n// apprt.action.ColorChange\ntypedef struct {\n  ghostty_action_color_kind_e kind;\n  uint8_t r;\n  uint8_t g;\n  uint8_t b;\n} ghostty_action_color_change_s;\n\n// apprt.action.ConfigChange\ntypedef struct {\n  ghostty_config_t config;\n} ghostty_action_config_change_s;\n\n// apprt.action.ReloadConfig\ntypedef struct {\n  bool soft;\n} ghostty_action_reload_config_s;\n\n// apprt.action.OpenUrlKind\ntypedef enum {\n  GHOSTTY_ACTION_OPEN_URL_KIND_UNKNOWN,\n  GHOSTTY_ACTION_OPEN_URL_KIND_TEXT,\n  GHOSTTY_ACTION_OPEN_URL_KIND_HTML,\n} ghostty_action_open_url_kind_e;\n\n// apprt.action.OpenUrl.C\ntypedef struct {\n  ghostty_action_open_url_kind_e kind;\n  const char* url;\n  uintptr_t len;\n} ghostty_action_open_url_s;\n\n// apprt.action.CloseTabMode\ntypedef enum {\n  GHOSTTY_ACTION_CLOSE_TAB_MODE_THIS,\n  GHOSTTY_ACTION_CLOSE_TAB_MODE_OTHER,\n  GHOSTTY_ACTION_CLOSE_TAB_MODE_RIGHT,\n} ghostty_action_close_tab_mode_e;\n\n// apprt.surface.Message.ChildExited\ntypedef struct {\n  uint32_t exit_code;\n  uint64_t timetime_ms;\n} ghostty_surface_message_childexited_s;\n\n// terminal.osc.Command.ProgressReport.State\ntypedef enum {\n  GHOSTTY_PROGRESS_STATE_REMOVE,\n  GHOSTTY_PROGRESS_STATE_SET,\n  GHOSTTY_PROGRESS_STATE_ERROR,\n  GHOSTTY_PROGRESS_STATE_INDETERMINATE,\n  GHOSTTY_PROGRESS_STATE_PAUSE,\n} ghostty_action_progress_report_state_e;\n\n// terminal.osc.Command.ProgressReport.C\ntypedef struct {\n  ghostty_action_progress_report_state_e state;\n  // -1 if no progress was reported, otherwise 0-100 indicating percent\n  // completeness.\n  int8_t progress;\n} ghostty_action_progress_report_s;\n\n// apprt.action.CommandFinished.C\ntypedef struct {\n  // -1 if no exit code was reported, otherwise 0-255\n  int16_t exit_code;\n  // number of nanoseconds that command was running for\n  uint64_t duration;\n} ghostty_action_command_finished_s;\n\n// apprt.action.StartSearch.C\ntypedef struct {\n  const char* needle;\n} ghostty_action_start_search_s;\n\n// apprt.action.SearchTotal\ntypedef struct {\n  ssize_t total;\n} ghostty_action_search_total_s;\n\n// apprt.action.SearchSelected\ntypedef struct {\n  ssize_t selected;\n} ghostty_action_search_selected_s;\n\n// terminal.Scrollbar\ntypedef struct {\n  uint64_t total;\n  uint64_t offset;\n  uint64_t len;\n} ghostty_action_scrollbar_s;\n\n// apprt.Action.Key\ntypedef enum {\n  GHOSTTY_ACTION_QUIT,\n  GHOSTTY_ACTION_NEW_WINDOW,\n  GHOSTTY_ACTION_NEW_TAB,\n  GHOSTTY_ACTION_CLOSE_TAB,\n  GHOSTTY_ACTION_NEW_SPLIT,\n  GHOSTTY_ACTION_CLOSE_ALL_WINDOWS,\n  GHOSTTY_ACTION_TOGGLE_MAXIMIZE,\n  GHOSTTY_ACTION_TOGGLE_FULLSCREEN,\n  GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW,\n  GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS,\n  GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,\n  GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,\n  GHOSTTY_ACTION_TOGGLE_VISIBILITY,\n  GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,\n  GHOSTTY_ACTION_MOVE_TAB,\n  GHOSTTY_ACTION_GOTO_TAB,\n  GHOSTTY_ACTION_GOTO_SPLIT,\n  GHOSTTY_ACTION_GOTO_WINDOW,\n  GHOSTTY_ACTION_RESIZE_SPLIT,\n  GHOSTTY_ACTION_EQUALIZE_SPLITS,\n  GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM,\n  GHOSTTY_ACTION_PRESENT_TERMINAL,\n  GHOSTTY_ACTION_SIZE_LIMIT,\n  GHOSTTY_ACTION_RESET_WINDOW_SIZE,\n  GHOSTTY_ACTION_INITIAL_SIZE,\n  GHOSTTY_ACTION_CELL_SIZE,\n  GHOSTTY_ACTION_SCROLLBAR,\n  GHOSTTY_ACTION_RENDER,\n  GHOSTTY_ACTION_INSPECTOR,\n  GHOSTTY_ACTION_SHOW_GTK_INSPECTOR,\n  GHOSTTY_ACTION_RENDER_INSPECTOR,\n  GHOSTTY_ACTION_DESKTOP_NOTIFICATION,\n  GHOSTTY_ACTION_SET_TITLE,\n  GHOSTTY_ACTION_PROMPT_TITLE,\n  GHOSTTY_ACTION_PWD,\n  GHOSTTY_ACTION_MOUSE_SHAPE,\n  GHOSTTY_ACTION_MOUSE_VISIBILITY,\n  GHOSTTY_ACTION_MOUSE_OVER_LINK,\n  GHOSTTY_ACTION_RENDERER_HEALTH,\n  GHOSTTY_ACTION_OPEN_CONFIG,\n  GHOSTTY_ACTION_QUIT_TIMER,\n  GHOSTTY_ACTION_FLOAT_WINDOW,\n  GHOSTTY_ACTION_SECURE_INPUT,\n  GHOSTTY_ACTION_KEY_SEQUENCE,\n  GHOSTTY_ACTION_KEY_TABLE,\n  GHOSTTY_ACTION_COLOR_CHANGE,\n  GHOSTTY_ACTION_RELOAD_CONFIG,\n  GHOSTTY_ACTION_CONFIG_CHANGE,\n  GHOSTTY_ACTION_CLOSE_WINDOW,\n  GHOSTTY_ACTION_RING_BELL,\n  GHOSTTY_ACTION_UNDO,\n  GHOSTTY_ACTION_REDO,\n  GHOSTTY_ACTION_CHECK_FOR_UPDATES,\n  GHOSTTY_ACTION_OPEN_URL,\n  GHOSTTY_ACTION_SHOW_CHILD_EXITED,\n  GHOSTTY_ACTION_PROGRESS_REPORT,\n  GHOSTTY_ACTION_SHOW_ON_SCREEN_KEYBOARD,\n  GHOSTTY_ACTION_COMMAND_FINISHED,\n  GHOSTTY_ACTION_START_SEARCH,\n  GHOSTTY_ACTION_END_SEARCH,\n  GHOSTTY_ACTION_SEARCH_TOTAL,\n  GHOSTTY_ACTION_SEARCH_SELECTED,\n  GHOSTTY_ACTION_READONLY,\n} ghostty_action_tag_e;\n\ntypedef union {\n  ghostty_action_split_direction_e new_split;\n  ghostty_action_fullscreen_e toggle_fullscreen;\n  ghostty_action_move_tab_s move_tab;\n  ghostty_action_goto_tab_e goto_tab;\n  ghostty_action_goto_split_e goto_split;\n  ghostty_action_goto_window_e goto_window;\n  ghostty_action_resize_split_s resize_split;\n  ghostty_action_size_limit_s size_limit;\n  ghostty_action_initial_size_s initial_size;\n  ghostty_action_cell_size_s cell_size;\n  ghostty_action_scrollbar_s scrollbar;\n  ghostty_action_inspector_e inspector;\n  ghostty_action_desktop_notification_s desktop_notification;\n  ghostty_action_set_title_s set_title;\n  ghostty_action_prompt_title_e prompt_title;\n  ghostty_action_pwd_s pwd;\n  ghostty_action_mouse_shape_e mouse_shape;\n  ghostty_action_mouse_visibility_e mouse_visibility;\n  ghostty_action_mouse_over_link_s mouse_over_link;\n  ghostty_action_renderer_health_e renderer_health;\n  ghostty_action_quit_timer_e quit_timer;\n  ghostty_action_float_window_e float_window;\n  ghostty_action_secure_input_e secure_input;\n  ghostty_action_key_sequence_s key_sequence;\n  ghostty_action_key_table_s key_table;\n  ghostty_action_color_change_s color_change;\n  ghostty_action_reload_config_s reload_config;\n  ghostty_action_config_change_s config_change;\n  ghostty_action_open_url_s open_url;\n  ghostty_action_close_tab_mode_e close_tab_mode;\n  ghostty_surface_message_childexited_s child_exited;\n  ghostty_action_progress_report_s progress_report;\n  ghostty_action_command_finished_s command_finished;\n  ghostty_action_start_search_s start_search;\n  ghostty_action_search_total_s search_total;\n  ghostty_action_search_selected_s search_selected;\n  ghostty_action_readonly_e readonly;\n} ghostty_action_u;\n\ntypedef struct {\n  ghostty_action_tag_e tag;\n  ghostty_action_u action;\n} ghostty_action_s;\n\ntypedef void (*ghostty_runtime_wakeup_cb)(void*);\ntypedef void (*ghostty_runtime_read_clipboard_cb)(void*,\n                                                  ghostty_clipboard_e,\n                                                  void*);\ntypedef void (*ghostty_runtime_confirm_read_clipboard_cb)(\n    void*,\n    const char*,\n    void*,\n    ghostty_clipboard_request_e);\ntypedef void (*ghostty_runtime_write_clipboard_cb)(void*,\n                                                   ghostty_clipboard_e,\n                                                   const ghostty_clipboard_content_s*,\n                                                   size_t,\n                                                   bool);\ntypedef void (*ghostty_runtime_close_surface_cb)(void*, bool);\ntypedef bool (*ghostty_runtime_action_cb)(ghostty_app_t,\n                                          ghostty_target_s,\n                                          ghostty_action_s);\n\ntypedef struct {\n  void* userdata;\n  bool supports_selection_clipboard;\n  ghostty_runtime_wakeup_cb wakeup_cb;\n  ghostty_runtime_action_cb action_cb;\n  ghostty_runtime_read_clipboard_cb read_clipboard_cb;\n  ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb;\n  ghostty_runtime_write_clipboard_cb write_clipboard_cb;\n  ghostty_runtime_close_surface_cb close_surface_cb;\n} ghostty_runtime_config_s;\n\n// apprt.ipc.Target.Key\ntypedef enum {\n  GHOSTTY_IPC_TARGET_CLASS,\n  GHOSTTY_IPC_TARGET_DETECT,\n} ghostty_ipc_target_tag_e;\n\ntypedef union {\n  char *klass;\n} ghostty_ipc_target_u;\n\ntypedef struct {\n  ghostty_ipc_target_tag_e tag;\n  ghostty_ipc_target_u target;\n} chostty_ipc_target_s;\n\n// apprt.ipc.Action.NewWindow\ntypedef struct {\n  // This should be a null terminated list of strings.\n  const char **arguments;\n} ghostty_ipc_action_new_window_s;\n\ntypedef union {\n  ghostty_ipc_action_new_window_s new_window;\n} ghostty_ipc_action_u;\n\n// apprt.ipc.Action.Key\ntypedef enum {\n  GHOSTTY_IPC_ACTION_NEW_WINDOW,\n} ghostty_ipc_action_tag_e;\n\n//-------------------------------------------------------------------\n// Published API\n\nint ghostty_init(uintptr_t, char**);\nvoid ghostty_cli_try_action(void);\nghostty_info_s ghostty_info(void);\nconst char* ghostty_translate(const char*);\nvoid ghostty_string_free(ghostty_string_s);\n\nghostty_config_t ghostty_config_new();\nvoid ghostty_config_free(ghostty_config_t);\nghostty_config_t ghostty_config_clone(ghostty_config_t);\nvoid ghostty_config_load_cli_args(ghostty_config_t);\nvoid ghostty_config_load_default_files(ghostty_config_t);\nvoid ghostty_config_load_recursive_files(ghostty_config_t);\nvoid ghostty_config_finalize(ghostty_config_t);\nbool ghostty_config_get(ghostty_config_t, void*, const char*, uintptr_t);\nghostty_input_trigger_s ghostty_config_trigger(ghostty_config_t,\n                                               const char*,\n                                               uintptr_t);\nuint32_t ghostty_config_diagnostics_count(ghostty_config_t);\nghostty_diagnostic_s ghostty_config_get_diagnostic(ghostty_config_t, uint32_t);\nghostty_string_s ghostty_config_open_path(void);\n\nghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*,\n                              ghostty_config_t);\nvoid ghostty_app_free(ghostty_app_t);\nvoid ghostty_app_tick(ghostty_app_t);\nvoid* ghostty_app_userdata(ghostty_app_t);\nvoid ghostty_app_set_focus(ghostty_app_t, bool);\nbool ghostty_app_key(ghostty_app_t, ghostty_input_key_s);\nbool ghostty_app_key_is_binding(ghostty_app_t, ghostty_input_key_s);\nvoid ghostty_app_keyboard_changed(ghostty_app_t);\nvoid ghostty_app_open_config(ghostty_app_t);\nvoid ghostty_app_update_config(ghostty_app_t, ghostty_config_t);\nbool ghostty_app_needs_confirm_quit(ghostty_app_t);\nbool ghostty_app_has_global_keybinds(ghostty_app_t);\nvoid ghostty_app_set_color_scheme(ghostty_app_t, ghostty_color_scheme_e);\n\nghostty_surface_config_s ghostty_surface_config_new();\n\nghostty_surface_t ghostty_surface_new(ghostty_app_t,\n                                      const ghostty_surface_config_s*);\nvoid ghostty_surface_free(ghostty_surface_t);\nvoid* ghostty_surface_userdata(ghostty_surface_t);\nghostty_app_t ghostty_surface_app(ghostty_surface_t);\nghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t, ghostty_surface_context_e);\nvoid ghostty_surface_update_config(ghostty_surface_t, ghostty_config_t);\nbool ghostty_surface_needs_confirm_quit(ghostty_surface_t);\nbool ghostty_surface_process_exited(ghostty_surface_t);\nvoid ghostty_surface_refresh(ghostty_surface_t);\nvoid ghostty_surface_draw(ghostty_surface_t);\nvoid ghostty_surface_set_content_scale(ghostty_surface_t, double, double);\nvoid ghostty_surface_set_focus(ghostty_surface_t, bool);\nvoid ghostty_surface_set_occlusion(ghostty_surface_t, bool);\nvoid ghostty_surface_set_size(ghostty_surface_t, uint32_t, uint32_t);\nghostty_surface_size_s ghostty_surface_size(ghostty_surface_t);\nvoid ghostty_surface_set_color_scheme(ghostty_surface_t,\n                                      ghostty_color_scheme_e);\nghostty_input_mods_e ghostty_surface_key_translation_mods(ghostty_surface_t,\n                                                          ghostty_input_mods_e);\nbool ghostty_surface_key(ghostty_surface_t, ghostty_input_key_s);\nbool ghostty_surface_key_is_binding(ghostty_surface_t,\n                                    ghostty_input_key_s,\n                                    ghostty_binding_flags_e*);\nvoid ghostty_surface_text(ghostty_surface_t, const char*, uintptr_t);\nvoid ghostty_surface_preedit(ghostty_surface_t, const char*, uintptr_t);\nbool ghostty_surface_mouse_captured(ghostty_surface_t);\nbool ghostty_surface_mouse_button(ghostty_surface_t,\n                                  ghostty_input_mouse_state_e,\n                                  ghostty_input_mouse_button_e,\n                                  ghostty_input_mods_e);\nvoid ghostty_surface_mouse_pos(ghostty_surface_t,\n                               double,\n                               double,\n                               ghostty_input_mods_e);\nvoid ghostty_surface_mouse_scroll(ghostty_surface_t,\n                                  double,\n                                  double,\n                                  ghostty_input_scroll_mods_t);\nvoid ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double);\nvoid ghostty_surface_ime_point(ghostty_surface_t, double*, double*, double*, double*);\nvoid ghostty_surface_request_close(ghostty_surface_t);\nvoid ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e);\nvoid ghostty_surface_split_focus(ghostty_surface_t,\n                                 ghostty_action_goto_split_e);\nvoid ghostty_surface_split_resize(ghostty_surface_t,\n                                  ghostty_action_resize_split_direction_e,\n                                  uint16_t);\nvoid ghostty_surface_split_equalize(ghostty_surface_t);\nbool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t);\nvoid ghostty_surface_complete_clipboard_request(ghostty_surface_t,\n                                                const char*,\n                                                void*,\n                                                bool);\nbool ghostty_surface_has_selection(ghostty_surface_t);\nbool ghostty_surface_read_selection(ghostty_surface_t, ghostty_text_s*);\nbool ghostty_surface_read_text(ghostty_surface_t,\n                               ghostty_selection_s,\n                               ghostty_text_s*);\nvoid ghostty_surface_free_text(ghostty_surface_t, ghostty_text_s*);\n\n#ifdef __APPLE__\nvoid ghostty_surface_set_display_id(ghostty_surface_t, uint32_t);\nvoid* ghostty_surface_quicklook_font(ghostty_surface_t);\nbool ghostty_surface_quicklook_word(ghostty_surface_t, ghostty_text_s*);\n#endif\n\nghostty_inspector_t ghostty_surface_inspector(ghostty_surface_t);\nvoid ghostty_inspector_free(ghostty_surface_t);\nvoid ghostty_inspector_set_focus(ghostty_inspector_t, bool);\nvoid ghostty_inspector_set_content_scale(ghostty_inspector_t, double, double);\nvoid ghostty_inspector_set_size(ghostty_inspector_t, uint32_t, uint32_t);\nvoid ghostty_inspector_mouse_button(ghostty_inspector_t,\n                                    ghostty_input_mouse_state_e,\n                                    ghostty_input_mouse_button_e,\n                                    ghostty_input_mods_e);\nvoid ghostty_inspector_mouse_pos(ghostty_inspector_t, double, double);\nvoid ghostty_inspector_mouse_scroll(ghostty_inspector_t,\n                                    double,\n                                    double,\n                                    ghostty_input_scroll_mods_t);\nvoid ghostty_inspector_key(ghostty_inspector_t,\n                           ghostty_input_action_e,\n                           ghostty_input_key_e,\n                           ghostty_input_mods_e);\nvoid ghostty_inspector_text(ghostty_inspector_t, const char*);\n\n#ifdef __APPLE__\nbool ghostty_inspector_metal_init(ghostty_inspector_t, void*);\nvoid ghostty_inspector_metal_render(ghostty_inspector_t, void*, void*);\nbool ghostty_inspector_metal_shutdown(ghostty_inspector_t);\n#endif\n\n// APIs I'd like to get rid of eventually but are still needed for now.\n// Don't use these unless you know what you're doing.\nvoid ghostty_set_window_background_blur(ghostty_app_t, void*);\n\n// Benchmark API, if available.\nbool ghostty_benchmark_cli(const char*, const char*);\n\n#ifdef __cplusplus\n}\n#endif\n\n#endif /* GHOSTTY_H */\n"
  },
  {
    "path": "ghostty/Vendor/include/module.modulemap",
    "content": "// This makes Ghostty available to the XCode build for the macOS app.\n// We append \"Kit\" to it not to be cute, but because targets have to have\n// unique names and we use Ghostty for other things.\nmodule GhosttyKit {\n    umbrella header \"ghostty.h\"\n    export *\n}\n"
  },
  {
    "path": "models/ActivityChartData.swift",
    "content": "import Foundation\n\nstruct ActivityChartDataPoint: Identifiable, Equatable {\n    let id = UUID()\n    let date: Date\n    let source: SessionSource.Kind\n    let sessionCount: Int\n    let duration: TimeInterval\n    let totalTokens: Int\n}\n\nenum ActivityChartUnit: Equatable {\n    case day\n    case hour\n}\n\nstruct ActivityChartData: Equatable {\n    let points: [ActivityChartDataPoint]\n    let unit: ActivityChartUnit\n    \n    static let empty = ActivityChartData(points: [], unit: .day)\n}\n\nextension Array where Element == SessionSummary {\n    func generateChartData() -> ActivityChartData {\n        guard !self.isEmpty else { return .empty }\n        \n        let dates = self.map { $0.startedAt }\n        let minDate = dates.min() ?? Date()\n        let maxDate = dates.max() ?? Date()\n        \n        // Heuristic: If all sessions are within the same calendar day, or span < 24h, use Hour.\n        // Using Calendar to check \"same day\" is safer for \"Today\" view.\n        let calendar = Calendar.current\n        let isSameDay = calendar.isDate(minDate, inSameDayAs: maxDate)\n        let range = maxDate.timeIntervalSince(minDate)\n        \n        // If explicit single day (same day) OR range < 24h, use Hour.\n        let unit: ActivityChartUnit = (isSameDay || range < 86400) ? .hour : .day\n        \n        // Grouping\n        var groups: [Date: [SessionSource.Kind: (count: Int, duration: TimeInterval, tokens: Int)]] = [:]\n        \n        for session in self {\n            let date = session.startedAt\n            let truncatedDate: Date\n            if unit == .day {\n                truncatedDate = calendar.startOfDay(for: date)\n            } else {\n                // Truncate to hour\n                let components = calendar.dateComponents([.year, .month, .day, .hour], from: date)\n                truncatedDate = calendar.date(from: components) ?? date\n            }\n            \n            let kind = session.source.baseKind\n            var current = groups[truncatedDate, default: [:]][kind, default: (0, 0, 0)]\n            current.count += 1\n            current.duration += session.duration\n            current.tokens += session.actualTotalTokens\n            \n            groups[truncatedDate, default: [:]][kind] = current\n        }\n        \n        var points: [ActivityChartDataPoint] = []\n        for (date, sourceMap) in groups {\n            for (kind, stats) in sourceMap {\n                points.append(ActivityChartDataPoint(\n                    date: date,\n                    source: kind,\n                    sessionCount: stats.count,\n                    duration: stats.duration,\n                    totalTokens: stats.tokens\n                ))\n            }\n        }\n        \n        return ActivityChartData(points: points.sorted { $0.date < $1.date }, unit: unit)\n    }\n}\n"
  },
  {
    "path": "models/AllOverviewViewModel.swift",
    "content": "import Combine\nimport Foundation\nimport OSLog\n\n@MainActor\nfinal class AllOverviewViewModel: ObservableObject {\n  @Published private(set) var snapshot: AllOverviewSnapshot = .empty\n  @Published private(set) var cacheCoverage: SessionIndexCoverage?\n  @Published private(set) var isLoading: Bool = false\n\n  private let sessionListViewModel: SessionListViewModel\n  private var cancellables: Set<AnyCancellable> = []\n  private var pendingRefreshTask: Task<Void, Never>? = nil\n  private let logger = Logger(subsystem: \"io.umate.codmate\", category: \"AllOverviewVM\")\n\n  init(sessionListViewModel: SessionListViewModel) {\n    self.sessionListViewModel = sessionListViewModel\n    bindPublishers()\n    recomputeSnapshot()\n  }\n\n  deinit {\n    pendingRefreshTask?.cancel()\n  }\n\n  func forceRefresh() {\n    pendingRefreshTask?.cancel()\n    pendingRefreshTask = nil\n    recomputeSnapshot()\n  }\n\n  private func bindPublishers() {\n    sessionListViewModel.$sections\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$usageSnapshots\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$projects\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$isLoading\n      .receive(on: DispatchQueue.main)\n      .sink { [weak self] value in self?.isLoading = value }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$cacheCoverage\n      .receive(on: DispatchQueue.main)\n      .sink { [weak self] value in self?.cacheCoverage = value }\n      .store(in: &cancellables)\n  }\n\n  private func scheduleSnapshotRefresh() {\n    pendingRefreshTask?.cancel()\n    pendingRefreshTask = Task { [weak self] in\n      try? await Task.sleep(nanoseconds: 120_000_000)\n      guard !Task.isCancelled else { return }\n      guard let self else { return }\n      let started = Date()\n      \n      // Capture data on MainActor\n      let filteredSessions: [SessionSummary] = self.sessionListViewModel.sections.flatMap { $0.sessions }\n      let usageSnapshots = self.sessionListViewModel.usageSnapshots\n      let projectCount = self.sessionListViewModel.projects.count\n      let scope = self.sessionListViewModel.overviewAggregateScope()\n      let aggregate: OverviewAggregate?\n      if let scope {\n        aggregate = await self.sessionListViewModel.fetchOverviewAggregate(scope: scope)\n      } else if self.sessionListViewModel.canUseGlobalOverviewAggregate {\n        aggregate = await self.sessionListViewModel.fetchOverviewAggregate()\n      } else {\n        aggregate = nil\n      }\n      self.logger.log(\"overview snapshot refresh start sessions=\\(filteredSessions.count, privacy: .public) scopeAggregate=\\(scope != nil, privacy: .public) aggregateFetched=\\(aggregate != nil, privacy: .public)\")\n      \n      // Run computation in background\n      let newSnapshot = await Self.computeSnapshot(\n        sessions: filteredSessions,\n        usageSnapshots: usageSnapshots,\n        projectCount: projectCount,\n        aggregate: aggregate\n      )\n      \n      guard !Task.isCancelled else { return }\n      await MainActor.run {\n        self.snapshot = newSnapshot\n      }\n      let elapsed = Date().timeIntervalSince(started)\n      self.logger.log(\"overview snapshot refresh done in \\(elapsed, format: .fixed(precision: 3))s sessions=\\(newSnapshot.totalSessions, privacy: .public) aggregate=\\(aggregate != nil, privacy: .public)\")\n    }\n  }\n\n  private static func computeSnapshot(\n    sessions: [SessionSummary],\n    usageSnapshots: [UsageProviderKind: UsageProviderSnapshot],\n    projectCount: Int,\n    aggregate: OverviewAggregate?\n  ) async -> AllOverviewSnapshot {\n    let now = Date()\n    \n    func anchorDate(for session: SessionSummary) -> Date {\n      session.lastUpdatedAt ?? session.startedAt\n    }\n\n    let totalDuration = aggregate?.totalDuration ?? sessions.reduce(0) { $0 + $1.duration }\n    let totalTokens = aggregate?.totalTokens ?? sessions.reduce(0) { $0 + $1.actualTotalTokens }\n    let userMessages = aggregate?.userMessages ?? sessions.reduce(0) { $0 + $1.userMessageCount }\n    let assistantMessages = aggregate?.assistantMessages ?? sessions.reduce(0) { $0 + $1.assistantMessageCount }\n\n    let recentTop = Array(\n      sessions\n        .sorted { anchorDate(for: $0) > anchorDate(for: $1) }\n        .prefix(5)\n    )\n\n    let sourceStats = aggregate.map { buildSourceStats(from: $0) } ?? buildSourceStats(from: sessions)\n    let activityData = aggregate.map { activityChartData(from: $0) } ?? sessions.generateChartData()\n\n    return AllOverviewSnapshot(\n      totalSessions: aggregate?.totalSessions ?? sessions.count,\n      totalDuration: totalDuration,\n      totalTokens: totalTokens,\n      userMessages: userMessages,\n      assistantMessages: assistantMessages,\n      recentSessions: recentTop,\n      sourceStats: sourceStats,\n      activityChartData: activityData,\n      usageSnapshots: usageSnapshots,\n      projectCount: projectCount,\n      lastUpdated: now\n    )\n  }\n  \n  private static func buildSourceStats(from aggregate: OverviewAggregate) -> [AllOverviewSnapshot.SourceStat] {\n    var stats: [AllOverviewSnapshot.SourceStat] = []\n    for item in aggregate.sources {\n      stats.append(\n        AllOverviewSnapshot.SourceStat(\n          kind: item.kind,\n          sessionCount: item.sessionCount,\n          totalTokens: item.totalTokens,\n          avgTokens: 0,\n          avgDuration: item.sessionCount > 0 ? item.totalDuration / Double(item.sessionCount) : 0,\n          isAll: false\n        )\n      )\n    }\n    if aggregate.totalSessions > 0 {\n      let allStat = AllOverviewSnapshot.SourceStat(\n        kind: .codex,  // placeholder when isAll=true\n        sessionCount: aggregate.totalSessions,\n        totalTokens: aggregate.totalTokens,\n        avgTokens: 0,\n        avgDuration: aggregate.totalSessions > 0 ? aggregate.totalDuration / Double(aggregate.totalSessions) : 0,\n        isAll: true\n      )\n      stats.insert(allStat, at: 0)\n    }\n    return stats\n  }\n\n  private static func buildSourceStats(from sessions: [SessionSummary]) -> [AllOverviewSnapshot.SourceStat] {\n    var groups: [SessionSource.Kind: [SessionSummary]] = [:]\n    for session in sessions {\n      groups[session.source.baseKind, default: []].append(session)\n    }\n    \n    let kinds: [SessionSource.Kind] = [.codex, .claude, .gemini]\n    \n    var stats: [AllOverviewSnapshot.SourceStat] = kinds.compactMap { kind in\n      let group = groups[kind] ?? []\n      let count = group.count\n      guard count > 0 else { return nil }\n      \n      let totalDuration = group.reduce(0) { $0 + $1.duration }\n      let totalTokens = group.reduce(0) { $0 + $1.actualTotalTokens }\n      \n      return AllOverviewSnapshot.SourceStat(\n        kind: kind,\n        sessionCount: count,\n        totalTokens: totalTokens,\n        avgTokens: 0, // Not used for display anymore\n        avgDuration: count > 0 ? totalDuration / Double(count) : 0,\n        isAll: false\n      )\n    }\n    \n    // Add \"All\" summary if there's data\n    if !sessions.isEmpty {\n      let totalDuration = sessions.reduce(0) { $0 + $1.duration }\n      let totalTokens = sessions.reduce(0) { $0 + $1.actualTotalTokens }\n      let count = sessions.count\n      \n      let allStat = AllOverviewSnapshot.SourceStat(\n        kind: .codex, // Placeholder kind, ignored when isAll is true\n        sessionCount: count,\n        totalTokens: totalTokens,\n        avgTokens: 0,\n        avgDuration: count > 0 ? totalDuration / Double(count) : 0,\n        isAll: true\n      )\n      stats.insert(allStat, at: 0)\n    }\n    \n    return stats\n  }\n\n  private static func activityChartData(from aggregate: OverviewAggregate) -> ActivityChartData {\n    guard !aggregate.daily.isEmpty else { return .empty }\n    let points = aggregate.daily.map {\n      ActivityChartDataPoint(\n        date: $0.day,\n        source: $0.kind,\n        sessionCount: $0.sessionCount,\n        duration: $0.totalDuration,\n        totalTokens: $0.totalTokens\n      )\n    }\n    return ActivityChartData(points: points.sorted { $0.date < $1.date }, unit: .day)\n  }\n\n  private func recomputeSnapshot() {\n    scheduleSnapshotRefresh()\n  }\n\n  func resolveProject(for session: SessionSummary) -> (id: String, name: String)? {\n    let projectId = sessionListViewModel.projectId(for: session)\n    \n    if projectId == SessionListViewModel.otherProjectId {\n        return (id: projectId, name: \"Unassigned\") as? (id: String, name: String)\n    }\n    \n    if let project = sessionListViewModel.projects.first(where: { $0.id == projectId }) {\n        return (id: project.id, name: project.name)\n    }\n    return nil\n  }\n}\n\nstruct AllOverviewSnapshot: Equatable {\n  struct SourceStat: Identifiable, Equatable {\n    let kind: SessionSource.Kind\n    let sessionCount: Int\n    let totalTokens: Int\n    let avgTokens: Double\n    let avgDuration: TimeInterval\n    var isAll: Bool = false\n    \n    var id: String { isAll ? \"all\" : kind.rawValue }\n    \n    var displayName: String {\n      if isAll { return \"All\" }\n      switch kind {\n      case .codex: return \"Codex\"\n      case .claude: return \"Claude\"\n      case .gemini: return \"Gemini\"\n      }\n    }\n  }\n\n  var totalSessions: Int\n  var totalDuration: TimeInterval\n  var totalTokens: Int\n  var userMessages: Int\n  var assistantMessages: Int\n  var recentSessions: [SessionSummary]\n  var sourceStats: [SourceStat]\n  var activityChartData: ActivityChartData\n  var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot]\n  var projectCount: Int\n  var lastUpdated: Date\n\n  static let empty = AllOverviewSnapshot(\n    totalSessions: 0,\n    totalDuration: 0,\n    totalTokens: 0,\n    userMessages: 0,\n    assistantMessages: 0,\n    recentSessions: [],\n    sourceStats: [],\n    activityChartData: .empty,\n    usageSnapshots: [:],\n    projectCount: 0,\n    lastUpdated: .distantPast\n  )\n}\n"
  },
  {
    "path": "models/CLIPathVM.swift",
    "content": "import Foundation\n\n@MainActor\nfinal class CLIPathVM: ObservableObject {\n    struct CLIInfo: Equatable {\n        var path: String?\n        var version: String?\n    }\n\n    @Published var codex: CLIInfo = .init(path: nil, version: nil)\n    @Published var claude: CLIInfo = .init(path: nil, version: nil)\n    @Published var gemini: CLIInfo = .init(path: nil, version: nil)\n    @Published var pathEnv: String = \"\"\n    @Published var sandboxOn: Bool = false\n\n    func refresh() {\n        let sandboxed = ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil\n        let fallbackPath = CLIEnvironment.buildBasePATH()\n        self.pathEnv = fallbackPath\n        self.sandboxOn = sandboxed\n        if sandboxed {\n            let brew = URL(fileURLWithPath: \"/opt/homebrew/bin\", isDirectory: true)\n            let usrLocal = URL(fileURLWithPath: \"/usr/local/bin\", isDirectory: true)\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: brew)\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: usrLocal)\n        }\n        Task(priority: .userInitiated) { @MainActor in\n            let snapshot = await Task.detached {\n                let path = CLIEnvironment.resolvedPATHForCLI(sandboxed: sandboxed)\n                let codexPath = CLIEnvironment.resolveExecutablePath(\"codex\", path: path)\n                let claudePath = CLIEnvironment.resolveExecutablePath(\"claude\", path: path)\n                let geminiPath = CLIEnvironment.resolveExecutablePath(\"gemini\", path: path)\n                let codexVersion = codexPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) }\n                let claudeVersion = claudePath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) }\n                let geminiVersion = geminiPath.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: path) }\n                return (\n                    path: path,\n                    sandboxOn: sandboxed,\n                    codex: CLIInfo(path: codexPath, version: codexVersion),\n                    claude: CLIInfo(path: claudePath, version: claudeVersion),\n                    gemini: CLIInfo(path: geminiPath, version: geminiVersion)\n                )\n            }.value\n            self.pathEnv = snapshot.path\n            self.sandboxOn = snapshot.sandboxOn\n            self.codex = snapshot.codex\n            self.claude = snapshot.claude\n            self.gemini = snapshot.gemini\n        }\n    }\n}\n"
  },
  {
    "path": "models/ClaudeCodeVM.swift",
    "content": "import Foundation\nimport SwiftUI\nimport AppKit\n\n@MainActor\nfinal class ClaudeCodeVM: ObservableObject {\n    let builtinModels: [String] = [\n        \"claude-3-5-sonnet-latest\",\n        \"claude-3-haiku-latest\",\n        \"claude-3-opus-latest\",\n    ]\n    @Published var providers: [ProvidersRegistryService.Provider] = []\n    @Published var activeProviderId: String?\n    enum LoginMethod: String, CaseIterable, Identifiable { case api, subscription; var id: String { rawValue } }\n    @Published var loginMethod: LoginMethod = .api\n    @Published var aliasDefault: String = \"\"\n    @Published var aliasHaiku: String = \"\"\n    @Published var aliasSonnet: String = \"\"\n    @Published var aliasOpus: String = \"\"\n    @Published var lastError: String?\n    @Published var rawSettingsText: String = \"\"\n    @Published var notificationsEnabled: Bool = false\n    @Published var notificationBridgeHealthy: Bool = false\n    @Published var notificationSelfTestResult: String? = nil\n\n    private let registry = ProvidersRegistryService()\n    private var saveDebounceTask: Task<Void, Never>? = nil\n    private var applyProviderDebounceTask: Task<Void, Never>? = nil\n    private var proxySelectionDebounceTask: Task<Void, Never>? = nil\n    private var defaultAliasDebounceTask: Task<Void, Never>? = nil\n    private var runtimeDebounceTask: Task<Void, Never>? = nil\n    private var notificationDebounceTask: Task<Void, Never>? = nil\n\n    func loadAll() async {\n        let providerList = await registry.listProviders()\n        let bindings = await registry.getBindings()\n        await MainActor.run {\n            self.providers = providerList\n            self.activeProviderId = bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n            self.syncAliases()\n            self.syncLoginMethod()\n        }\n        await loadNotificationSettings()\n    }\n\n    func loadProxyDefaults(preferences: SessionPreferencesStore) async {\n        let settings = ClaudeSettingsService()\n        let currentModel = await settings.currentModel()\n        let env = await settings.envSnapshot()\n        if preferences.claudeProxyModelId == nil {\n            if let model = currentModel, !model.isEmpty {\n                preferences.claudeProxyModelId = model\n            } else if let envModel = env[\"ANTHROPIC_MODEL\"] ?? env[\"ANTHROPIC_DEFAULT_SONNET_MODEL\"]\n                        ?? env[\"ANTHROPIC_DEFAULT_OPUS_MODEL\"] ?? env[\"ANTHROPIC_DEFAULT_HAIKU_MODEL\"],\n                      !envModel.isEmpty {\n                preferences.claudeProxyModelId = envModel\n            }\n        }\n        if let providerId = preferences.claudeProxyProviderId {\n            let existing = preferences.claudeProxyModelAliases[providerId] ?? [:]\n            if existing.isEmpty {\n                var aliases: [String: String] = [:]\n                if let opus = env[\"ANTHROPIC_DEFAULT_OPUS_MODEL\"] { aliases[\"opus\"] = opus }\n                if let sonnet = env[\"ANTHROPIC_DEFAULT_SONNET_MODEL\"] { aliases[\"sonnet\"] = sonnet }\n                if let haiku = env[\"ANTHROPIC_DEFAULT_HAIKU_MODEL\"] { aliases[\"haiku\"] = haiku }\n                if !aliases.isEmpty {\n                    var stored = preferences.claudeProxyModelAliases\n                    stored[providerId] = aliases\n                    preferences.claudeProxyModelAliases = stored\n                }\n            }\n        }\n    }\n\n    func availableModels() -> [String] {\n        guard let id = activeProviderId,\n              let provider = providers.first(where: { $0.id == id })\n        else { return [] }\n        return (provider.catalog?.models ?? []).map { $0.vendorModelId }\n    }\n\n    func applyDefaultAlias(_ modelId: String) async {\n        guard let id = activeProviderId else {\n            await MainActor.run { self.aliasDefault = modelId }\n            return\n        }\n        let providerList = await registry.listProviders()\n        guard var provider = providerList.first(where: { $0.id == id }) else { return }\n        var connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init(\n            baseURL: nil,\n            wireAPI: nil,\n            envKey: \"ANTHROPIC_AUTH_TOKEN\",\n            loginMethod: nil,\n            queryParams: nil,\n            httpHeaders: nil,\n            envHttpHeaders: nil,\n            requestMaxRetries: nil,\n            streamMaxRetries: nil,\n            streamIdleTimeoutMs: nil,\n            modelAliases: nil)\n        var aliases = connector.modelAliases ?? [:]\n        aliases[\"default\"] = modelId\n        connector.modelAliases = aliases\n        provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = connector\n        do {\n            try await registry.upsertProvider(provider)\n            await MainActor.run { self.aliasDefault = modelId; self.lastError = nil }\n            // Persist to ~/.claude/settings.json → model only for third‑party providers\n            if self.activeProviderId != nil {\n                if SecurityScopedBookmarks.shared.isSandboxed {\n                    let home = SessionPreferencesStore.getRealUserHomeURL()\n                    _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: \"Authorize your Home folder to update Claude settings\")\n                }\n                let settings = ClaudeSettingsService()\n                try? await settings.setModel(modelId)\n            }\n        } catch { await MainActor.run { self.lastError = \"Failed to set default model\" } }\n    }\n\n    func tokenMissingForCurrentSelection() -> Bool {\n        if loginMethod == .subscription { return false }\n        let env = ProcessInfo.processInfo.environment\n        if let id = activeProviderId,\n           let provider = providers.first(where: { $0.id == id }) {\n            let key = provider.envKey ?? provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n            let val = env[key]\n            return (val == nil || val?.isEmpty == true)\n        }\n        let val = env[\"ANTHROPIC_AUTH_TOKEN\"]\n        return (val == nil || val?.isEmpty == true)\n    }\n\n    func applyActiveProvider() async {\n        do {\n            try await registry.setActiveProvider(.claudeCode, providerId: activeProviderId)\n            await MainActor.run { self.lastError = nil }\n        } catch {\n            await MainActor.run { self.lastError = \"Failed to set active provider\" }\n        }\n        await MainActor.run {\n            self.syncAliases()\n            self.syncLoginMethod()\n        }\n        // Decide persistence policy\n        let isBuiltin = (activeProviderId == nil)\n        // Built‑in provider → clear provider-specific keys (model/env base URL/forceLogin/token)\n        if isBuiltin {\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n            }\n            let settings = ClaudeSettingsService()\n            try? await settings.setModel(nil)\n            try? await settings.setEnvBaseURL(nil)\n            try? await settings.setForceLoginMethod(nil)\n            try? await settings.setEnvToken(nil)\n            return\n        }\n\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n        }\n        let settings = ClaudeSettingsService()\n        // Base URL only for third‑party providers\n        let base = isBuiltin ? nil : selectedClaudeBaseURL?.trimmingCharacters(in: .whitespacesAndNewlines)\n        try? await settings.setEnvBaseURL((base?.isEmpty == false) ? base : nil)\n        // Force login only for API; remove for subscription\n        if loginMethod == .api {\n            try? await settings.setForceLoginMethod(\"console\")\n        } else {\n            try? await settings.setForceLoginMethod(nil)\n        }\n        // Token only for API\n        if loginMethod == .api {\n            var token: String? = nil\n            if let id = activeProviderId,\n               let provider = providers.first(where: { $0.id == id }) {\n                let conn = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n                let keyName = provider.envKey ?? conn?.envKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n                let env = ProcessInfo.processInfo.environment\n                if let val = env[keyName], !val.isEmpty {\n                    token = val\n                } else {\n                    let looksLikeToken = keyName.lowercased().contains(\"sk-\") || keyName.hasPrefix(\"eyJ\") || keyName.contains(\".\")\n                    if looksLikeToken { token = keyName }\n                }\n            }\n            try? await settings.setEnvToken(token)\n        } else {\n            try? await settings.setEnvToken(nil)\n        }\n    }\n\n    func applyProxySelection(\n        providerId: String?,\n        modelId: String?,\n        preferences: SessionPreferencesStore\n    ) async {\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n        }\n        let settings = ClaudeSettingsService()\n        do {\n            if providerId == nil {\n                try await settings.setModel(nil)\n                try await settings.setEnvBaseURL(nil)\n                try await settings.setForceLoginMethod(nil)\n                try await settings.setEnvToken(nil)\n                try await settings.setEnvValues([\n                    \"ANTHROPIC_DEFAULT_OPUS_MODEL\": nil,\n                    \"ANTHROPIC_DEFAULT_SONNET_MODEL\": nil,\n                    \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": nil,\n                    \"ANTHROPIC_MODEL\": nil,\n                    \"ANTHROPIC_SMALL_FAST_MODEL\": nil\n                ])\n                await MainActor.run { self.lastError = nil }\n                return\n            }\n            let port = preferences.localServerPort\n            let baseURL = \"http://127.0.0.1:\\(port)\"\n            let trimmedModel = modelId?.trimmingCharacters(in: .whitespacesAndNewlines)\n            let apiKey = CLIProxyService.shared.resolvePublicAPIKey()\n            let trimmedKey = apiKey.trimmingCharacters(in: .whitespacesAndNewlines)\n            try await settings.setEnvBaseURL(baseURL)\n            try await settings.setForceLoginMethod(nil)\n            try await settings.setEnvToken(trimmedKey.isEmpty ? nil : trimmedKey)\n            let resolved = await resolveProxyAliases(\n                providerId: providerId,\n                selectedModel: trimmedModel,\n                preferences: preferences\n            )\n            try await settings.setModel(resolved.defaultModel)\n            try await settings.setEnvValues([\n                \"ANTHROPIC_DEFAULT_OPUS_MODEL\": resolved.opus,\n                \"ANTHROPIC_DEFAULT_SONNET_MODEL\": resolved.sonnet,\n                \"ANTHROPIC_DEFAULT_HAIKU_MODEL\": resolved.haiku,\n                \"ANTHROPIC_MODEL\": resolved.defaultModel,\n                \"ANTHROPIC_SMALL_FAST_MODEL\": resolved.haiku ?? resolved.defaultModel\n            ])\n            await MainActor.run { self.lastError = nil }\n        } catch {\n            await MainActor.run {\n                self.lastError = \"Failed to apply CLI Proxy provider: \\(error.localizedDescription)\"\n            }\n        }\n    }\n\n    private struct ClaudeProxyAliasSet {\n        var defaultModel: String?\n        var opus: String?\n        var sonnet: String?\n        var haiku: String?\n    }\n\n    private func resolveProxyAliases(\n        providerId: String?,\n        selectedModel: String?,\n        preferences: SessionPreferencesStore\n    ) async -> ClaudeProxyAliasSet {\n        let trimmedSelected = selectedModel?.trimmingCharacters(in: .whitespacesAndNewlines)\n        var defaultModel = (trimmedSelected?.isEmpty == false) ? trimmedSelected : nil\n\n        let storedAliases = providerId.flatMap { preferences.claudeProxyModelAliases[$0] } ?? [:]\n        var opus = storedAliases[\"opus\"]\n        var sonnet = storedAliases[\"sonnet\"]\n        var haiku = storedAliases[\"haiku\"]\n\n        var fallbackAliases: [String: String] = [:]\n\n        if let providerId {\n            switch UnifiedProviderID.parse(providerId) {\n            case .oauth(let authProvider, _):\n                fallbackAliases = await proxyAliasDefaults(\n                    for: authProvider,\n                    fallbackModel: defaultModel\n                )\n            case .api(let apiId):\n                fallbackAliases = await registryAliasDefaults(\n                    for: apiId\n                )\n            default:\n                break\n            }\n        }\n\n        if defaultModel == nil {\n            defaultModel = fallbackAliases[\"default\"]\n                ?? fallbackAliases[\"sonnet\"]\n                ?? fallbackAliases[\"opus\"]\n                ?? fallbackAliases[\"haiku\"]\n        }\n\n        if opus == nil { opus = fallbackAliases[\"opus\"] ?? defaultModel }\n        if sonnet == nil { sonnet = fallbackAliases[\"sonnet\"] ?? defaultModel }\n        if haiku == nil { haiku = fallbackAliases[\"haiku\"] ?? defaultModel }\n\n        return ClaudeProxyAliasSet(\n            defaultModel: defaultModel,\n            opus: opus,\n            sonnet: sonnet,\n            haiku: haiku\n        )\n    }\n\n    private func proxyAliasDefaults(\n        for provider: LocalAuthProvider,\n        fallbackModel: String?\n    ) async -> [String: String] {\n        let trimmedSelected = fallbackModel?.trimmingCharacters(in: .whitespacesAndNewlines)\n        let fallback = (trimmedSelected?.isEmpty == false) ? trimmedSelected : nil\n        var models: [String] = []\n        if let target = builtInProvider(for: provider), CLIProxyService.shared.isRunning {\n            let localModels = await CLIProxyService.shared.fetchLocalModels()\n            models = localModels.compactMap { model in\n                let candidate = model.id.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !candidate.isEmpty else { return nil }\n                if builtInProvider(for: model) == target {\n                    return candidate\n                }\n                return nil\n            }\n        }\n\n        let preferred = fallback ?? selectDefaultModel(from: models)\n        let opus = selectModel(from: models, tokens: [\"opus\"]) ?? preferred\n        let sonnet = selectModel(from: models, tokens: [\"sonnet\"]) ?? preferred\n        let haiku = selectModel(from: models, tokens: [\"haiku\", \"flash\", \"lite\", \"mini\"]) ?? preferred\n\n        var out: [String: String] = [:]\n        if let preferred { out[\"default\"] = preferred }\n        if let opus { out[\"opus\"] = opus }\n        if let sonnet { out[\"sonnet\"] = sonnet }\n        if let haiku { out[\"haiku\"] = haiku }\n        return out\n    }\n\n    private func registryAliasDefaults(for providerId: String) async -> [String: String] {\n        let providers = await registry.listAllProviders()\n        guard let provider = providers.first(where: { $0.id == providerId }) else { return [:] }\n        let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        let aliases = connector?.modelAliases ?? [:]\n        var out: [String: String] = [:]\n        if let def = aliases[\"default\"] { out[\"default\"] = def }\n        if let opus = aliases[\"opus\"] { out[\"opus\"] = opus }\n        if let sonnet = aliases[\"sonnet\"] { out[\"sonnet\"] = sonnet }\n        if let haiku = aliases[\"haiku\"] { out[\"haiku\"] = haiku }\n        if let rec = provider.recommended?.defaultModelFor?[ProvidersRegistryService.Consumer.claudeCode.rawValue],\n           out[\"default\"] == nil {\n            out[\"default\"] = rec\n        }\n        if out[\"default\"] == nil,\n           let first = provider.catalog?.models?.first?.vendorModelId {\n            out[\"default\"] = first\n        }\n        return out\n    }\n\n    private func selectDefaultModel(from models: [String]) -> String? {\n        if let match = selectModel(from: models, tokens: [\"sonnet\", \"opus\", \"haiku\"]) { return match }\n        if let match = selectModel(from: models, tokens: [\"pro\", \"latest\", \"preview\"]) { return match }\n        return models.first\n    }\n\n    private func selectModel(from models: [String], tokens: [String]) -> String? {\n        guard !models.isEmpty else { return nil }\n\n        // Find all models matching any token, then select the one with highest version\n        var candidates: [String] = []\n        for token in tokens {\n            let matching = models.filter { $0.localizedCaseInsensitiveContains(token) }\n            candidates.append(contentsOf: matching)\n        }\n\n        guard !candidates.isEmpty else { return nil }\n\n        // Use ModelNameSanitizer's version comparison logic to find the highest version\n        var bestModel: String? = nil\n        var bestVersion: ModelNameSanitizer.ModelVersion? = nil\n\n        for model in candidates {\n            let (baseName, version) = ModelNameSanitizer.extractModelVersion(model)\n\n            // Check if this model's base name matches any token\n            let matchesToken = tokens.contains { token in\n                baseName.localizedCaseInsensitiveContains(token)\n            }\n\n            if matchesToken {\n                if let existing = bestVersion {\n                    if version.isNewerThan(existing) {\n                        bestVersion = version\n                        bestModel = model\n                    }\n                } else {\n                    bestVersion = version\n                    bestModel = model\n                }\n            }\n        }\n\n        // If no version-based match found, fall back to first match (for models without date suffixes)\n        return bestModel ?? candidates.first\n    }\n\n    private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? {\n        switch provider {\n        case .codex: return .openai\n        case .claude: return .anthropic\n        case .gemini: return .gemini\n        case .antigravity: return .antigravity\n        case .qwen: return .qwen\n        }\n    }\n\n    private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? {\n        let hint = model.provider ?? model.source ?? model.owned_by\n        if let hint, let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesOwnedBy(hint) }) {\n            return provider\n        }\n        let modelId = model.id\n        if let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesModelId(modelId) }) {\n            return provider\n        }\n        return nil\n    }\n\n    func save() async {\n        guard let id = activeProviderId else { return }\n        let providerList = await registry.listAllProviders()\n        guard var provider = providerList.first(where: { $0.id == id }) else { return }\n        var connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init(\n            baseURL: nil,\n            wireAPI: nil,\n            envKey: \"ANTHROPIC_AUTH_TOKEN\",\n            queryParams: nil,\n            httpHeaders: nil,\n            envHttpHeaders: nil,\n            requestMaxRetries: nil,\n            streamMaxRetries: nil,\n            streamIdleTimeoutMs: nil,\n            modelAliases: nil)\n\n        var aliases: [String: String] = [:]\n        func assign(_ key: String, _ value: String) {\n            let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n            if !trimmed.isEmpty { aliases[key] = trimmed }\n        }\n        assign(\"default\", aliasDefault)\n        assign(\"haiku\", aliasHaiku)\n        assign(\"sonnet\", aliasSonnet)\n        assign(\"opus\", aliasOpus)\n\n        connector.modelAliases = aliases.isEmpty ? nil : aliases\n        provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = connector\n\n        do {\n            try await registry.upsertProvider(provider)\n            await MainActor.run { self.lastError = nil }\n            // Persist model only for third‑party providers\n            if self.activeProviderId != nil {\n                if SecurityScopedBookmarks.shared.isSandboxed {\n                    let home = SessionPreferencesStore.getRealUserHomeURL()\n                    _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n                }\n                let settings = ClaudeSettingsService()\n                let m = aliasDefault.trimmingCharacters(in: .whitespacesAndNewlines)\n                try? await settings.setModel(m.isEmpty ? nil : m)\n            }\n            await loadAll()\n        } catch {\n            await MainActor.run { self.lastError = \"Failed to save aliases\" }\n        }\n    }\n\n    func scheduleSaveDebounced(delayMs: UInt64 = 300) {\n        // Cancel any in-flight debounce task and schedule a new one\n        saveDebounceTask?.cancel()\n        saveDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do {\n                try await Task.sleep(nanoseconds: delayMs * 1_000_000)\n            } catch { return }\n            if Task.isCancelled { return }\n            await self.save()\n        }\n    }\n\n    // MARK: - Runtime settings writer\n    func scheduleApplyRuntimeSettings(_ preferences: SessionPreferencesStore, delayMs: UInt64 = 250) {\n        runtimeDebounceTask?.cancel()\n        runtimeDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n            if Task.isCancelled { return }\n            await self.applyRuntimeSettings(preferences)\n        }\n    }\n\n    func applyRuntimeSettings(_ preferences: SessionPreferencesStore) async {\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n        }\n        let settings = ClaudeSettingsService()\n        let addDirs: [String]? = {\n            let raw = preferences.claudeAddDirs.trimmingCharacters(in: .whitespacesAndNewlines)\n            if raw.isEmpty { return nil }\n            return raw.split(whereSeparator: { $0 == \",\" || $0.isWhitespace }).map { String($0) }\n        }()\n        let runtime = ClaudeSettingsService.Runtime(\n            permissionMode: preferences.claudePermissionMode.rawValue,\n            skipPermissions: preferences.claudeSkipPermissions,\n            allowSkipPermissions: preferences.claudeAllowSkipPermissions,\n            debug: preferences.claudeDebug,\n            debugFilter: preferences.claudeDebugFilter,\n            verbose: preferences.claudeVerbose,\n            ide: preferences.claudeIDE,\n            strictMCP: preferences.claudeStrictMCP,\n            fallbackModel: preferences.claudeFallbackModel,\n            allowedTools: preferences.claudeAllowedTools,\n            disallowedTools: preferences.claudeDisallowedTools,\n            addDirs: addDirs\n        )\n        try? await settings.applyRuntime(runtime)\n    }\n\n    func loadNotificationSettings() async {\n        let settings = ClaudeSettingsService()\n        let status = await settings.codMateNotificationHooksStatus()\n        await MainActor.run {\n            let healthy = status.permissionHookInstalled && status.completionHookInstalled\n            self.notificationsEnabled = healthy\n            self.notificationBridgeHealthy = healthy\n            if !healthy {\n                self.notificationSelfTestResult = nil\n            }\n        }\n    }\n\n    private func syncAliases() {\n        guard let id = activeProviderId,\n              let provider = providers.first(where: { $0.id == id })\n        else {\n            aliasDefault = \"\"\n            aliasHaiku = \"\"\n            aliasSonnet = \"\"\n            aliasOpus = \"\"\n            return\n        }\n        let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        let aliases = connector?.modelAliases ?? [:]\n        let recommended = provider.recommended?.defaultModelFor?[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        aliasDefault = aliases[\"default\"] ?? recommended ?? \"\"\n        aliasHaiku = aliases[\"haiku\"] ?? \"\"\n        aliasSonnet = aliases[\"sonnet\"] ?? \"\"\n        aliasOpus = aliases[\"opus\"] ?? \"\"\n    }\n\n    private func syncLoginMethod() {\n        // Built-in (nil provider) defaults to subscription; third-party defaults to api\n        guard let id = activeProviderId,\n              let provider = providers.first(where: { $0.id == id }) else {\n            loginMethod = .subscription\n            return\n        }\n        let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        if let lm = connector?.loginMethod, lm.lowercased() == \"subscription\" {\n            loginMethod = .subscription\n        } else {\n            loginMethod = .api\n        }\n    }\n\n    func setLoginMethod(_ method: LoginMethod) async {\n        await MainActor.run { self.loginMethod = method }\n        // Persist to registry for active provider (if any). Built-in (nil) has no connector; nothing to write.\n        guard let id = activeProviderId else { return }\n        let list = await registry.listProviders()\n        guard var p = list.first(where: { $0.id == id }) else { return }\n        var conn = p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] ?? .init(\n            baseURL: nil, wireAPI: nil, envKey: nil, loginMethod: nil,\n            queryParams: nil, httpHeaders: nil, envHttpHeaders: nil,\n            requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n            modelAliases: nil)\n        conn.loginMethod = method.rawValue\n        // Restore default env key for API login if absent (prefer provider-level key)\n        if method == .api && (p.envKey == nil || p.envKey?.isEmpty == true) {\n            p.envKey = \"ANTHROPIC_AUTH_TOKEN\"\n        }\n        if method == .subscription {\n            // No need to store token env mapping; leave as-is but it will be ignored at launch.\n        }\n        p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = conn\n        do {\n            try await registry.upsertProvider(p)\n            // Persist to settings: only when API; subscription removes forced key and token\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n            }\n            let settings = ClaudeSettingsService()\n            if method == .api {\n                try? await settings.setForceLoginMethod(\"console\")\n                var token: String? = nil\n                let env = ProcessInfo.processInfo.environment\n                let keyName = p.envKey ?? p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n                if let val = env[keyName], !val.isEmpty {\n                    token = val\n                } else {\n                    let looksLikeToken = keyName.lowercased().contains(\"sk-\") || keyName.hasPrefix(\"eyJ\") || keyName.contains(\".\")\n                    if looksLikeToken { token = keyName }\n                }\n                try? await settings.setEnvToken(token)\n            } else {\n                try? await settings.setForceLoginMethod(nil)\n                try? await settings.setEnvToken(nil)\n            }\n        } catch {\n            await MainActor.run { self.lastError = \"Failed to save login method\" }\n        }\n    }\n\n    private func applyNotificationSettings() async {\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n                directory: home,\n                purpose: .generalAccess,\n                message: \"Authorize ~/.claude to update Claude notifications\"\n            )\n        }\n        let settings = ClaudeSettingsService()\n        do {\n            try await settings.setCodMateNotificationHooks(enabled: notificationsEnabled)\n            await loadNotificationSettings()\n        } catch {\n            await MainActor.run { self.lastError = \"Failed to update Claude notifications\" }\n        }\n    }\n\n    func runNotificationSelfTest() async {\n        notificationSelfTestResult = nil\n        var comps = URLComponents()\n        comps.scheme = \"codmate\"\n        comps.host = \"notify\"\n        let title = \"CodMate\"\n        let body = \"Claude notifications self-test\"\n        var items = [\n            URLQueryItem(name: \"source\", value: \"claude\"),\n            URLQueryItem(name: \"event\", value: \"test\")\n        ]\n        if let titleData = title.data(using: .utf8) {\n            items.append(URLQueryItem(name: \"title64\", value: titleData.base64EncodedString()))\n        }\n        if let bodyData = body.data(using: .utf8) {\n            items.append(URLQueryItem(name: \"body64\", value: bodyData.base64EncodedString()))\n        }\n        comps.queryItems = items\n        guard let url = comps.url else {\n            notificationSelfTestResult = \"Invalid test URL\"\n            return\n        }\n        let success = NSWorkspace.shared.open(url)\n        notificationSelfTestResult = success ? \"Sent (check Notification Center)\" : \"Failed to open codmate:// URL\"\n    }\n\n    // MARK: - Debounced operations\n    func scheduleApplyActiveProviderDebounced(delayMs: UInt64 = 300) {\n        applyProviderDebounceTask?.cancel()\n        applyProviderDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n            if Task.isCancelled { return }\n            await self.applyActiveProvider()\n        }\n    }\n\n    func scheduleApplyProxySelectionDebounced(\n        providerId: String?,\n        modelId: String?,\n        preferences: SessionPreferencesStore,\n        delayMs: UInt64 = 300\n    ) {\n        proxySelectionDebounceTask?.cancel()\n        proxySelectionDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n            if Task.isCancelled { return }\n            await self.applyProxySelection(\n                providerId: providerId,\n                modelId: modelId,\n                preferences: preferences\n            )\n        }\n    }\n\n    func scheduleApplyDefaultAliasDebounced(_ modelId: String, delayMs: UInt64 = 300) {\n        defaultAliasDebounceTask?.cancel()\n        defaultAliasDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n            if Task.isCancelled { return }\n            await self.applyDefaultAlias(modelId)\n        }\n    }\n\n    func scheduleApplyNotificationSettingsDebounced(delayMs: UInt64 = 250) {\n        notificationDebounceTask?.cancel()\n        notificationDebounceTask = Task { [weak self] in\n            guard let self else { return }\n            do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n            if Task.isCancelled { return }\n            await self.applyNotificationSettings()\n        }\n    }\n\n    // MARK: - Raw settings helpers\n    func settingsFileURL() -> URL {\n        SessionPreferencesStore.getRealUserHomeURL()\n            .appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"settings.json\")\n    }\n\n    func reloadRawSettings() async {\n        let url = settingsFileURL()\n        let text = (try? String(contentsOf: url, encoding: .utf8)) ?? \"\"\n        await MainActor.run { self.rawSettingsText = text }\n    }\n\n    func openSettingsInEditor() {\n        Task { @MainActor in\n            NSWorkspace.shared.open(self.settingsFileURL())\n        }\n    }\n}\n"
  },
  {
    "path": "models/ClaudeUsageStatus.swift",
    "content": "import Foundation\n\nstruct ClaudeUsageStatus: Equatable {\n    let updatedAt: Date\n    let modelName: String?\n    let contextUsedTokens: Int?\n    let contextLimitTokens: Int?\n    let fiveHourUsedMinutes: Double?\n    let fiveHourWindowMinutes: Double\n    let fiveHourResetAt: Date?\n    let weeklyUsedMinutes: Double?\n    let weeklyWindowMinutes: Double\n    let weeklyResetAt: Date?\n    let sessionExpiresAt: Date?\n    let planType: String?  // Subscription type (Pro, Max, Team, etc.)\n\n    init(\n        updatedAt: Date,\n        modelName: String?,\n        contextUsedTokens: Int?,\n        contextLimitTokens: Int?,\n        fiveHourUsedMinutes: Double?,\n        fiveHourWindowMinutes: Double = 300,\n        fiveHourResetAt: Date?,\n        weeklyUsedMinutes: Double?,\n        weeklyWindowMinutes: Double = 10_080,\n        weeklyResetAt: Date?,\n        sessionExpiresAt: Date? = nil,\n        planType: String? = nil\n    ) {\n        self.updatedAt = updatedAt\n        self.modelName = modelName\n        self.contextUsedTokens = contextUsedTokens\n        self.contextLimitTokens = contextLimitTokens\n        self.fiveHourUsedMinutes = fiveHourUsedMinutes\n        self.fiveHourWindowMinutes = fiveHourWindowMinutes\n        self.fiveHourResetAt = fiveHourResetAt\n        self.weeklyUsedMinutes = weeklyUsedMinutes\n        self.weeklyWindowMinutes = weeklyWindowMinutes\n        self.weeklyResetAt = weeklyResetAt\n        self.sessionExpiresAt = sessionExpiresAt\n        self.planType = planType\n    }\n\n    private var contextProgress: Double? {\n        guard\n            let used = contextUsedTokens,\n            let limit = contextLimitTokens,\n            limit > 0\n        else { return nil }\n        return Double(used) / Double(limit)\n    }\n\n    private var fiveHourProgress: Double? {\n        guard let used = fiveHourUsedMinutes, fiveHourWindowMinutes > 0 else { return nil }\n        let remaining = max(0, fiveHourWindowMinutes - used)\n        return remaining / fiveHourWindowMinutes\n    }\n\n    private var weeklyProgress: Double? {\n        guard let used = weeklyUsedMinutes, weeklyWindowMinutes > 0 else { return nil }\n        let remaining = max(0, weeklyWindowMinutes - used)\n        return remaining / weeklyWindowMinutes\n    }\n\n    func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot {\n        var metrics: [UsageMetricSnapshot] = []\n\n        metrics.append(\n            UsageMetricSnapshot(\n                kind: .context,\n                label: \"Context\",\n                usageText: contextUsageText,\n                percentText: contextPercentText,\n                progress: contextProgress?.clamped01(),\n                resetDate: nil,\n                fallbackWindowMinutes: nil\n            )\n        )\n\n        metrics.append(\n            UsageMetricSnapshot(\n                kind: .fiveHour,\n                label: \"5h limit\",\n                usageText: fiveHourUsageText,\n                percentText: fiveHourPercentText,\n                progress: fiveHourProgress?.clamped01(),\n                resetDate: fiveHourResetAt,\n                fallbackWindowMinutes: Int(fiveHourWindowMinutes)\n            )\n        )\n\n        metrics.append(\n            UsageMetricSnapshot(\n                kind: .weekly,\n                label: \"Weekly limit\",\n                usageText: weeklyUsageText,\n                percentText: weeklyPercentText,\n                progress: weeklyProgress?.clamped01(),\n                resetDate: weeklyResetAt,\n                fallbackWindowMinutes: Int(weeklyWindowMinutes)\n            )\n        )\n\n        // Session expiry removed - new Web API strategy auto-refreshes tokens\n\n        return UsageProviderSnapshot(\n            provider: .claude,\n            title: UsageProviderKind.claude.displayName,\n            titleBadge: titleBadge,\n            availability: .ready,\n            metrics: metrics,\n            updatedAt: updatedAt,\n            statusMessage: nil,\n            origin: .builtin\n        )\n    }\n\n    private var contextUsageText: String? {\n        guard let used = contextUsedTokens else { return nil }\n        if let limit = contextLimitTokens {\n            return \"\\(TokenFormatter.string(from: used)) used / \\(TokenFormatter.string(from: limit)) total\"\n        }\n        return \"\\(TokenFormatter.string(from: used)) used\"\n    }\n\n    private var contextPercentText: String? {\n        guard let ratio = contextProgress else { return nil }\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: ratio))\n            ?? String(format: \"%.0f%%\", ratio * 100)\n    }\n\n    private var fiveHourUsageText: String? {\n        if let resetAt = fiveHourResetAt {\n            let remaining = resetAt.timeIntervalSince(updatedAt)\n            if remaining <= 0 {\n                return \"Reset\"\n            }\n            let minutes = Int(remaining / 60)\n            let hours = minutes / 60\n            let mins = minutes % 60\n            if hours > 0 {\n                return \"\\(hours)h \\(mins)m remaining\"\n            } else {\n                return \"\\(mins)m remaining\"\n            }\n        }\n        // Fallback if no reset date\n        guard let usedMinutes = fiveHourUsedMinutes else { return nil }\n        let remainingMinutes = max(0, fiveHourWindowMinutes - usedMinutes)\n        return \"\\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining\"\n    }\n\n    private var fiveHourPercentText: String? {\n        guard let progress = fiveHourProgress else { return nil }\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: progress))\n            ?? String(format: \"%.0f%%\", progress * 100)\n    }\n\n    private var weeklyUsageText: String? {\n        if let resetAt = weeklyResetAt {\n            let remaining = resetAt.timeIntervalSince(updatedAt)\n            if remaining <= 0 {\n                return \"Reset\"\n            }\n            let minutes = Int(remaining / 60)\n            let hours = minutes / 60\n            let days = hours / 24\n            let remainingHours = hours % 24\n            let remainingMins = minutes % 60\n\n            if days > 0 {\n                if remainingHours > 0 {\n                    return \"\\(days)d \\(remainingHours)h remaining\"\n                } else {\n                    return \"\\(days)d remaining\"\n                }\n            } else if hours > 0 {\n                return \"\\(hours)h \\(remainingMins)m remaining\"\n            } else {\n                return \"\\(remainingMins)m remaining\"\n            }\n        }\n        // Fallback if no reset date\n        guard let usedMinutes = weeklyUsedMinutes else { return nil }\n        let remainingMinutes = max(0, weeklyWindowMinutes - usedMinutes)\n        return \"\\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining\"\n    }\n\n    private var weeklyPercentText: String? {\n        guard let progress = weeklyProgress else { return nil }\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: progress))\n            ?? String(format: \"%.0f%%\", progress * 100)\n    }\n}\n\nprivate extension Double {\n    func clamped01() -> Double {\n        if self.isNaN { return 0 }\n        return max(0, min(self, 1))\n    }\n}\n"
  },
  {
    "path": "models/CodexUsageStatus.swift",
    "content": "import Foundation\n\nstruct CodexUsageStatus: Equatable {\n    let updatedAt: Date\n    let contextUsedTokens: Int?\n    let contextLimitTokens: Int?\n    let primaryWindowUsedPercent: Double?\n    let primaryWindowMinutes: Int?\n    let primaryResetAt: Date?\n    let secondaryWindowUsedPercent: Double?\n    let secondaryWindowMinutes: Int?\n    let secondaryResetAt: Date?\n\n    var contextUsedPercent: Double? {\n        guard\n            let used = contextUsedTokens,\n            let limit = contextLimitTokens,\n            limit > 0\n        else { return nil }\n        return Double(used) / Double(limit)\n    }\n\n    init(\n        updatedAt: Date,\n        contextUsedTokens: Int?,\n        contextLimitTokens: Int?,\n        primaryWindowUsedPercent: Double?,\n        primaryWindowMinutes: Int?,\n        primaryResetAt: Date?,\n        secondaryWindowUsedPercent: Double?,\n        secondaryWindowMinutes: Int?,\n        secondaryResetAt: Date?\n    ) {\n        self.updatedAt = updatedAt\n        self.contextUsedTokens = contextUsedTokens\n        self.contextLimitTokens = contextLimitTokens\n        self.primaryWindowUsedPercent = primaryWindowUsedPercent\n        self.primaryWindowMinutes = primaryWindowMinutes\n        self.primaryResetAt = primaryResetAt\n        self.secondaryWindowUsedPercent = secondaryWindowUsedPercent\n        self.secondaryWindowMinutes = secondaryWindowMinutes\n        self.secondaryResetAt = secondaryResetAt\n    }\n}\n\nextension CodexUsageStatus {\n    var contextUsageText: String? {\n        guard let used = contextUsedTokens, let limit = contextLimitTokens else { return nil }\n        return \"\\(TokenFormatter.string(from: used)) used / \\(TokenFormatter.string(from: limit)) total\"\n    }\n\n    var contextPercentText: String? {\n        guard let percent = contextUsedPercent else { return nil }\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: percent))\n            ?? String(format: \"%.0f%%\", percent * 100)\n    }\n\n    var primaryPercentText: String? {\n        guard let percent = primaryWindowUsedPercent else { return nil }\n        let remainingPercent = 100.0 - percent\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remainingPercent / 100.0))\n            ?? String(format: \"%.0f%%\", remainingPercent)\n    }\n\n    var secondaryPercentText: String? {\n        guard let percent = secondaryWindowUsedPercent else { return nil }\n        let remainingPercent = 100.0 - percent\n        return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remainingPercent / 100.0))\n            ?? String(format: \"%.0f%%\", remainingPercent)\n    }\n\n    var primaryUsageText: String? {\n        guard let percent = primaryWindowUsedPercent, let minutes = primaryWindowMinutes else { return nil }\n        let usedMinutes = max(0, min(percent, 100)) / 100.0 * Double(minutes)\n        let remainingMinutes = max(0, Double(minutes) - usedMinutes)\n        return \"\\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining\"\n    }\n\n    var secondaryUsageText: String? {\n        guard let percent = secondaryWindowUsedPercent, let minutes = secondaryWindowMinutes else { return nil }\n        let usedMinutes = max(0, min(percent, 100)) / 100.0 * Double(minutes)\n        let remainingMinutes = max(0, Double(minutes) - usedMinutes)\n        return \"\\(UsageDurationFormatter.string(minutes: remainingMinutes)) remaining\"\n    }\n\n    var contextProgress: Double? { contextUsedPercent }\n\n    var primaryProgress: Double? {\n        guard let percent = primaryWindowUsedPercent else { return nil }\n        let remainingPercent = 100.0 - percent\n        return remainingPercent / 100.0\n    }\n\n    var secondaryProgress: Double? {\n        guard let percent = secondaryWindowUsedPercent else { return nil }\n        let remainingPercent = 100.0 - percent\n        return remainingPercent / 100.0\n    }\n\n    func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot {\n        var metrics: [UsageMetricSnapshot] = []\n\n        if contextUsedTokens != nil || contextLimitTokens != nil {\n            metrics.append(\n                UsageMetricSnapshot(\n                    kind: .context,\n                    label: \"Context\",\n                    usageText: contextUsageText,\n                    percentText: contextPercentText,\n                    progress: contextProgress,\n                    resetDate: nil,\n                    fallbackWindowMinutes: nil\n                )\n            )\n        }\n\n        metrics.append(\n            UsageMetricSnapshot(\n                kind: .fiveHour,\n                label: \"5h limit\",\n                usageText: primaryUsageText,\n                percentText: primaryPercentText,\n                progress: primaryProgress,\n                resetDate: validPrimaryResetAt,\n                fallbackWindowMinutes: primaryWindowMinutes\n            )\n        )\n\n        metrics.append(\n            UsageMetricSnapshot(\n                kind: .weekly,\n                label: \"Weekly limit\",\n                usageText: secondaryUsageText,\n                percentText: secondaryPercentText,\n                progress: secondaryProgress,\n                resetDate: validSecondaryResetAt,\n                fallbackWindowMinutes: secondaryWindowMinutes\n            )\n        )\n\n        return UsageProviderSnapshot(\n            provider: .codex,\n            title: UsageProviderKind.codex.displayName,\n            titleBadge: titleBadge,\n            availability: .ready,\n            metrics: metrics,\n            updatedAt: updatedAt,\n            statusMessage: nil,\n            origin: .builtin\n        )\n    }\n\n    init(snapshot: TokenUsageSnapshot) {\n        self.init(\n            updatedAt: snapshot.timestamp,\n            contextUsedTokens: snapshot.totalTokens,\n            contextLimitTokens: snapshot.contextWindow,\n            primaryWindowUsedPercent: snapshot.primaryPercent,\n            primaryWindowMinutes: snapshot.primaryWindowMinutes,\n            primaryResetAt: snapshot.primaryResetAt,\n            secondaryWindowUsedPercent: snapshot.secondaryPercent,\n            secondaryWindowMinutes: snapshot.secondaryWindowMinutes,\n            secondaryResetAt: snapshot.secondaryResetAt\n        )\n    }\n\n    func overridingRateLimits(\n        updatedAt: Date? = nil,\n        primaryUsedPercent: Double?,\n        primaryWindowMinutes: Int?,\n        primaryResetAt: Date?,\n        secondaryUsedPercent: Double?,\n        secondaryWindowMinutes: Int?,\n        secondaryResetAt: Date?\n    ) -> CodexUsageStatus {\n        CodexUsageStatus(\n            updatedAt: updatedAt ?? self.updatedAt,\n            contextUsedTokens: contextUsedTokens,\n            contextLimitTokens: contextLimitTokens,\n            primaryWindowUsedPercent: primaryUsedPercent ?? primaryWindowUsedPercent,\n            primaryWindowMinutes: primaryWindowMinutes ?? self.primaryWindowMinutes,\n            primaryResetAt: primaryResetAt ?? self.primaryResetAt,\n            secondaryWindowUsedPercent: secondaryUsedPercent ?? secondaryWindowUsedPercent,\n            secondaryWindowMinutes: secondaryWindowMinutes ?? self.secondaryWindowMinutes,\n            secondaryResetAt: secondaryResetAt ?? self.secondaryResetAt\n        )\n    }\n\n    private var validPrimaryResetAt: Date? {\n        guard let reset = primaryResetAt else { return nil }\n        return reset > updatedAt ? reset : nil\n    }\n\n    private var validSecondaryResetAt: Date? {\n        guard let reset = secondaryResetAt else { return nil }\n        return reset > updatedAt ? reset : nil\n    }\n}\n\nenum UsageDurationFormatter {\n    static func string(minutes: Double) -> String {\n        if minutes >= 1440 {\n            let days = minutes / 1440.0\n            return days >= 10 ? String(format: \"%.0fd\", days) : String(format: \"%.1fd\", days)\n        }\n        if minutes >= 60 {\n            let hours = minutes / 60.0\n            return hours >= 10 ? String(format: \"%.0fh\", hours) : String(format: \"%.1fh\", hours)\n        }\n        return String(format: \"%.0fm\", minutes)\n    }\n}\n\nextension NumberFormatter {\n    static let decimalFormatter: NumberFormatter = {\n        let formatter = NumberFormatter()\n        formatter.numberStyle = .decimal\n        formatter.maximumFractionDigits = 0\n        return formatter\n    }()\n\n    static let compactPercentFormatter: NumberFormatter = {\n        let formatter = NumberFormatter()\n        formatter.numberStyle = .percent\n        formatter.minimumFractionDigits = 0\n        formatter.maximumFractionDigits = 0\n        return formatter\n    }()\n}\n"
  },
  {
    "path": "models/CodexVM.swift",
    "content": "import Foundation\nimport SwiftUI\n\n@MainActor\nfinal class CodexVM: ObservableObject {\n  let builtinModels: [String] = [\n    \"gpt-5.2-codex\", \"gpt-5.1-codex-max\", \"gpt-5.1-codex-mini\", \"gpt-5.2\"\n  ]\n  enum ReasoningEffort: String, CaseIterable, Identifiable {\n    case minimal, low, medium, high\n    var id: String { rawValue }\n  }\n  enum ReasoningSummary: String, CaseIterable, Identifiable {\n    case auto, concise, detailed, none\n    var id: String { rawValue }\n  }\n  enum ModelVerbosity: String, CaseIterable, Identifiable {\n    case low, medium, high\n    var id: String { rawValue }\n  }\n  enum FeatureOverrideState: String, Identifiable {\n    case inherit, forceOn, forceOff\n    var id: String { rawValue }\n  }\n  struct FeatureFlag: Identifiable, Equatable {\n    let name: String\n    let stage: String\n    let defaultEnabled: Bool\n    var overrideState: FeatureOverrideState\n    var id: String { name }\n  }\n  enum OtelKind: String, Identifiable {\n    case http, grpc\n    var id: String { rawValue }\n  }\n\n  // Providers\n  @Published var providers: [CodexProvider] = []\n  @Published var activeProviderId: String?\n  @Published var registryProviders: [ProvidersRegistryService.Provider] = []\n  @Published var registryActiveProviderId: String?\n  @Published var showProviderEditor = false\n  @Published var providerDraft: CodexProvider = .init(\n    id: \"\", name: nil, baseURL: nil, envKey: nil, wireAPI: nil, queryParamsRaw: nil,\n    httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil, streamMaxRetries: nil,\n    streamIdleTimeoutMs: nil, managedByCodMate: true)\n  private var editingExistingId: String? = nil\n  var editingKindIsNew: Bool { editingExistingId == nil }\n  @Published var showDeleteAlert: Bool = false\n  @Published var deleteTargetId: String? = nil\n\n  // Runtime\n  @Published var model: String = \"\"\n  @Published var reasoningEffort: ReasoningEffort = .medium\n  @Published var reasoningSummary: ReasoningSummary = .auto\n  @Published var modelVerbosity: ModelVerbosity = .medium\n  @Published var sandboxMode: SandboxMode = .workspaceWrite\n  @Published var approvalPolicy: ApprovalPolicy = .onRequest\n  @Published var runtimeDirty = false\n  // Features\n  @Published var featureFlags: [FeatureFlag] = []\n  @Published var featuresLoading: Bool = false\n  @Published var featureError: String?\n\n  // Notifications\n  @Published var tuiNotifications: Bool = false\n  @Published var systemNotifications: Bool = false\n  @Published var notifyBridgePath: String?\n  @Published var rawConfigText: String = \"\"\n\n  // Privacy\n  @Published var envInherit: String = \"all\"\n  @Published var envIgnoreDefaults: Bool = false\n  @Published var envIncludeOnly: String = \"\"\n  @Published var envExclude: String = \"\"\n  @Published var envSetPairs: String = \"\"\n  @Published var hideAgentReasoning: Bool = false\n  @Published var showRawAgentReasoning: Bool = false\n  @Published var suppressUnstableFeaturesWarning: Bool = false\n  @Published var fileOpener: String = \"vscode\"\n  // OTEL\n  @Published var otelEnabled: Bool = false\n  @Published var otelKind: OtelKind = .http\n  @Published var otelEndpoint: String = \"\"\n\n  @Published var lastError: String?\n\n  private let service = CodexConfigService()\n  private let featuresService = CodexFeaturesService()\n  private let providersRegistry = ProvidersRegistryService()\n  private var featureDefaults: [String: Bool] = [:]\n  // Debounce tasks\n  private var debounceProviderTask: Task<Void, Never>? = nil\n  private var debounceModelTask: Task<Void, Never>? = nil\n  private var debounceProxySelectionTask: Task<Void, Never>? = nil\n  private var debounceReasoningTask: Task<Void, Never>? = nil\n  private var debounceTuiNotifTask: Task<Void, Never>? = nil\n  private var debounceSysNotifTask: Task<Void, Never>? = nil\n  private var debounceHideReasoningTask: Task<Void, Never>? = nil\n  private var debounceShowReasoningTask: Task<Void, Never>? = nil\n  private var debounceSandboxTask: Task<Void, Never>? = nil\n  private var debounceApprovalTask: Task<Void, Never>? = nil\n  private var debounceSuppressUnstableWarningTask: Task<Void, Never>? = nil\n  // Preset helper\n  enum ProviderPreset { case k2, glm, deepseek }\n  @Published var providerKeyApplyURL: String? = nil\n\n  func loadAll() async {\n    await loadProviders()\n    await loadRuntime()\n    await loadRegistryBindings()\n    await loadNotifications()\n    await loadPrivacy()\n    await loadFeatures()\n    await reloadRawConfig()\n  }\n\n  func loadProviders() async {\n    providers = await service.listProviders()\n    activeProviderId = await service.activeProvider()\n  }\n\n  func loadRegistryBindings() async {\n    // Align with Claude Code: only show user-configured providers,\n    // not bundled templates, to avoid confusing, incomplete entries.\n    registryProviders = await providersRegistry.listProviders()\n    let bindings = await providersRegistry.getBindings()\n    registryActiveProviderId =\n      bindings.activeProvider?[\n        ProvidersRegistryService.Consumer.codex.rawValue]\n    if let defaultModel = bindings.defaultModel?[\n      ProvidersRegistryService.Consumer.codex.rawValue], !defaultModel.isEmpty\n    {\n      model = defaultModel\n    } else if registryActiveProviderId == nil {\n      model = builtinModels.first ?? \"gpt-5.2-codex\"\n    }\n    normalizeBuiltinModelIfNeeded()\n  }\n\n  func loadProxyDefaults(preferences: SessionPreferencesStore) async {\n    let currentModel = await service.getTopLevelString(\"model\")\n    if let value = currentModel, !value.isEmpty {\n      if preferences.codexProxyModelId == nil {\n        preferences.codexProxyModelId = value\n      }\n    }\n  }\n\n  // MARK: - Debounced schedulers\n  private func schedule(\n    _ taskRef: inout Task<Void, Never>?, delayMs: UInt64 = 300,\n    action: @escaping @MainActor () async -> Void\n  ) {\n    taskRef?.cancel()\n    taskRef = Task { [weak self] in\n      guard self != nil else { return }\n      do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n      if Task.isCancelled { return }\n      await action()\n    }\n  }\n\n  func scheduleApplyRegistryProviderSelectionDebounced() {\n    schedule(&debounceProviderTask) { [weak self] in\n      guard let self else { return }\n      await self.applyRegistryProviderSelection()\n    }\n  }\n  func scheduleApplyProxySelectionDebounced(\n    providerId: String?,\n    modelId: String?,\n    preferences: SessionPreferencesStore\n  ) {\n    schedule(&debounceProxySelectionTask) { [weak self] in\n      guard let self else { return }\n      await self.applyProxySelection(providerId: providerId, modelId: modelId, preferences: preferences)\n    }\n  }\n  func scheduleApplyModelDebounced() {\n    schedule(&debounceModelTask) { [weak self] in\n      guard let self else { return }\n      await self.applyModel()\n    }\n  }\n  func scheduleApplyReasoningDebounced() {\n    schedule(&debounceReasoningTask) { [weak self] in\n      guard let self else { return }\n      await self.applyReasoning()\n    }\n  }\n  func scheduleApplyTuiNotificationsDebounced() {\n    schedule(&debounceTuiNotifTask) { [weak self] in\n      guard let self else { return }\n      await self.applyTuiNotifications()\n    }\n  }\n  func scheduleApplySystemNotificationsDebounced() {\n    schedule(&debounceSysNotifTask) { [weak self] in\n      guard let self else { return }\n      await self.applySystemNotifications()\n    }\n  }\n  func scheduleApplyHideReasoningDebounced() {\n    schedule(&debounceHideReasoningTask) { [weak self] in\n      guard let self else { return }\n      await self.applyHideReasoning()\n    }\n  }\n  func scheduleApplyShowRawReasoningDebounced() {\n    schedule(&debounceShowReasoningTask) { [weak self] in\n      guard let self else { return }\n      await self.applyShowRawReasoning()\n    }\n  }\n  func scheduleApplySuppressUnstableWarningDebounced() {\n    schedule(&debounceSuppressUnstableWarningTask) { [weak self] in\n      guard let self else { return }\n      await self.applySuppressUnstableWarning()\n    }\n  }\n  func scheduleApplySandboxDebounced() {\n      schedule(&debounceSandboxTask) { [weak self] in\n          guard let self else { return }\n          await self.applySandbox()\n      }\n  }\n  func scheduleApplyApprovalDebounced() {\n      schedule(&debounceApprovalTask) { [weak self] in\n          guard let self else { return }\n          await self.applyApproval()\n      }\n  }\n\n  func presentAddProvider() {\n    editingExistingId = nil\n    providerDraft = .init(\n      id: \"\", name: nil, baseURL: nil, envKey: nil, wireAPI: nil, queryParamsRaw: nil,\n      httpHeadersRaw: nil, envHttpHeadersRaw: nil, requestMaxRetries: nil,\n      streamMaxRetries: nil, streamIdleTimeoutMs: nil, managedByCodMate: true)\n    providerKeyApplyURL = nil\n    showProviderEditor = true\n  }\n\n  func presentAddProviderPreset(_ preset: ProviderPreset) {\n    editingExistingId = nil\n    switch preset {\n    case .k2:\n      providerDraft = .init(\n        id: \"\", name: \"K2\", baseURL: \"https://api.moonshot.cn/v1\", envKey: nil,\n        wireAPI: \"responses\", queryParamsRaw: nil, httpHeadersRaw: nil,\n        envHttpHeadersRaw: nil,\n        requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n        managedByCodMate: true)\n      providerKeyApplyURL = \"https://platform.moonshot.cn/console/api-keys\"\n    case .glm:\n      providerDraft = .init(\n        id: \"\", name: \"GLM\", baseURL: \"https://open.bigmodel.cn/api/paas/v4/\", envKey: nil,\n        wireAPI: \"responses\", queryParamsRaw: nil, httpHeadersRaw: nil,\n        envHttpHeadersRaw: nil,\n        requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n        managedByCodMate: true)\n      providerKeyApplyURL = \"https://bigmodel.cn/usercenter/proj-mgmt/apikeys\"\n    case .deepseek:\n      providerDraft = .init(\n        id: \"\", name: \"DeepSeek\", baseURL: \"https://api.deepseek.com/v1\", envKey: nil,\n        wireAPI: \"responses\", queryParamsRaw: nil, httpHeadersRaw: nil,\n        envHttpHeadersRaw: nil,\n        requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n        managedByCodMate: true)\n      providerKeyApplyURL = \"https://platform.deepseek.com/api_keys\"\n    }\n    showProviderEditor = true\n  }\n\n  func presentEditProvider(_ p: CodexProvider) {\n    editingExistingId = p.id\n    providerDraft = p\n    switch p.id.lowercased() {\n    case \"k2\": providerKeyApplyURL = \"https://platform.moonshot.cn/console/api-keys\"\n    case \"glm\": providerKeyApplyURL = \"https://bigmodel.cn/usercenter/proj-mgmt/apikeys\"\n    case \"deepseek\": providerKeyApplyURL = \"https://platform.deepseek.com/api_keys\"\n    default: providerKeyApplyURL = nil\n    }\n    showProviderEditor = true\n  }\n\n  func dismissEditor() { showProviderEditor = false }\n\n  func saveProviderDraft() async {\n    lastError = nil\n    do {\n      var provider = providerDraft\n      // Trim and normalize\n      func norm(_ s: String?) -> String? {\n        let t = s?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n        return t.isEmpty ? nil : t\n      }\n      provider.name = norm(provider.name)\n      provider.baseURL = norm(provider.baseURL)\n      provider.envKey = norm(provider.envKey)\n      // wire_api must be one of: responses, chat. If empty → nil; if invalid → keep as-is (user intent), but presets default to responses.\n      if let w = norm(provider.wireAPI) {\n        let lw = w.lowercased()\n        provider.wireAPI = (lw == \"responses\" || lw == \"chat\") ? lw : w\n      } else {\n        provider.wireAPI = nil\n      }\n      provider.queryParamsRaw = norm(provider.queryParamsRaw)\n      provider.httpHeadersRaw = norm(provider.httpHeadersRaw)\n      provider.envHttpHeadersRaw = norm(provider.envHttpHeadersRaw)\n\n      // Basic validation: require at least a base URL or name\n      if provider.baseURL == nil && provider.name == nil {\n        lastError = \"Please enter at least a Name or Base URL.\"\n        return\n      }\n\n      if editingKindIsNew {\n        // Determine id: prefer existing non-empty id, otherwise slugify name/base\n        let proposed = norm(provider.id) ?? provider.name ?? provider.baseURL ?? \"provider\"\n        let baseSlug = Self.slugify(proposed)\n        var candidate = baseSlug.isEmpty ? \"provider\" : baseSlug\n        var n = 2\n        while providers.contains(where: { $0.id == candidate }) {\n          candidate = \"\\(baseSlug)-\\(n)\"\n          n += 1\n        }\n        provider.id = candidate\n      } else {\n        provider.id = editingExistingId ?? provider.id\n      }\n      try await service.upsertProvider(provider)\n      showProviderEditor = false\n      await loadProviders()\n    } catch {\n      lastError = \"Failed to save provider: \\(error.localizedDescription)\"\n    }\n  }\n\n  func deleteProvider(id: String) {\n    Task { [weak self] in\n      do {\n        try await self?.service.deleteProvider(id: id)\n        await self?.loadProviders()\n      } catch {\n        await MainActor.run {\n          self?.lastError = \"Delete failed: \\(error.localizedDescription)\"\n        }\n      }\n    }\n  }\n\n  func requestDeleteProvider(id: String) {\n    deleteTargetId = id\n    showDeleteAlert = true\n  }\n  func cancelDelete() {\n    showDeleteAlert = false\n    deleteTargetId = nil\n  }\n  func confirmDelete() async {\n    guard let id = deleteTargetId else { return }\n    deleteProvider(id: id)\n    await MainActor.run {\n      self.showDeleteAlert = false\n      self.deleteTargetId = nil\n    }\n  }\n\n  func applyActiveProvider() async {\n    do { try await service.setActiveProvider(activeProviderId) } catch {\n      lastError = \"Failed to set active provider\"\n    }\n  }\n\n  func deleteEditingProviderViaEditor() async {\n    guard let id = editingExistingId else { return }\n    do {\n      try await service.deleteProvider(id: id)\n      await loadProviders()\n      await MainActor.run { self.showProviderEditor = false }\n    } catch {\n      await MainActor.run { self.lastError = \"Delete failed: \\(error.localizedDescription)\" }\n    }\n  }\n\n  // Runtime\n  func loadRuntime() async {\n    model = await service.getTopLevelString(\"model\") ?? model\n    if let e = await service.getTopLevelString(\"model_reasoning_effort\"),\n      let v = ReasoningEffort(rawValue: e)\n    {\n      reasoningEffort = v\n    }\n    if let s = await service.getTopLevelString(\"model_reasoning_summary\"),\n      let v = ReasoningSummary(rawValue: s)\n    {\n      reasoningSummary = v\n    }\n    if let v = await service.getTopLevelString(\"model_verbosity\"),\n      let mv = ModelVerbosity(rawValue: v)\n    {\n      modelVerbosity = mv\n    }\n    if let s = await service.getTopLevelString(\"sandbox_mode\"),\n      let sm = SandboxMode(rawValue: s)\n    {\n      sandboxMode = sm\n    }\n    if let a = await service.getTopLevelString(\"approval_policy\"),\n      let ap = ApprovalPolicy(rawValue: a)\n    {\n      approvalPolicy = ap\n    }\n  }\n\n  func applyModel() async {\n    let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)\n    let value = trimmed.isEmpty ? nil : trimmed\n    model = trimmed\n    do {\n      try await service.setTopLevelString(\"model\", value: value)\n      try await providersRegistry.setDefaultModel(\n        .codex, modelId: value)\n      runtimeDirty = false\n    } catch {\n      lastError = \"Save failed\"\n    }\n  }\n\n  func selectedRegistryProvider() -> ProvidersRegistryService.Provider? {\n    guard let id = registryActiveProviderId else { return nil }\n    return registryProviders.first(where: { $0.id == id })\n  }\n\n  func modelsForActiveRegistryProvider() -> [String] {\n    guard let provider = selectedRegistryProvider() else { return [] }\n    let ids = (provider.catalog?.models ?? []).map { $0.vendorModelId }\n    var seen = Set<String>()\n    return ids.compactMap { id in\n      let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !trimmed.isEmpty else { return nil }\n      if seen.insert(trimmed).inserted { return trimmed }\n      return nil\n    }\n  }\n\n  func registryDisplayName(for provider: ProvidersRegistryService.Provider) -> String {\n    if let name = provider.name, !name.isEmpty { return name }\n    return provider.id\n  }\n\n  func applyRegistryProviderSelection() async {\n    do {\n      try await providersRegistry.setActiveProvider(\n        .codex, providerId: registryActiveProviderId)\n      if let provider = selectedRegistryProvider() {\n        try await service.applyProviderFromRegistry(provider)\n        if let recommended = provider.recommended?.defaultModelFor?[\n          ProvidersRegistryService.Consumer.codex.rawValue],\n          !recommended.isEmpty\n        {\n          model = recommended\n        } else if let first = provider.catalog?.models?.first?.vendorModelId {\n          model = first\n        }\n      } else {\n        try await service.applyProviderFromRegistry(nil)\n        model = builtinModels.first ?? \"gpt-5.2-codex\"\n      }\n      await applyModel()\n    } catch {\n      lastError = \"Failed to apply provider\"\n    }\n    await loadRegistryBindings()\n  }\n\n  func applyProxySelection(\n    providerId: String?,\n    modelId: String?,\n    preferences: SessionPreferencesStore\n  ) async {\n    do {\n      if providerId == nil {\n        try await service.replaceProviders(with: [])\n        try await service.setActiveProvider(nil)\n        try await service.setTopLevelString(\"model\", value: nil)\n        await MainActor.run { self.lastError = nil }\n        return\n      }\n      let key = CLIProxyService.shared.resolvePublicAPIKey()\n      let port = preferences.localServerPort\n      try await service.applyLocalProxyProvider(\n        providerId: \"codmate-proxy\",\n        port: port,\n        apiKey: key,\n        modelId: modelId\n      )\n      await MainActor.run { self.lastError = nil }\n    } catch {\n      await MainActor.run {\n        self.lastError = \"Failed to apply CLI Proxy provider: \\(error.localizedDescription)\"\n      }\n    }\n  }\n\n  private func normalizeBuiltinModelIfNeeded() {\n    guard registryActiveProviderId == nil else { return }\n    if !builtinModels.contains(model) {\n      model = builtinModels.first ?? \"gpt-5.2-codex\"\n  }\n  }\n  func applyReasoning() async {\n    do {\n      try await service.setTopLevelString(\n        \"model_reasoning_effort\", value: reasoningEffort.rawValue)\n      try await service.setTopLevelString(\n        \"model_reasoning_summary\", value: reasoningSummary.rawValue)\n      try await service.setTopLevelString(\"model_verbosity\", value: modelVerbosity.rawValue)\n    } catch { lastError = \"Save failed\" }\n  }\n  func applySandbox() async {\n    do { try await service.setSandboxMode(sandboxMode.rawValue) } catch {\n      lastError = \"Save failed\"\n    }\n  }\n  func applyApproval() async {\n    do { try await service.setApprovalPolicy(approvalPolicy.rawValue) } catch {\n      lastError = \"Save failed\"\n    }\n  }\n\n  // Features\n  func loadFeatures() async {\n    featuresLoading = true\n    featureError = nil\n    do {\n      suppressUnstableFeaturesWarning = await service.getBool(\"suppress_unstable_features_warning\")\n      async let overridesTask = service.featureOverrides()\n      let infos = try await featuresService.listFeatures()\n      let overrides = await overridesTask\n      var defaults = featureDefaults\n      var rows: [FeatureFlag] = []\n      let hiddenKeys: Set<String> = [\n        \"experimental_windows_sandbox\",\n        \"elevated_windows_sandbox\",\n        \"powershell_utf8\",\n      ]\n      for info in infos where !hiddenKeys.contains(info.name) {\n        let base = defaults[info.name] ?? info.enabled\n        defaults[info.name] = base\n        let state: FeatureOverrideState\n        if let override = overrides[info.name] { state = override ? .forceOn : .forceOff }\n        else { state = .inherit }\n        rows.append(FeatureFlag(name: info.name, stage: info.stage, defaultEnabled: base, overrideState: state))\n      }\n      featureDefaults = defaults\n      featureFlags = rows\n    } catch {\n      featureFlags = []\n      if let localized = (error as? LocalizedError)?.errorDescription {\n        featureError = localized\n      } else {\n        featureError = \"Failed to load features\"\n      }\n    }\n    featuresLoading = false\n  }\n\n  func setFeatureOverride(name: String, state: FeatureOverrideState) {\n    if let idx = featureFlags.firstIndex(where: { $0.name == name }) {\n      featureFlags[idx].overrideState = state\n    }\n    Task { await self.applyFeatureOverride(name: name, state: state) }\n  }\n\n  private func overrideValue(for state: FeatureOverrideState) -> Bool? {\n    switch state {\n    case .inherit: return nil\n    case .forceOn: return true\n    case .forceOff: return false\n    }\n  }\n\n  private func applyFeatureOverride(name: String, state: FeatureOverrideState) async {\n    do {\n      let value = overrideValue(for: state)\n      try await service.setFeatureOverride(name: name, value: value)\n      await loadFeatures()\n    } catch {\n      featureError = \"Failed to update \\(name)\"\n    }\n  }\n\n  // Notifications\n  @Published var notifySelfTestResult: String? = nil\n  @Published var notifyBridgeHealthy: Bool = false\n  func loadNotifications() async {\n    tuiNotifications = await service.getTuiNotifications()\n    let arr = await service.getNotifyArray()\n    if let bridge = arr.first {\n      // If the configured bridge is missing or not executable, try to reinstall silently.\n      if FileManager.default.isExecutableFile(atPath: bridge) {\n        systemNotifications = true\n        notifyBridgePath = bridge\n        notifyBridgeHealthy = true\n      } else {\n        if let url = try? await service.ensureNotifyBridgeInstalled() {\n          notifyBridgePath = url.path\n          systemNotifications = true\n          _ = try? await service.setNotifyArray([url.path])\n          notifyBridgeHealthy = FileManager.default.isExecutableFile(atPath: url.path)\n        } else {\n          systemNotifications = false\n          notifyBridgePath = nil\n          notifyBridgeHealthy = false\n        }\n      }\n    } else {\n      systemNotifications = false\n      notifyBridgePath = nil\n      notifyBridgeHealthy = false\n    }\n  }\n  func applyTuiNotifications() async {\n    do { try await service.setTuiNotifications(tuiNotifications) } catch {\n      lastError = \"Failed to save TUI notifications\"\n    }\n  }\n  func applySystemNotifications() async {\n    do {\n      if systemNotifications {\n        let url = try await service.ensureNotifyBridgeInstalled()\n        notifyBridgePath = url.path\n        try await service.setNotifyArray([url.path])\n        notifyBridgeHealthy = FileManager.default.isExecutableFile(atPath: url.path)\n      } else {\n        notifyBridgePath = nil\n        try await service.setNotifyArray(nil)\n        notifyBridgeHealthy = false\n      }\n    } catch { lastError = \"Failed to configure system notifications\" }\n  }\n\n  // Run a local self-test of the notify bridge; returns true on success\n  func runNotifySelfTest() async {\n    notifySelfTestResult = nil\n    // Always reinstall to ensure the latest bridge content (marker + escaping fixes)\n    let path: String =\n      (try? await service.ensureNotifyBridgeInstalled().path) ?? (notifyBridgePath ?? \"\")\n    guard !path.isEmpty else {\n      notifySelfTestResult = \"Bridge path unavailable\"\n      return\n    }\n    let payload =\n      #\"{\"type\":\"agent-turn-complete\",\"last-assistant-message\":\"Self-test: turn done\",\"thread-id\":\"codmate-selftest\"}\"#\n    do {\n      let proc = Process()\n      proc.executableURL = URL(fileURLWithPath: path)\n      proc.arguments = [payload, \"--self-test\"]\n      let outPipe = Pipe()\n      proc.standardOutput = outPipe\n      proc.standardError = Pipe()\n      try proc.run()\n      proc.waitUntilExit()\n      let outData = outPipe.fileHandleForReading.readDataToEndOfFile()\n      let outStr = String(data: outData, encoding: .utf8) ?? \"\"\n      if proc.terminationStatus == 0 {\n        if outStr.contains(\"__CODMATE_NOTIFIED__\") {\n          // Success: show a lightweight status to avoid a \"no feedback\" experience\n          await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Notifications self-test sent\")\n          notifySelfTestResult = \"Sent (check Notification Center)\"\n        } else {\n          notifySelfTestResult =\n            \"Bridge ran, but no notifier accepted (check Focus/Do Not Disturb / permissions)\"\n        }\n      } else {\n        notifySelfTestResult = \"Exited with status \\(proc.terminationStatus)\"\n      }\n    } catch {\n      notifySelfTestResult = \"Failed to run bridge\"\n    }\n  }\n\n  // Privacy\n  func loadPrivacy() async {\n    _ = await service.sanitizeQuotedBooleans()\n    let p = await service.getShellEnvironmentPolicy()\n    envInherit = p.inherit ?? envInherit\n    envIgnoreDefaults = p.ignoreDefaultExcludes ?? envIgnoreDefaults\n    envIncludeOnly = (p.includeOnly ?? []).joined(separator: \", \")\n    envExclude = (p.exclude ?? []).joined(separator: \", \")\n    envSetPairs = (p.set ?? [:]).map { \"\\($0.key)=\\($0.value)\" }.sorted().joined(\n      separator: \"\\n\")\n    hideAgentReasoning = await service.getBool(\"hide_agent_reasoning\")\n    showRawAgentReasoning = await service.getBool(\"show_raw_agent_reasoning\")\n    fileOpener = await service.getTopLevelString(\"file_opener\") ?? fileOpener\n\n    let oc = await service.getOtelConfig()\n    otelEnabled = oc.exporterKind != .none\n    otelKind = (oc.exporterKind == .otlpGrpc) ? .grpc : .http\n    otelEndpoint = oc.endpoint ?? \"\"\n  }\n\n  func applyEnvPolicy() async {\n    var dict: [String: String] = [:]\n    for line in envSetPairs.split(separator: \"\\n\") {\n      let s = String(line)\n      guard let eq = s.firstIndex(of: \"=\") else { continue }\n      let k = String(s[..<eq]).trimmingCharacters(in: .whitespaces)\n      let v = String(s[s.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n      if !k.isEmpty { dict[k] = v }\n    }\n    let policy = CodexConfigService.ShellEnvironmentPolicy(\n      inherit: envInherit,\n      ignoreDefaultExcludes: envIgnoreDefaults,\n      includeOnly: tokens(envIncludeOnly),\n      exclude: tokens(envExclude),\n      set: dict.isEmpty ? nil : dict\n    )\n    do { try await service.setShellEnvironmentPolicy(policy) } catch {\n      lastError = \"Failed to save env policy\"\n    }\n  }\n  func applyHideReasoning() async {\n    do { try await service.setBool(\"hide_agent_reasoning\", hideAgentReasoning) } catch {\n      lastError = \"Failed\"\n    }\n  }\n  func applyShowRawReasoning() async {\n    do { try await service.setBool(\"show_raw_agent_reasoning\", showRawAgentReasoning) } catch {\n      lastError = \"Failed\"\n    }\n  }\n  func applySuppressUnstableWarning() async {\n    do {\n      try await service.setBool(\"suppress_unstable_features_warning\", suppressUnstableFeaturesWarning)\n    } catch {\n      lastError = \"Failed\"\n    }\n  }\n  func applyFileOpener() async {\n    do { try await service.setFileOpener(fileOpener) } catch { lastError = \"Failed\" }\n  }\n  func applyOtel() async {\n    let kind: CodexConfigService.OtelExporterKind =\n      otelEnabled ? (otelKind == .grpc ? .otlpGrpc : .otlpHttp) : .none\n    let cfg = CodexConfigService.OtelConfig(\n      environment: nil, exporterKind: kind, endpoint: otelEndpoint)\n    do { try await service.setOtelConfig(cfg) } catch { lastError = \"Failed to save OTEL\" }\n  }\n\n  private func tokens(_ s: String) -> [String]? {\n    let arr = s.split(separator: \",\").map { $0.trimmingCharacters(in: .whitespaces) }.filter {\n      !$0.isEmpty\n    }\n    return arr.isEmpty ? nil : arr\n  }\n  // Raw config helpers\n  func reloadRawConfig() async { rawConfigText = await service.readRawConfigText() }\n  func openConfigInEditor() {\n    Task { @MainActor in\n      let url = await service.configFileURL()\n      NSWorkspace.shared.open(url)\n    }\n  }\n  private static func slugify(_ s: String) -> String {\n    let lower = s.lowercased()\n    let mapped = lower.map { c -> Character in\n      if c.isLetter || c.isNumber { return c }\n      return \"-\"\n    }\n    var collapsed: [Character] = []\n    var lastDash = false\n    for ch in mapped {\n      if ch == \"-\" {\n        if !lastDash {\n          collapsed.append(ch)\n          lastDash = true\n        }\n      } else {\n        collapsed.append(ch)\n        lastDash = false\n      }\n    }\n    while collapsed.first == \"-\" { collapsed.removeFirst() }\n    while collapsed.last == \"-\" { collapsed.removeLast() }\n    let s2 = String(collapsed)\n    return s2.isEmpty ? \"provider\" : s2\n  }\n}\n"
  },
  {
    "path": "models/CommandRecord.swift",
    "content": "import Foundation\n\n// MARK: - Command Record\n/// Represents a unified slash command that can be synced to multiple AI CLI providers\nstruct CommandRecord: Codable, Identifiable, Hashable {\n  var id: String\n  var name: String\n  var description: String\n  var prompt: String\n  var metadata: CommandMetadata\n  var targets: CommandTargets\n  var isEnabled: Bool\n  var source: String\n  var path: String  // Path to the Markdown file\n  var installedAt: Date\n\n  init(\n    id: String,\n    name: String,\n    description: String,\n    prompt: String,\n    metadata: CommandMetadata = CommandMetadata(),\n    targets: CommandTargets = CommandTargets(),\n    isEnabled: Bool = true,\n    source: String = \"user\",\n    path: String = \"\",\n    installedAt: Date = Date()\n  ) {\n    self.id = id\n    self.name = name\n    self.description = description\n    self.prompt = prompt\n    self.metadata = metadata\n    self.targets = targets\n    self.isEnabled = isEnabled\n    self.source = source\n    self.path = path\n    self.installedAt = installedAt\n  }\n}\n\n// MARK: - Command Metadata\nstruct CommandMetadata: Codable, Hashable {\n  var argumentHint: String?\n  var model: String?\n  var allowedTools: [String]?\n  var tags: [String]\n\n  init(\n    argumentHint: String? = nil,\n    model: String? = nil,\n    allowedTools: [String]? = nil,\n    tags: [String] = []\n  ) {\n    self.argumentHint = argumentHint\n    self.model = model\n    self.allowedTools = allowedTools\n    self.tags = tags\n  }\n}\n\n// MARK: - Command Targets\nstruct CommandTargets: Codable, Hashable {\n  var codex: Bool\n  var claude: Bool\n  var gemini: Bool\n\n  init(codex: Bool = true, claude: Bool = true, gemini: Bool = false) {\n    self.codex = codex\n    self.claude = claude\n    self.gemini = gemini\n  }\n\n  func isEnabled(for target: CommandTarget) -> Bool {\n    switch target {\n    case .codex: return codex\n    case .claude: return claude\n    case .gemini: return gemini\n    }\n  }\n}\n\n// MARK: - Command Target\nenum CommandTarget: String, CaseIterable {\n  case codex\n  case claude\n  case gemini\n\n  var displayName: String {\n    switch self {\n    case .codex: return \"Codex CLI\"\n    case .claude: return \"Claude Code\"\n    case .gemini: return \"Gemini CLI\"\n    }\n  }\n\n  var directoryName: String {\n    switch self {\n    case .codex: return \".codex\"\n    case .claude: return \".claude\"\n    case .gemini: return \".gemini\"\n    }\n  }\n\n  var commandsSubpath: String {\n    switch self {\n    case .codex: return \"prompts\"  // Codex uses ~/.codex/prompts/\n    case .claude: return \"commands\" // Claude uses ~/.claude/commands/\n    case .gemini: return \"commands\" // Gemini uses ~/.gemini/commands/\n    }\n  }\n\n  var baseKind: SessionSource.Kind {\n    switch self {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n}\n\n// MARK: - Command Extensions\nextension Array where Element == CommandRecord {\n  func enabledCommands(for target: CommandTarget) -> [CommandRecord] {\n    filter { $0.isEnabled && $0.targets.isEnabled(for: target) }\n  }\n}\n"
  },
  {
    "path": "models/CommandsViewModel.swift",
    "content": "import Foundation\nimport SwiftUI\n\n@MainActor\nclass CommandsViewModel: ObservableObject {\n  @Published var commands: [CommandRecord] = []\n  @Published var selectedCommandId: String? = nil\n  @Published var searchText: String = \"\"\n  @Published var showAddSheet = false\n  @Published var editingCommand: CommandRecord? = nil\n  @Published var syncWarnings: [CommandSyncWarning] = []\n  @Published var errorMessage: String? = nil\n  @Published var isLoading = false\n  @Published var showImportSheet = false\n  @Published var importCandidates: [CommandImportCandidate] = []\n  @Published var isImporting = false\n  @Published var importStatusMessage: String? = nil\n\n  private let store = CommandsStore()\n  private let syncService = CommandsSyncService()\n\n  init() {\n    Task { await load() }\n  }\n\n  var selectedCommand: CommandRecord? {\n    guard let id = selectedCommandId else { return nil }\n    return commands.first(where: { $0.id == id })\n  }\n\n  var filteredCommands: [CommandRecord] {\n    let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    if query.isEmpty {\n      return commands\n    }\n    return commands.filter { command in\n      command.name.localizedCaseInsensitiveContains(query) ||\n      command.description.localizedCaseInsensitiveContains(query) ||\n      command.prompt.localizedCaseInsensitiveContains(query)\n    }\n  }\n\n  // MARK: - Load\n  func load() async {\n    isLoading = true\n    defer { isLoading = false }\n\n    let records = await store.listWithBuiltIns()\n    commands = records\n  }\n\n  // MARK: - Import (Home)\n  func beginImportFromHome() {\n    showImportSheet = true\n    Task { await loadImportCandidatesFromHome() }\n  }\n\n  func loadImportCandidatesFromHome() async {\n    isImporting = true\n    importStatusMessage = \"Scanning…\"\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n        directory: home,\n        purpose: .generalAccess,\n        message: \"Authorize your Home folder to import commands\"\n      )\n    }\n    let existing = await store.listWithBuiltIns()\n    let existingIds = Set(existing.map(\\.id))\n\n    let scanned = await Task.detached(priority: .userInitiated) {\n      CommandsImportService.scan(scope: .home)\n    }.value\n    // CodMate store is the source of truth; provider directories can drift if edited by other tools.\n    let candidates = scanned.filter { !existingIds.contains($0.id) }\n\n    await MainActor.run {\n      self.importCandidates = candidates\n      self.isImporting = false\n      self.importStatusMessage = candidates.isEmpty ? \"No commands found.\" : nil\n    }\n  }\n\n  func cancelImport() {\n    showImportSheet = false\n    importCandidates = []\n    importStatusMessage = nil\n  }\n\n  func importSelectedCommands() async {\n    let selected = importCandidates.filter { $0.isSelected }\n    guard !selected.isEmpty else {\n      importStatusMessage = \"No commands selected.\"\n      return\n    }\n\n    var importedCount = 0\n    var importedCandidateIds: Set<String> = []\n    for item in selected {\n      let resolution = item.hasConflict ? item.resolution : .overwrite\n      switch resolution {\n      case .skip:\n        continue\n      case .overwrite, .rename:\n        let finalId = resolution == .rename\n          ? item.renameId.trimmingCharacters(in: .whitespacesAndNewlines)\n          : item.id\n        guard !finalId.isEmpty else { continue }\n        var name = item.name\n        if name == item.id && finalId != item.id {\n          name = finalId\n        }\n        let targets = CommandTargets(\n          codex: item.sources.contains(\"Codex\"),\n          claude: item.sources.contains(\"Claude\"),\n          gemini: item.sources.contains(\"Gemini\")\n        )\n        let record = CommandRecord(\n          id: finalId,\n          name: name,\n          description: item.description,\n          prompt: item.prompt,\n          metadata: item.metadata,\n          targets: targets,\n          isEnabled: true,\n          source: \"import\",\n          path: \"\",\n          installedAt: Date()\n        )\n        await store.upsert(record)\n        importedCount += 1\n        importedCandidateIds.insert(item.id)\n      }\n    }\n\n    await load()\n    await syncToProviders()\n    importStatusMessage = \"Imported \\(importedCount) command(s).\"\n    if !importedCandidateIds.isEmpty {\n      importCandidates.removeAll { importedCandidateIds.contains($0.id) }\n    }\n    if importCandidates.isEmpty {\n      closeImportSheetAfterDelay()\n    }\n  }\n\n  private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n    Task { @MainActor in\n      try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n      self.showImportSheet = false\n      self.importStatusMessage = nil\n    }\n  }\n\n  // MARK: - CRUD Operations\n  func addCommand(_ command: CommandRecord) async {\n    await store.upsert(command)\n    await load()\n    selectedCommandId = command.id\n    await syncToProviders()\n  }\n\n  func updateCommand(_ command: CommandRecord) async {\n    await store.upsert(command)\n    await load()\n    await syncToProviders()\n  }\n\n  func deleteCommand(id: String) async {\n    await store.delete(id: id)\n    if selectedCommandId == id {\n      selectedCommandId = nil\n    }\n    await load()\n    await syncToProviders()\n  }\n\n  func updateCommandEnabled(id: String, value: Bool) {\n    updateLocalCommand(id: id) { record in\n      record.isEnabled = value\n      if !value {\n        record.targets.codex = false\n        record.targets.claude = false\n        record.targets.gemini = false\n      } else {\n        record.targets.codex = true\n        record.targets.claude = true\n        record.targets.gemini = true\n      }\n    }\n    Task {\n      await store.update(id: id) { record in\n        record.isEnabled = value\n        if !value {\n          record.targets.codex = false\n          record.targets.claude = false\n          record.targets.gemini = false\n        } else {\n          record.targets.codex = true\n          record.targets.claude = true\n          record.targets.gemini = true\n        }\n      }\n      await syncToProviders()\n    }\n  }\n\n  func updateCommandTarget(id: String, target: CommandTarget, value: Bool) {\n    updateLocalCommand(id: id) { record in\n      switch target {\n      case .codex:\n        record.targets.codex = value\n      case .claude:\n        record.targets.claude = value\n      case .gemini:\n        record.targets.gemini = value\n      }\n      if value && !record.isEnabled {\n        record.isEnabled = true\n      } else if !record.targets.codex && !record.targets.claude && !record.targets.gemini {\n        record.isEnabled = false\n      }\n    }\n    Task {\n      await store.update(id: id) { record in\n        switch target {\n        case .codex:\n          record.targets.codex = value\n        case .claude:\n          record.targets.claude = value\n        case .gemini:\n          record.targets.gemini = value\n        }\n        if value && !record.isEnabled {\n          record.isEnabled = true\n        } else if !record.targets.codex && !record.targets.claude && !record.targets.gemini {\n          record.isEnabled = false\n        }\n      }\n      await syncToProviders()\n    }\n  }\n\n  // MARK: - Sync\n  func syncToProviders() async {\n    let warnings = await syncService.syncGlobal(commands: commands)\n    syncWarnings = warnings\n\n    if !warnings.isEmpty {\n      errorMessage = \"Sync completed with \\(warnings.count) warning(s)\"\n    }\n  }\n\n  func manualSync() async {\n    isLoading = true\n    defer { isLoading = false }\n\n    await syncToProviders()\n\n    if syncWarnings.isEmpty {\n      errorMessage = \"Successfully synced \\(commands.filter { $0.isEnabled }.count) commands\"\n    }\n  }\n\n  // MARK: - Import/Export\n  func importFromJSON(url: URL) async {\n    do {\n      let data = try Data(contentsOf: url)\n      let decoder = JSONDecoder()\n      decoder.dateDecodingStrategy = .iso8601\n      let imported = try decoder.decode([CommandRecord].self, from: data)\n\n      for command in imported {\n        await store.upsert(command)\n      }\n\n      await load()\n      await syncToProviders()\n\n      errorMessage = \"Successfully imported \\(imported.count) commands\"\n    } catch {\n      errorMessage = \"Import failed: \\(error.localizedDescription)\"\n    }\n  }\n\n  func exportToJSON(url: URL) async {\n    do {\n      let encoder = JSONEncoder()\n      encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]\n      encoder.dateEncodingStrategy = .iso8601\n\n      let data = try encoder.encode(commands)\n      try data.write(to: url, options: .atomic)\n\n      errorMessage = \"Successfully exported \\(commands.count) commands\"\n    } catch {\n      errorMessage = \"Export failed: \\(error.localizedDescription)\"\n    }\n  }\n\n  // MARK: - Editor\n  func openInEditor(_ command: CommandRecord, using editor: EditorApp) {\n    let path = command.path.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !path.isEmpty else {\n      errorMessage = \"Command path not available\"\n      return\n    }\n\n    var isDirectory: ObjCBool = false\n    guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),\n          !isDirectory.boolValue else {\n      errorMessage = \"Command file does not exist: \\(path)\"\n      return\n    }\n\n    if let executablePath = findExecutableInPath(editor.cliCommand) {\n      let process = Process()\n      process.executableURL = URL(fileURLWithPath: executablePath)\n      process.arguments = [path]\n      process.standardOutput = Pipe()\n      process.standardError = Pipe()\n      do {\n        try process.run()\n        return\n      } catch {\n      }\n    }\n\n    if let appURL = editor.appURL {\n      let config = NSWorkspace.OpenConfiguration()\n      config.activates = true\n      NSWorkspace.shared.open(\n        [URL(fileURLWithPath: path)],\n        withApplicationAt: appURL,\n        configuration: config\n      ) { _, error in\n        if let error = error {\n          DispatchQueue.main.async {\n            self.errorMessage = \"Failed to open \\(editor.title): \\(error.localizedDescription)\"\n          }\n        }\n      }\n      return\n    }\n\n    errorMessage = \"\\(editor.title) is not installed. Please install it or try a different editor.\"\n  }\n\n  // MARK: - Helpers\n  func canDelete(id: String) -> Bool {\n    // All commands can be deleted\n    return commands.first(where: { $0.id == id }) != nil\n  }\n\n  func enabledCount(for target: CommandTarget) -> Int {\n    commands.filter { $0.isEnabled && $0.targets.isEnabled(for: target) }.count\n  }\n\n  func isCommandTargetEnabled(id: String, target: CommandTarget) -> Bool {\n    guard let command = commands.first(where: { $0.id == id }) else { return false }\n    return command.targets.isEnabled(for: target)\n  }\n\n  var totalEnabledCount: Int {\n    commands.filter { $0.isEnabled }.count\n  }\n\n  private func updateLocalCommand(id: String, mutate: (inout CommandRecord) -> Void) {\n    guard let index = commands.firstIndex(where: { $0.id == id }) else { return }\n    var updated = commands\n    mutate(&updated[index])\n    commands = updated\n  }\n\n  private func findExecutableInPath(_ name: String) -> String? {\n    let process = Process()\n    process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n    process.arguments = [name]\n\n    let pipe = Pipe()\n    process.standardOutput = pipe\n    process.standardError = Pipe()\n\n    do {\n      try process.run()\n      process.waitUntilExit()\n      guard process.terminationStatus == 0 else { return nil }\n      let data = pipe.fileHandleForReading.readDataToEndOfFile()\n      let path = String(data: data, encoding: .utf8)?\n        .trimmingCharacters(in: .whitespacesAndNewlines)\n      return path?.isEmpty == false ? path : nil\n    } catch {\n      return nil\n    }\n  }\n}\n"
  },
  {
    "path": "models/ConversationTurn.swift",
    "content": "import Foundation\n\n// MARK: - ConversationTurnPreview\n\n/// Lightweight preview for conversation turns, used for fast initial rendering\n/// before full timeline data is loaded. Cached in SQLite for instant display.\nstruct ConversationTurnPreview: Identifiable, Hashable, Sendable, Codable {\n    let id: String  // Same stable ID as ConversationTurn\n    let sessionId: String\n    let turnIndex: Int\n    let timestamp: Date\n\n    // Preview text (truncated for display when collapsed)\n    let userPreview: String?        // First ~100 chars of user message\n    let outputsPreview: String?     // First ~100 chars of assistant/tool output\n    let outputCount: Int            // Number of output events\n\n    // Metadata flags\n    let hasToolCalls: Bool\n    let hasThinking: Bool\n\n    /// Convert a full ConversationTurn to a preview\n    init(from turn: ConversationTurn, sessionId: String, index: Int) {\n        self.id = turn.id\n        self.sessionId = sessionId\n        self.turnIndex = index\n        self.timestamp = turn.timestamp\n\n        // Extract user preview (first 100 chars)\n        if let userText = turn.userMessage?.text {\n            let trimmed = userText.trimmingCharacters(in: .whitespacesAndNewlines)\n            self.userPreview = String(trimmed.prefix(100))\n        } else {\n            self.userPreview = nil\n        }\n\n        // Extract outputs preview (first assistant or tool output, first 100 chars)\n        if let firstOutput = turn.outputs.first(where: { $0.actor == .assistant || $0.actor == .tool }),\n           let text = firstOutput.text {\n            let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)\n            self.outputsPreview = String(trimmed.prefix(100))\n        } else if let firstText = turn.outputs.first?.text {\n            let trimmed = firstText.trimmingCharacters(in: .whitespacesAndNewlines)\n            self.outputsPreview = String(trimmed.prefix(100))\n        } else {\n            self.outputsPreview = nil\n        }\n\n        self.outputCount = turn.outputs.count\n\n        // Check for tool calls and thinking\n        self.hasToolCalls = turn.outputs.contains { $0.actor == .tool }\n        self.hasThinking = turn.outputs.contains { $0.visibilityKind == .reasoning }\n    }\n\n    // Direct initializer for decoding from SQLite\n    init(\n        id: String,\n        sessionId: String,\n        turnIndex: Int,\n        timestamp: Date,\n        userPreview: String?,\n        outputsPreview: String?,\n        outputCount: Int,\n        hasToolCalls: Bool,\n        hasThinking: Bool\n    ) {\n        self.id = id\n        self.sessionId = sessionId\n        self.turnIndex = turnIndex\n        self.timestamp = timestamp\n        self.userPreview = userPreview\n        self.outputsPreview = outputsPreview\n        self.outputCount = outputCount\n        self.hasToolCalls = hasToolCalls\n        self.hasThinking = hasThinking\n    }\n}\n\n// MARK: - ConversationTurn\n\nstruct ConversationTurn: Identifiable, Hashable {\n    let id: String\n    let timestamp: Date\n    let userMessage: TimelineEvent?\n    let outputs: [TimelineEvent]\n\n    var allEvents: [TimelineEvent] {\n        var items: [TimelineEvent] = []\n        if let userMessage {\n            items.append(userMessage)\n        }\n        items.append(contentsOf: outputs)\n        return items\n    }\n\n    var actorSummary: String {\n        actorSummary(using: \"Codex\")\n    }\n\n    func actorSummary(using assistantName: String) -> String {\n        var parts: [String] = []\n        if userMessage != nil {\n            parts.append(\"User\")\n        }\n        var seen: Set<TimelineActor> = []\n        for event in outputs {\n            if seen.insert(event.actor).inserted {\n                parts.append(event.actor.displayName(assistantName: assistantName))\n            }\n        }\n        if parts.isEmpty, let first = outputs.first {\n            parts.append(first.actor.displayName(assistantName: assistantName))\n        }\n        return parts.joined(separator: \" → \")\n    }\n\n    var previewText: String? {\n        var snippets: [String] = []\n        if let text = userMessage?.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty {\n            snippets.append(text)\n        }\n        if let assistantReply = outputs.first(where: { $0.actor == .assistant })?.text?\n            .trimmingCharacters(in: .whitespacesAndNewlines),\n            !assistantReply.isEmpty\n        {\n            snippets.append(assistantReply)\n        } else if let other = outputs.first?.text?\n            .trimmingCharacters(in: .whitespacesAndNewlines),\n            !other.isEmpty\n        {\n            snippets.append(other)\n        }\n        guard !snippets.isEmpty else { return nil }\n        return snippets.joined(separator: \"\\n\")\n    }\n}\n\nprivate extension TimelineActor {\n    func displayName(assistantName: String = \"Codex\") -> String {\n        switch self {\n        case .user: return \"User\"\n        case .assistant: return assistantName\n        case .tool: return \"Tool\"\n        case .info: return \"Info\"\n        }\n    }\n}\n\nextension Array where Element == ConversationTurn {\n    func removingEnvironmentContext() -> [ConversationTurn] {\n        compactMap { turn in\n            let filteredUser = (turn.userMessage?.title == TimelineEvent.environmentContextTitle)\n                ? nil : turn.userMessage\n            let filteredOutputs = turn.outputs.filter { $0.title != TimelineEvent.environmentContextTitle }\n            if filteredUser == nil && filteredOutputs.isEmpty {\n                return nil\n            }\n            if filteredUser == turn.userMessage && filteredOutputs.count == turn.outputs.count {\n                return turn\n            }\n            return ConversationTurn(\n                id: turn.id,\n                timestamp: turn.timestamp,\n                userMessage: filteredUser,\n                outputs: filteredOutputs\n            )\n        }\n    }\n\n    func filtering(visibleKinds: Set<MessageVisibilityKind>) -> [ConversationTurn] {\n        compactMap { turn in\n            let userAllowed: Bool = {\n                guard let u = turn.userMessage else { return false }\n                return visibleKinds.contains(event: u)\n            }()\n            let keptOutputs = turn.outputs.filter { visibleKinds.contains(event: $0) }\n            if !userAllowed && keptOutputs.isEmpty { return nil }\n            return ConversationTurn(\n                id: turn.id,\n                timestamp: turn.timestamp,\n                userMessage: userAllowed ? turn.userMessage : nil,\n                outputs: keptOutputs\n            )\n        }\n    }\n\n    /// Extract all user message texts (trimmed, non-empty)\n    func extractUserMessages() -> [String] {\n        compactMap { turn in\n            turn.userMessage?.text?.trimmingCharacters(in: .whitespacesAndNewlines)\n        }.filter { !$0.isEmpty }\n    }\n\n    /// Extract the last assistant message text (from the last turn with assistant outputs)\n    func extractLastAssistantMessage() -> String? {\n        guard let lastTurn = self.last(where: { !$0.outputs.isEmpty }) else {\n            return nil\n        }\n\n        // Extract only text from assistant messages (visibilityKind == .assistant)\n        let assistantTexts = lastTurn.outputs\n            .filter { $0.visibilityKind == .assistant }\n            .compactMap { $0.text?.trimmingCharacters(in: .whitespacesAndNewlines) }\n            .filter { !$0.isEmpty }\n\n        return assistantTexts.isEmpty ? nil : assistantTexts.joined(separator: \"\\n\\n\")\n    }\n}\n"
  },
  {
    "path": "models/DateDimension.swift",
    "content": "import Foundation\n\nenum DateDimension: String, CaseIterable, Identifiable, Sendable {\n    case created\n    case updated\n\n    var id: String { rawValue }\n\n    var title: String {\n        switch self {\n        case .created: return \"Created\"\n        case .updated: return \"Last Updated\"\n        }\n    }\n}\n"
  },
  {
    "path": "models/DialecticsVM.swift",
    "content": "import Foundation\nimport SwiftUI\nimport AppKit\n\n@MainActor\nfinal class DialecticsVM: ObservableObject {\n    @Published var sessions: SessionsDiagnostics? = nil\n    @Published var codexPresent: Bool = false\n    @Published var codexVersion: String? = nil\n    @Published var claudePresent: Bool = false\n    @Published var claudeVersion: String? = nil\n    @Published var geminiPresent: Bool = false\n    @Published var geminiVersion: String? = nil\n    @Published var pathEnv: String = ProcessInfo.processInfo.environment[\"PATH\"] ?? \"\"\n    @Published var sandboxOn: Bool = ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil\n\n    private let sessionsSvc = SessionsDiagnosticsService()\n\n    func runAll(preferences: SessionPreferencesStore) async {\n        struct Snapshot {\n            let sessions: SessionsDiagnostics?\n            let codexPresent: Bool\n            let codexVersion: String?\n            let claudePresent: Bool\n            let claudeVersion: String?\n            let geminiPresent: Bool\n            let geminiVersion: String?\n            let pathEnv: String\n            let sandboxOn: Bool\n        }\n\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        let defRoot = SessionPreferencesStore.defaultSessionsRoot(for: home)\n        let notesDefault = SessionPreferencesStore.defaultNotesRoot(for: defRoot)\n        let projectsDefault = SessionPreferencesStore.defaultProjectsRoot(for: home)\n        let claudeDefault = home.appendingPathComponent(\".claude\", isDirectory: true).appendingPathComponent(\"projects\", isDirectory: true)\n        let claudeCurrent: URL? = FileManager.default.fileExists(atPath: claudeDefault.path) ? claudeDefault : nil\n        let geminiDefault = home.appendingPathComponent(\".gemini\", isDirectory: true).appendingPathComponent(\"tmp\", isDirectory: true)\n        let geminiCurrent: URL? = FileManager.default.fileExists(atPath: geminiDefault.path) ? geminiDefault : nil\n        let sessionsRoot = preferences.sessionsRoot\n        let notesRoot = preferences.notesRoot\n        let projectsRoot = preferences.projectsRoot\n\n        let sandboxed = ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil\n        if sandboxed {\n            let brew = URL(fileURLWithPath: \"/opt/homebrew/bin\", isDirectory: true)\n            let usrLocal = URL(fileURLWithPath: \"/usr/local/bin\", isDirectory: true)\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: brew)\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: usrLocal)\n        }\n\n        let snapshot = await Task.detached(priority: .userInitiated) {\n            let svc = SessionsDiagnosticsService()\n            let s = await svc.run(\n                currentRoot: sessionsRoot,\n                defaultRoot: defRoot,\n                notesCurrentRoot: notesRoot,\n                notesDefaultRoot: notesDefault,\n                projectsCurrentRoot: projectsRoot,\n                projectsDefaultRoot: projectsDefault,\n                claudeCurrentRoot: claudeCurrent,\n                claudeDefaultRoot: claudeDefault,\n                geminiCurrentRoot: geminiCurrent,\n                geminiDefaultRoot: geminiDefault\n            )\n            let mergedPATH = CLIEnvironment.resolvedPATHForCLI(sandboxed: sandboxed)\n            let resolved = CLIEnvironment.resolveExecutablePath(\"codex\", path: mergedPATH)\n            let resolvedClaude = CLIEnvironment.resolveExecutablePath(\"claude\", path: mergedPATH)\n            let resolvedGemini = CLIEnvironment.resolveExecutablePath(\"gemini\", path: mergedPATH)\n            let codexVersion = resolved.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) }\n            let claudeVersion = resolvedClaude.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) }\n            let geminiVersion = resolvedGemini.flatMap { CLIEnvironment.version(atExecutablePath: $0, path: mergedPATH) }\n            return Snapshot(\n                sessions: s,\n                codexPresent: resolved != nil,\n                codexVersion: codexVersion,\n                claudePresent: resolvedClaude != nil,\n                claudeVersion: claudeVersion,\n                geminiPresent: resolvedGemini != nil,\n                geminiVersion: geminiVersion,\n                pathEnv: mergedPATH,\n                sandboxOn: sandboxed\n            )\n        }.value\n\n        self.sessions = snapshot.sessions\n        self.codexPresent = snapshot.codexPresent\n        self.claudePresent = snapshot.claudePresent\n        self.geminiPresent = snapshot.geminiPresent\n        self.codexVersion = snapshot.codexVersion\n        self.claudeVersion = snapshot.claudeVersion\n        self.geminiVersion = snapshot.geminiVersion\n        self.pathEnv = snapshot.pathEnv\n        self.sandboxOn = snapshot.sandboxOn\n    }\n\n    var appVersion: String {\n        let info = Bundle.main.infoDictionary\n        let version = info?[\"CFBundleShortVersionString\"] as? String ?? \"—\"\n        let build = info?[\"CFBundleVersion\"] as? String ?? \"—\"\n        return \"\\(version) (\\(build))\"\n    }\n    var buildTime: String {\n        guard let exe = Bundle.main.executableURL,\n            let attrs = try? FileManager.default.attributesOfItem(atPath: exe.path),\n            let date = attrs[.modificationDate] as? Date\n        else { return \"Unavailable\" }\n        let df = DateFormatter()\n        df.dateStyle = .medium\n        df.timeStyle = .medium\n        return df.string(from: date)\n    }\n    var osVersion: String {\n        let v = ProcessInfo.processInfo.operatingSystemVersion\n        return \"macOS \\(v.majorVersion).\\(v.minorVersion).\\(v.patchVersion)\"\n    }\n\n    // MARK: - Report\n    struct CLICommandReport: Codable {\n        let detectedPath: String?\n        let detectedVersion: String?\n        let userOverridePath: String?\n        let userOverrideResolvedPath: String?\n        let userOverrideVersion: String?\n        let resolvedPath: String?\n        let resolvedVersion: String?\n    }\n\n    struct CLIReport: Codable {\n        let pathEnv: String\n        let sandboxed: Bool\n        let commands: [String: CLICommandReport]\n    }\n\n    struct RipgrepReport: Codable {\n        let cachedCoverageEntries: Int\n        let cachedToolEntries: Int\n        let cachedTokenEntries: Int\n        let lastCoverageScan: Date?\n        let lastToolScan: Date?\n        let lastTokenScan: Date?\n    }\n\n    struct SessionIndexMetaReport: Codable {\n        let lastFullIndexAt: Date?\n        let sessionCount: Int\n    }\n\n    struct SessionIndexCoverageReport: Codable {\n        let sessionCount: Int\n        let lastFullIndexAt: Date?\n        let sources: [String]\n    }\n\n    struct SessionIndexReport: Codable {\n        let meta: SessionIndexMetaReport?\n        let coverage: SessionIndexCoverageReport?\n    }\n\n    struct CacheReport: Codable {\n        let sessionIndex: SessionIndexReport?\n        let ripgrep: RipgrepReport?\n    }\n\n    struct CombinedReport: Codable {\n        let timestamp: Date\n        let appVersion: String\n        let buildTime: String\n        let osVersion: String\n        let sessions: SessionsDiagnostics?\n        let cli: CLIReport\n        let caches: CacheReport?\n    }\n\n    func saveReport(\n        preferences: SessionPreferencesStore,\n        ripgrepReport: SessionRipgrepStore.Diagnostics?,\n        indexMeta: SessionIndexMeta?,\n        cacheCoverage: SessionIndexCoverage?\n    ) {\n        let panel = NSSavePanel()\n        panel.canCreateDirectories = true\n        panel.allowedContentTypes = [.json]\n        let df = DateFormatter()\n        df.dateFormat = \"yyyyMMdd-HHmmss\"\n        let now = Date()\n        panel.nameFieldStringValue = \"CodMate-Diagnostics-\\(df.string(from: now)).json\"\n        panel.beginSheetModal(\n            for: NSApplication.shared.keyWindow ?? NSApplication.shared.windows.first!\n        ) { resp in\n            guard resp == .OK, let url = panel.url else { return }\n            let report = self.buildReport(\n                preferences: preferences,\n                now: now,\n                ripgrepReport: ripgrepReport,\n                indexMeta: indexMeta,\n                cacheCoverage: cacheCoverage\n            )\n            let enc = JSONEncoder()\n            enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n            enc.dateEncodingStrategy = .iso8601\n            if let data = try? enc.encode(report) {\n                try? data.write(to: url, options: .atomic)\n            }\n        }\n    }\n\n    @MainActor private func buildReport(\n        preferences: SessionPreferencesStore,\n        now: Date,\n        ripgrepReport: SessionRipgrepStore.Diagnostics?,\n        indexMeta: SessionIndexMeta?,\n        cacheCoverage: SessionIndexCoverage?\n    ) -> CombinedReport {\n        let path = pathEnv\n\n        func trimmedOverridePath(for kind: SessionSource.Kind) -> String? {\n            let raw: String\n            switch kind {\n            case .codex: raw = preferences.codexCommandPath\n            case .claude: raw = preferences.claudeCommandPath\n            case .gemini: raw = preferences.geminiCommandPath\n            }\n            let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n            return trimmed.isEmpty ? nil : trimmed\n        }\n\n        func commandReport(for kind: SessionSource.Kind) -> CLICommandReport {\n            let name = kind.cliExecutableName\n            let detectedPath = CLIEnvironment.resolveExecutablePath(name, path: path)\n            let detectedVersion = detectedPath.flatMap {\n                CLIEnvironment.version(atExecutablePath: $0, path: path)\n            }\n            let userOverridePath = trimmedOverridePath(for: kind)\n            let userResolvedPath = preferences.resolvedCommandOverrideURL(for: kind)?.path\n            let userVersion = userResolvedPath.flatMap {\n                CLIEnvironment.version(atExecutablePath: $0, path: path)\n            }\n            let resolvedPath = userResolvedPath ?? detectedPath\n            let resolvedVersion = userResolvedPath != nil ? userVersion : detectedVersion\n            return CLICommandReport(\n                detectedPath: detectedPath,\n                detectedVersion: detectedVersion,\n                userOverridePath: userOverridePath,\n                userOverrideResolvedPath: userResolvedPath,\n                userOverrideVersion: userVersion,\n                resolvedPath: resolvedPath,\n                resolvedVersion: resolvedVersion\n            )\n        }\n\n        let cli = CLIReport(\n            pathEnv: path,\n            sandboxed: sandboxOn,\n            commands: [\n                \"codex\": commandReport(for: .codex),\n                \"claude\": commandReport(for: .claude),\n                \"gemini\": commandReport(for: .gemini),\n            ]\n        )\n\n        let caches = CacheReport(\n            sessionIndex: SessionIndexReport(\n                meta: indexMeta.map { SessionIndexMetaReport(lastFullIndexAt: $0.lastFullIndexAt, sessionCount: $0.sessionCount) },\n                coverage: cacheCoverage.map {\n                    SessionIndexCoverageReport(\n                        sessionCount: $0.sessionCount,\n                        lastFullIndexAt: $0.lastFullIndexAt,\n                        sources: $0.sources.map(\\.rawValue)\n                    )\n                }\n            ),\n            ripgrep: ripgrepReport.map {\n                RipgrepReport(\n                    cachedCoverageEntries: $0.cachedCoverageEntries,\n                    cachedToolEntries: $0.cachedToolEntries,\n                    cachedTokenEntries: $0.cachedTokenEntries,\n                    lastCoverageScan: $0.lastCoverageScan,\n                    lastToolScan: $0.lastToolScan,\n                    lastTokenScan: $0.lastTokenScan\n                )\n            }\n        )\n\n        return CombinedReport(\n            timestamp: now,\n            appVersion: appVersion,\n            buildTime: buildTime,\n            osVersion: osVersion,\n            sessions: sessions,\n            cli: cli,\n            caches: caches\n        )\n    }\n}\n"
  },
  {
    "path": "models/EditorApp.swift",
    "content": "import Foundation\nimport AppKit\n\nenum EditorApp: String, CaseIterable, Identifiable {\n    case vscode\n    case cursor\n    case zed\n    case antigravity\n\n    var id: String { rawValue }\n    private static let menuIconSize = NSSize(width: 14, height: 14)\n\n    /// Editors that are currently available on this system.\n    /// This is computed once per launch by probing the bundle id and CLI.\n    /// Results are sorted alphabetically by title.\n    static let installedEditors: [EditorApp] = {\n        allCases.filter(\\.isInstalled).sorted(by: { $0.title < $1.title })\n    }()\n\n    var title: String {\n        switch self {\n        case .vscode: return \"Visual Studio Code\"\n        case .cursor: return \"Cursor\"\n        case .zed: return \"Zed\"\n        case .antigravity: return \"Antigravity\"\n        }\n    }\n\n    var bundleIdentifier: String {\n        switch self {\n        case .vscode: return \"com.microsoft.VSCode\"\n        case .cursor: return \"com.todesktop.230313mzl4w4u92\"\n        case .zed: return \"dev.zed.Zed\"\n        case .antigravity: return \"com.google.antigravity\"\n        }\n    }\n\n    var cliCommand: String {\n        switch self {\n        case .vscode: return \"code\"\n        case .cursor: return \"cursor\"\n        case .zed: return \"zed\"\n        case .antigravity: return \"antigravity\"\n        }\n    }\n\n    var appURL: URL? {\n        NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier)\n    }\n\n    var menuIcon: NSImage? {\n        guard let url = appURL else { return nil }\n        let image = NSWorkspace.shared.icon(forFile: url.path)\n        return resizedMenuIcon(image)\n    }\n\n    /// Check if the editor is installed on the system\n    var isInstalled: Bool {\n        // Try to find the app via bundle identifier\n        if NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil {\n            return true\n        }\n\n        // Fallback: check if CLI command is available in PATH\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n        process.arguments = [cliCommand]\n        process.standardOutput = Pipe()\n        process.standardError = Pipe()\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n            return process.terminationStatus == 0\n        } catch {\n            return false\n        }\n    }\n\n    private func resizedMenuIcon(_ image: NSImage) -> NSImage {\n        let newImage = NSImage(size: Self.menuIconSize)\n        newImage.lockFocus()\n        image.draw(\n            in: NSRect(origin: .zero, size: Self.menuIconSize),\n            from: NSRect(origin: .zero, size: image.size),\n            operation: .copy,\n            fraction: 1.0\n        )\n        newImage.unlockFocus()\n        return newImage\n    }\n}\n"
  },
  {
    "path": "models/EnvironmentContextInfo.swift",
    "content": "import Foundation\n\nstruct EnvironmentContextInfo: Equatable {\n    struct Entry: Identifiable, Equatable {\n        let key: String\n        let value: String\n\n        var id: String { key }\n    }\n\n    let timestamp: Date\n    let entries: [Entry]\n    let rawText: String?\n\n    var hasContent: Bool {\n        !entries.isEmpty || (rawText?.isEmpty == false)\n    }\n}\n"
  },
  {
    "path": "models/ExecutionPolicy.swift",
    "content": "import Foundation\n\nenum SandboxMode: String, CaseIterable, Identifiable, Codable {\n    case readOnly = \"read-only\"\n    case workspaceWrite = \"workspace-write\"\n    case dangerFullAccess = \"danger-full-access\"\n\n    var id: String { rawValue }\n    var title: String {\n        switch self {\n        case .readOnly: return \"read-only\"\n        case .workspaceWrite: return \"workspace-write\"\n        case .dangerFullAccess: return \"danger-full-access\"\n        }\n    }\n}\n\nenum ApprovalPolicy: String, CaseIterable, Identifiable, Codable {\n    case untrusted\n    case onFailure = \"on-failure\"\n    case onRequest = \"on-request\"\n    case never\n\n    var id: String { rawValue }\n    var title: String {\n        switch self {\n        case .untrusted: return \"untrusted\"\n        case .onFailure: return \"on-failure\"\n        case .onRequest: return \"on-request\"\n        case .never: return \"never\"\n        }\n    }\n}\n\n// Claude Code specific permission mode\nenum ClaudePermissionMode: String, CaseIterable, Identifiable, Codable {\n    case `default`\n    case acceptEdits\n    case bypassPermissions\n    case plan\n    var id: String { rawValue }\n}\n\nstruct ResumeOptions {\n    var sandbox: SandboxMode?\n    var approval: ApprovalPolicy?\n    var fullAuto: Bool\n    var dangerouslyBypass: Bool\n    // Claude Code advanced flags (optional)\n    var claudeDebug: Bool = false\n    var claudeDebugFilter: String? = nil\n    var claudeVerbose: Bool = false\n    var claudePermissionMode: ClaudePermissionMode? = nil\n    var claudeAllowedTools: String? = nil\n    var claudeDisallowedTools: String? = nil\n    var claudeAddDirs: String? = nil\n    var claudeIDE: Bool = false\n    var claudeStrictMCP: Bool = false\n    var claudeFallbackModel: String? = nil\n    var claudeSkipPermissions: Bool = false\n    var claudeAllowSkipPermissions: Bool = false\n    var claudeAllowUnsandboxedCommands: Bool = false\n}\n\nextension ResumeOptions {\n    var flagSandboxRaw: String? { sandbox?.rawValue }\n    var flagApprovalRaw: String? { approval?.rawValue }\n}\n"
  },
  {
    "path": "models/ExtensionsImportModels.swift",
    "content": "import Foundation\n\nenum ImportResolutionChoice: String, CaseIterable, Identifiable {\n  case skip\n  case overwrite\n  case rename\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .skip: return \"Skip\"\n    case .overwrite: return \"Overwrite\"\n    case .rename: return \"Rename\"\n    }\n  }\n}\n\nenum ExtensionsImportScope: Hashable {\n  case home\n  case project(directory: URL)\n}\n\nstruct CommandImportCandidate: Identifiable, Hashable {\n  var id: String\n  var name: String\n  var description: String\n  var prompt: String\n  var metadata: CommandMetadata\n  var sources: [String]\n  var sourcePaths: [String: String]\n  var isSelected: Bool\n  var hasConflict: Bool\n  var resolution: ImportResolutionChoice\n  var renameId: String\n\n  func hash(into hasher: inout Hasher) {\n    hasher.combine(id)\n  }\n\n  static func == (lhs: CommandImportCandidate, rhs: CommandImportCandidate) -> Bool {\n    lhs.id == rhs.id\n  }\n}\n\nstruct SkillImportCandidate: Identifiable, Hashable {\n  var id: String\n  var name: String\n  var summary: String\n  var sourcePath: String\n  var sources: [String]\n  var sourcePaths: [String: String]\n  var isSelected: Bool\n  var hasConflict: Bool\n  var conflictDetail: String?\n  var resolution: ImportResolutionChoice\n  var renameId: String\n  var suggestedId: String\n\n  func hash(into hasher: inout Hasher) {\n    hasher.combine(id)\n  }\n\n  static func == (lhs: SkillImportCandidate, rhs: SkillImportCandidate) -> Bool {\n    lhs.id == rhs.id\n  }\n}\n\nstruct MCPImportCandidate: Identifiable {\n  let id: UUID\n  var name: String\n  var kind: MCPServerKind\n  var command: String?\n  var args: [String]?\n  var env: [String: String]?\n  var url: String?\n  var headers: [String: String]?\n  var description: String?\n  var sources: [String]\n  var sourcePaths: [String: String]\n  var isSelected: Bool\n  var hasConflict: Bool\n  var hasNameCollision: Bool\n  var resolution: ImportResolutionChoice\n  var renameName: String\n  var signature: String\n}\n\nstruct HookImportCandidate: Identifiable, Hashable {\n  let id: UUID\n  var rule: HookRule\n  var sources: [String]\n  var sourcePaths: [String: String]\n  var isSelected: Bool\n  var hasConflict: Bool\n  var hasNameCollision: Bool\n  var resolution: ImportResolutionChoice\n  var renameName: String\n  var signature: String\n\n  func hash(into hasher: inout Hasher) {\n    hasher.combine(id)\n  }\n\n  static func == (lhs: HookImportCandidate, rhs: HookImportCandidate) -> Bool {\n    lhs.id == rhs.id\n  }\n}\n"
  },
  {
    "path": "models/ExtensionsSettingsTab.swift",
    "content": "import Foundation\n\nenum ExtensionsSettingsTab: String, CaseIterable, Identifiable {\n  case mcp\n  case skills\n  case commands\n  case hooks\n\n  var id: String { rawValue }\n}\n"
  },
  {
    "path": "models/ExternalTerminalProfile.swift",
    "content": "import Foundation\n\nstruct ExternalTerminalProfile: Identifiable, Codable, Equatable {\n    enum CommandStyle: String, Codable {\n        case standard\n        case warp\n    }\n\n    var id: String\n    var title: String?\n    var bundleIdentifiers: [String]?\n    var urlTemplate: String?\n    var supportsCommand: Bool?\n    var supportsDirectory: Bool?\n    var managedByCodMate: Bool?\n    var commandStyle: CommandStyle?\n\n    var displayTitle: String {\n        let trimmed = (title ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? id : trimmed\n    }\n\n    var isNone: Bool { id == \"none\" }\n    var isTerminal: Bool { id == \"terminal\" }\n    var isITerm2: Bool { id == \"iterm2\" }\n    var isWarp: Bool { commandStyleResolved == .warp || id == \"warp\" }\n\n    var commandStyleResolved: CommandStyle {\n        if let commandStyle { return commandStyle }\n        return id == \"warp\" ? .warp : .standard\n    }\n\n    var supportsCommandResolved: Bool {\n        if let supportsCommand { return supportsCommand }\n        if let urlTemplate, urlTemplate.contains(\"{command}\") { return true }\n        return isITerm2\n    }\n\n    var supportsDirectoryResolved: Bool {\n        if let supportsDirectory { return supportsDirectory }\n        return true\n    }\n\n    var resolvedBundleIdentifier: String? {\n        if isTerminal { return \"com.apple.Terminal\" }\n        guard let bundleIdentifiers, !bundleIdentifiers.isEmpty else { return nil }\n        return AppAvailability.firstInstalledBundleIdentifier(in: bundleIdentifiers) ?? bundleIdentifiers.first\n    }\n\n    var isInstalled: Bool {\n        if isTerminal { return true }\n        guard let bundleIdentifiers, !bundleIdentifiers.isEmpty else { return false }\n        return AppAvailability.isInstalled(bundleIdentifiers: bundleIdentifiers)\n    }\n\n    var isAvailable: Bool {\n        if isNone || isTerminal { return true }\n        if let bundleIdentifiers, !bundleIdentifiers.isEmpty {\n            return AppAvailability.isInstalled(bundleIdentifiers: bundleIdentifiers)\n        }\n        if urlTemplate != nil { return true }\n        return false\n    }\n\n    var usesWarpCommands: Bool { commandStyleResolved == .warp }\n}\n"
  },
  {
    "path": "models/GeminiUsageStatus.swift",
    "content": "import Foundation\n\nstruct GeminiUsageStatus: Equatable {\n  struct Bucket: Equatable {\n    let modelId: String?\n    let tokenType: String?\n    let remainingFraction: Double?\n    let remainingAmount: String?\n    let resetTime: Date?\n  }\n\n  let updatedAt: Date\n  let projectId: String?\n  let buckets: [Bucket]\n  let planType: String?  // Subscription type (AI Pro, AI Ultra, etc.)\n\n  func asProviderSnapshot(titleBadge: String? = nil) -> UsageProviderSnapshot {\n    // Group buckets by model ID to find the lowest quota per model\n    // (input/output tokens might have different quotas; show the more limiting one)\n    var modelQuotaMap: [String: Bucket] = [:]\n    for bucket in buckets {\n      guard let modelId = bucket.modelId, !modelId.isEmpty else { continue }\n      if let existing = modelQuotaMap[modelId] {\n        // Keep the bucket with lower remaining fraction (more constrained)\n        if let newFraction = bucket.remainingFraction,\n           let existingFraction = existing.remainingFraction,\n           newFraction < existingFraction\n        {\n          modelQuotaMap[modelId] = bucket\n        }\n      } else {\n        modelQuotaMap[modelId] = bucket\n      }\n    }\n\n    // Sort models by name, showing used models first (lower remaining fraction)\n    let sortedModels = modelQuotaMap.sorted { a, b in\n      let aUsed = (a.value.remainingFraction ?? 1.0) < 1.0\n      let bUsed = (b.value.remainingFraction ?? 1.0) < 1.0\n      if aUsed != bUsed { return aUsed }\n      return a.key.localizedStandardCompare(b.key) == .orderedAscending\n    }\n\n    // Create metrics for models - show ALL models with quotas\n    // Models with usage (remainingFraction < 1.0) show full details\n    // Models without usage show a simplified \"available\" state\n    let metrics: [UsageMetricSnapshot] = sortedModels.compactMap { modelId, bucket in\n      let remaining = bucket.remainingFraction?.clamped01()\n      let hasBeenUsed = (remaining ?? 1.0) < 1.0\n\n      // For unused models, show a simplified display\n      let percentText: String? = {\n        guard let remaining else { return nil }\n        if hasBeenUsed {\n          return NumberFormatter.compactPercentFormatter.string(from: NSNumber(value: remaining))\n            ?? String(format: \"%.0f%%\", remaining * 100)\n        }\n        return \"100%\"\n      }()\n\n      let label = modelId\n\n      let usageText: String? = {\n        if hasBeenUsed {\n          if let amount = bucket.remainingAmount, !amount.isEmpty {\n            return \"Remaining \\(amount)\"\n          }\n        }\n        return nil\n      }()\n\n      return UsageMetricSnapshot(\n        kind: .quota,\n        label: label,\n        usageText: usageText,\n        percentText: percentText,\n        progress: remaining ?? 1.0,\n        resetDate: bucket.resetTime,\n        fallbackWindowMinutes: 1440  // 24h default for Gemini quotas\n      )\n    }\n\n    // Availability: ready if we have any buckets, empty only if no data at all\n    let availability: UsageProviderSnapshot.Availability = buckets.isEmpty ? .empty : .ready\n\n    // Count used models\n    let usedModels = sortedModels.filter { (_, bucket) in\n      (bucket.remainingFraction ?? 1.0) < 1.0\n    }.count\n    let totalModels = sortedModels.count\n\n    let statusMessage: String? = {\n      if buckets.isEmpty {\n        return \"No Gemini usage data.\"\n      }\n      if usedModels == 0 && totalModels > 0 {\n        return \"No models used yet. Quotas available for \\(totalModels) models.\"\n      }\n      return nil\n    }()\n\n    return UsageProviderSnapshot(\n      provider: .gemini,\n      title: UsageProviderKind.gemini.displayName,\n      titleBadge: titleBadge,\n      availability: availability,\n      metrics: metrics,\n      updatedAt: updatedAt,\n      statusMessage: statusMessage,\n      origin: .builtin\n    )\n  }\n}\n\nprivate extension Double {\n  func clamped01() -> Double { max(0, min(self, 1)) }\n}\n"
  },
  {
    "path": "models/GeminiVM.swift",
    "content": "import AppKit\nimport Foundation\nimport SwiftUI\n\n@MainActor\nfinal class GeminiVM: ObservableObject {\n  struct ModelOption: Identifiable {\n    let value: String?\n    let title: String\n    let subtitle: String\n\n    var id: String { value ?? \"default\" }\n  }\n\n  @Published var previewFeatures = false\n  @Published var vimMode = false\n  @Published var disableAutoUpdate = false\n  @Published var enablePromptCompletion = false\n  @Published var sessionRetentionEnabled = false\n\n  @Published var selectedModelId: String?\n  @Published var maxSessionTurns: Int = -1\n  @Published var compressionThreshold: Double = 0.5\n  @Published var skipNextSpeakerCheck = true\n\n  @Published var notificationsEnabled = false\n  @Published var notificationBridgeHealthy = false\n  @Published var notificationSelfTestResult: String? = nil\n\n  @Published var rawSettingsText: String = \"\"\n  @Published var lastError: String?\n  @Published private(set) var hasLoadedInitialState = false\n\n  var modelOptions: [ModelOption] {\n    [\n      ModelOption(value: nil, title: \"Auto (Gemini CLI default)\", subtitle: \"CLI picks the best model depending on task complexity\"),\n      ModelOption(value: \"gemini-3-pro-preview\", title: \"Gemini 3 Pro Preview\", subtitle: \"High reasoning depth when preview access is enabled\"),\n      ModelOption(value: \"gemini-2.5-pro\", title: \"Gemini 2.5 Pro\", subtitle: \"Deep reasoning with broad tool support\"),\n      ModelOption(value: \"gemini-2.5-flash\", title: \"Gemini 2.5 Flash\", subtitle: \"Balanced speed and reasoning\"),\n      ModelOption(value: \"gemini-2.5-flash-lite\", title: \"Gemini 2.5 Flash Lite\", subtitle: \"Fastest responses for lightweight tasks\"),\n    ]\n  }\n\n  private let service = GeminiSettingsService()\n  private var notificationDebounceTask: Task<Void, Never>? = nil\n\n  func loadIfNeeded() async {\n    if hasLoadedInitialState { return }\n    await refreshSettings()\n    await loadNotificationSettings()\n    await reloadRawSettings()\n    hasLoadedInitialState = true\n  }\n\n  func refreshSettings() async {\n    let snapshot = await service.loadSnapshot()\n    previewFeatures = snapshot.previewFeatures ?? false\n    vimMode = snapshot.vimMode ?? false\n    disableAutoUpdate = snapshot.disableAutoUpdate ?? false\n    enablePromptCompletion = snapshot.enablePromptCompletion ?? false\n    sessionRetentionEnabled = snapshot.sessionRetentionEnabled ?? false\n    selectedModelId = snapshot.modelName\n    maxSessionTurns = snapshot.maxSessionTurns ?? -1\n    compressionThreshold = snapshot.compressionThreshold ?? 0.5\n    skipNextSpeakerCheck = snapshot.skipNextSpeakerCheck ?? true\n  }\n\n  func reloadRawSettings() async {\n    rawSettingsText = await service.loadRawText()\n  }\n\n  func openSettingsInEditor() {\n    let url = service.settingsFileURL\n    NSWorkspace.shared.activateFileViewerSelecting([url])\n  }\n\n  // MARK: - Apply handlers\n\n  func applyPreviewFeaturesChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.previewFeatures, at: [\"general\", \"previewFeatures\"]) }\n  }\n\n  func applyVimModeChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.vimMode, at: [\"general\", \"vimMode\"]) }\n  }\n\n  func applyDisableAutoUpdateChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.disableAutoUpdate, at: [\"general\", \"disableAutoUpdate\"]) }\n  }\n\n  func applyPromptCompletionChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.enablePromptCompletion, at: [\"general\", \"enablePromptCompletion\"]) }\n  }\n\n  func applySessionRetentionChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.sessionRetentionEnabled, at: [\"general\", \"sessionRetention\", \"enabled\"]) }\n  }\n\n  func applyModelSelectionChange() {\n    guard hasLoadedInitialState else { return }\n    let value = selectedModelId\n    persist { [self] in try await self.service.setOptionalString(value, at: [\"model\", \"name\"]) }\n  }\n\n  func applyMaxSessionTurnsChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setInt(self.maxSessionTurns, at: [\"model\", \"maxSessionTurns\"]) }\n  }\n\n  func applyCompressionThresholdChange() {\n    guard hasLoadedInitialState else { return }\n    let value = compressionThreshold\n    persist { [self] in try await self.service.setDouble(value, at: [\"model\", \"compressionThreshold\"]) }\n  }\n\n  func applySkipNextSpeakerChange() {\n    guard hasLoadedInitialState else { return }\n    persist { [self] in try await self.service.setBool(self.skipNextSpeakerCheck, at: [\"model\", \"skipNextSpeakerCheck\"]) }\n  }\n\n  func loadNotificationSettings() async {\n    let status = await service.codMateNotificationHooksStatus()\n    await MainActor.run {\n      self.notificationsEnabled = status.hookInstalled\n      self.notificationBridgeHealthy = status.hookInstalled && status.hooksEnabled\n      if !self.notificationBridgeHealthy {\n        self.notificationSelfTestResult = nil\n      }\n    }\n  }\n\n  private func applyNotificationSettings() async {\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n        directory: home.appendingPathComponent(\".gemini\", isDirectory: true),\n        purpose: .generalAccess,\n        message: \"Authorize ~/.gemini to update Gemini notifications\"\n      )\n    }\n    do {\n      try await service.setCodMateNotificationHooks(enabled: notificationsEnabled)\n      await loadNotificationSettings()\n    } catch {\n      await MainActor.run { self.lastError = \"Failed to update Gemini notifications\" }\n    }\n  }\n\n  func runNotificationSelfTest() async {\n    notificationSelfTestResult = nil\n    var comps = URLComponents()\n    comps.scheme = \"codmate\"\n    comps.host = \"notify\"\n    let title = \"CodMate\"\n    let body = \"Gemini notifications self-test\"\n    var items = [\n      URLQueryItem(name: \"source\", value: \"gemini\"),\n      URLQueryItem(name: \"event\", value: \"test\")\n    ]\n    if let titleData = title.data(using: .utf8) {\n      items.append(URLQueryItem(name: \"title64\", value: titleData.base64EncodedString()))\n    }\n    if let bodyData = body.data(using: .utf8) {\n      items.append(URLQueryItem(name: \"body64\", value: bodyData.base64EncodedString()))\n    }\n    comps.queryItems = items\n    guard let url = comps.url else {\n      notificationSelfTestResult = \"Invalid test URL\"\n      return\n    }\n    let success = NSWorkspace.shared.open(url)\n    notificationSelfTestResult = success ? \"Sent (check Notification Center)\" : \"Failed to open codmate:// URL\"\n  }\n\n  func scheduleApplyNotificationSettingsDebounced(delayMs: UInt64 = 250) {\n    notificationDebounceTask?.cancel()\n    notificationDebounceTask = Task { [weak self] in\n      guard let self else { return }\n      do { try await Task.sleep(nanoseconds: delayMs * 1_000_000) } catch { return }\n      if Task.isCancelled { return }\n      await self.applyNotificationSettings()\n    }\n  }\n\n  private func persist(_ work: @escaping () async throws -> Void) {\n    Task { @MainActor in\n      do {\n        try await work()\n        self.lastError = nil\n      } catch {\n        self.lastError = error.localizedDescription\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "models/GitChangesViewModel.swift",
    "content": "import AppKit\nimport OSLog\nimport Foundation\n\n@MainActor\nfinal class GitChangesViewModel: ObservableObject {\n    private static let log = Logger(subsystem: \"ai.codmate.app\", category: \"AICommit\")\n    @Published private(set) var repoRoot: URL? = nil\n    @Published private(set) var changes: [GitService.Change] = []\n    @Published var selectedPath: String? = nil\n    enum CompareSide: Equatable { case unstaged, staged }\n    @Published var selectedSide: CompareSide = .unstaged\n    @Published var showPreviewInsteadOfDiff: Bool = false\n    @Published var diffText: String = \"\"  // or file preview text when in preview mode\n    @Published var isLoading = false\n    @Published var errorMessage: String? = nil\n    @Published var commitMessage: String = \"\"\n    @Published var isGenerating: Bool = false\n    @Published private(set) var generatingRepoPath: String? = nil\n    @Published private(set) var isResolvingRepo: Bool = true\n    @Published private(set) var treeSnapshot: GitReviewTreeSnapshot = .empty\n\n    private let service = GitService()\n    private var monitorWorktree: DirectoryMonitor?\n    private var monitorIndex: DirectoryMonitor?\n    private var refreshTask: Task<Void, Never>? = nil\n    private var repo: GitService.Repo? = nil\n    private var generatingTask: Task<Void, Never>? = nil\n    private var treeBuildTask: Task<Void, Never>? = nil\n    private var diffTask: Task<Void, Never>? = nil\n    private var treeSnapshotGeneration: UInt64 = 0\n    private var lastRefreshToken: Int? = nil\n    private var explorerFallbackRoot: URL? = nil\n\n    func attach(to directory: URL, fallbackProjectDirectory: URL? = nil) {\n        isResolvingRepo = true\n        explorerFallbackRoot = fallbackProjectDirectory ?? directory\n        Task { [weak self] in\n            guard let self else { return }\n            defer { self.isResolvingRepo = false }\n            await self.resolveRepoRoot(from: directory, fallbackProjectDirectory: fallbackProjectDirectory)\n            await self.refreshStatus()\n            self.configureMonitors()\n        }\n    }\n\n    func detach() {\n        monitorWorktree?.cancel(); monitorWorktree = nil\n        monitorIndex?.cancel(); monitorIndex = nil\n        treeBuildTask?.cancel(); treeBuildTask = nil\n        diffTask?.cancel(); diffTask = nil\n        repo = nil\n        repoRoot = nil\n        explorerFallbackRoot = nil\n        changes = []\n        selectedPath = nil\n        diffText = \"\"\n        isResolvingRepo = false\n        treeSnapshot = .empty\n    }\n\n    private func resolveRepoRoot(from directory: URL, fallbackProjectDirectory: URL?) async {\n        let canonical = directory\n        if let repo = await service.repositoryRoot(for: canonical) {\n            assignRepoRoot(to: repo.root, reason: \"git-cli (session)\")\n            return\n        }\n        if let fsRoot = filesystemGitRoot(startingAt: canonical) {\n            assignRepoRoot(to: fsRoot, reason: \"filesystem (session)\")\n            return\n        }\n        if let fallback = fallbackProjectDirectory {\n            if let repo = await service.repositoryRoot(for: fallback) {\n                assignRepoRoot(to: repo.root, reason: \"git-cli (project)\")\n                return\n            }\n            if hasGitDirectory(at: fallback) {\n                assignRepoRoot(to: fallback.standardizedFileURL, reason: \"project directory\")\n                return\n            }\n        }\n        Self.log.warning(\"No Git repository found starting from \\(directory.path, privacy: .public)\")\n        self.repo = nil\n        self.repoRoot = nil\n        errorMessage = \"No Git repository found\"\n    }\n\n    private func assignRepoRoot(to root: URL, reason: String) {\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let hasBookmark = SecurityScopedBookmarks.shared.hasDynamicBookmark(for: root)\n            #if DEBUG\n            Self.log.info(\"Repository at \\(root.path, privacy: .public) has bookmark: \\(hasBookmark, privacy: .public)\")\n            #endif\n\n            let hasAccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: root)\n            #if DEBUG\n            Self.log.info(\"Started access for \\(root.path, privacy: .public): \\(hasAccess, privacy: .public)\")\n            #endif\n\n            if !hasAccess {\n                Self.log.error(\"Failed to start access for repository at \\(root.path, privacy: .public)\")\n                if hasBookmark {\n                    errorMessage = \"Repository access failed. The bookmark may be stale. Please re-authorize.\"\n                } else {\n                    errorMessage = \"Repository access required. Please authorize the repository folder: \\(root.path)\"\n                }\n            }\n        }\n        self.repo = GitService.Repo(root: root)\n        self.repoRoot = root\n        #if DEBUG\n        Self.log.info(\"Git repository resolved via \\(reason, privacy: .public): \\(root.path, privacy: .public)\")\n        #endif\n    }\n\n    private func filesystemGitRoot(startingAt start: URL) -> URL? {\n        var cur = start.standardizedFileURL\n        var guardCounter = 0\n        while guardCounter < 200 {\n            if hasGitDirectory(at: cur) { return cur }\n            let parent = cur.deletingLastPathComponent()\n            if parent.path == cur.path { break }\n            cur = parent\n            guardCounter += 1\n        }\n        return nil\n    }\n\n    private func hasGitDirectory(at url: URL) -> Bool {\n        let gitDir = url.appendingPathComponent(\".git\", isDirectory: true)\n        var isDir: ObjCBool = false\n        return FileManager.default.fileExists(atPath: gitDir.path, isDirectory: &isDir) && isDir.boolValue\n    }\n\n    private func configureMonitors() {\n        guard let root = repoRoot else { return }\n        // Monitor the worktree directory (non-recursive; still good enough to get write pulses)\n        monitorWorktree?.cancel()\n        monitorWorktree = DirectoryMonitor(url: root) { [weak self] in self?.scheduleRefresh() }\n        // Monitor .git/index changes (staging updates)\n        let indexURL = root.appendingPathComponent(\".git/index\")\n        monitorIndex?.cancel()\n        monitorIndex = DirectoryMonitor(url: indexURL) { [weak self] in self?.scheduleRefresh() }\n    }\n\n    private func scheduleRefresh() {\n        refreshTask?.cancel()\n        refreshTask = Task { @MainActor [weak self] in\n            guard let self else { return }\n            try? await Task.sleep(nanoseconds: 200_000_000)\n            await self.refreshStatus()\n        }\n    }\n\n    private func scheduleTreeSnapshotRefresh() {\n        treeBuildTask?.cancel()\n        treeSnapshotGeneration &+= 1\n        let generation = treeSnapshotGeneration\n        let snapshotInput = self.changes\n        treeBuildTask = Task { [weak self] in\n            guard let self else { return }\n            let built = await Task.detached(priority: .userInitiated) {\n                GitReviewTreeBuilder.buildSnapshot(from: snapshotInput)\n            }.value\n            guard !Task.isCancelled else { return }\n            if self.treeSnapshotGeneration == generation {\n                self.treeSnapshot = built\n            }\n        }\n    }\n\n    func refreshStatus() async {\n        guard let repo = self.repo else {\n            changes = []; selectedPath = nil; diffText = \"\"; return\n        }\n\n        // Ensure we have access before executing git commands\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let hasAccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: repo.root)\n            if !hasAccess {\n                Self.log.error(\"Failed to start access for repository at \\(repo.root.path, privacy: .public)\")\n                errorMessage = \"Repository access required. Please authorize the repository folder.\"\n                changes = []\n                return\n            }\n        }\n\n        isLoading = true\n        errorMessage = nil // Clear previous errors\n        let list = await service.status(in: repo)\n        isLoading = false\n\n        if list.isEmpty {\n            if let failure = await service.takeLastFailureDescription() {\n                errorMessage = Self.describeGitFailure(failure)\n            }\n        } else {\n            _ = await service.takeLastFailureDescription()\n        }\n\n        if list.isEmpty && SecurityScopedBookmarks.shared.isSandboxed {\n            // Verify git can actually access the repository\n            Self.log.warning(\"Git status returned empty for \\(repo.root.path, privacy: .public)\")\n        }\n\n        changes = list\n        scheduleTreeSnapshotRefresh()\n        // Maintain selection when possible\n        if let sel = selectedPath, !list.contains(where: { $0.path == sel }) {\n            selectedPath = nil\n            diffText = \"\"\n        }\n        await refreshDetail()\n    }\n\n    func refreshStatusIfNeeded(refreshToken: Int) async {\n        if lastRefreshToken == refreshToken { return }\n        lastRefreshToken = refreshToken\n        await refreshStatus()\n    }\n\n    func refreshDetail() async {\n        diffTask?.cancel()\n        guard let path = selectedPath else { diffText = \"\"; return }\n        let currentRepo = self.repo\n\n        if currentRepo == nil, showPreviewInsteadOfDiff, let base = repoRoot ?? explorerFallbackRoot {\n            let url = base.appendingPathComponent(path)\n            if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {\n                diffText = text\n            } else {\n                diffText = \"(Preview unavailable)\"\n            }\n            return\n        }\n        guard let repo = currentRepo else { diffText = \"\"; return }\n\n        // Ensure access before reading files\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repo.root)\n        }\n\n        let showPreview = showPreviewInsteadOfDiff\n        let selectedSide = self.selectedSide\n        let changesSnapshot = self.changes\n        let service = self.service\n\n        diffTask = Task { [weak self] in\n            guard let self else { return }\n            let text = await Task.detached(priority: .userInitiated) {\n                await Self.computeDiffText(\n                    service: service,\n                    repo: repo,\n                    path: path,\n                    showPreview: showPreview,\n                    selectedSide: selectedSide,\n                    changes: changesSnapshot\n                )\n            }.value\n            guard !Task.isCancelled else { return }\n            if self.selectedPath == path,\n               self.selectedSide == selectedSide,\n               self.showPreviewInsteadOfDiff == showPreview {\n                self.diffText = text\n            }\n        }\n    }\n\n    private static func computeDiffText(\n        service: GitService,\n        repo: GitService.Repo,\n        path: String,\n        showPreview: Bool,\n        selectedSide: CompareSide,\n        changes: [GitService.Change]\n    ) async -> String {\n        if showPreview {\n            return await service.readFile(in: repo, path: path)\n        }\n\n        let isStagedSide = (selectedSide == .staged)\n        var text = await service.diff(in: repo, path: path, staged: isStagedSide)\n        if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            if isStagedSide {\n                text = await service.diff(in: repo, path: path, staged: false)\n            }\n            if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,\n               let kind = changes.first(where: { $0.path == path })?.worktree,\n               kind == .untracked {\n                let content = await service.readFile(in: repo, path: path)\n                text = syntheticDiff(forPath: path, content: content)\n            }\n        }\n        return text\n    }\n\n    private static func syntheticDiff(forPath path: String, content: String) -> String {\n        // Produce a minimal unified diff for a new (untracked) file vs /dev/null\n        let lines = content.split(separator: \"\\\\n\", omittingEmptySubsequences: false)\n        let count = lines.count\n        var out: [String] = []\n        out.append(\"--- /dev/null\")\n        out.append(\"+++ b/\\(path)\")\n        out.append(\"@@ -0,0 +\\(count) @@\")\n        for l in lines { out.append(\"+\" + String(l)) }\n        return out.joined(separator: \"\\\\n\")\n    }\n\n    private static func describeGitFailure(_ raw: String) -> String {\n        let message = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n        if message.isEmpty {\n            return \"The git command failed without returning an error message.\"\n        }\n        if message.contains(\"App Sandbox\") || message.contains(\"xcrun: error\") {\n            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.\"\n        }\n        if message.contains(\"not a git repository\") {\n            return \"The current directory is not a Git repository.\"\n        }\n        return message\n    }\n\n\n    func toggleStage(for paths: [String]) async {\n        guard let repo = self.repo else { return }\n        // Determine which ones are staged\n        let staged: Set<String> = Set(changes.compactMap { ($0.staged != nil) ? $0.path : nil })\n        let toUnstage = paths.filter { staged.contains($0) }\n        let toStage = paths.filter { !staged.contains($0) }\n        if !toStage.isEmpty { await service.stage(in: repo, paths: toStage) }\n        if !toUnstage.isEmpty { await service.unstage(in: repo, paths: toUnstage) }\n        await refreshStatus()\n    }\n\n    // Explicit stage only\n    func stage(paths: [String]) async {\n        guard let repo = self.repo, !paths.isEmpty else { return }\n        await service.stage(in: repo, paths: paths)\n        await refreshStatus()\n    }\n\n    // Explicit unstage only\n    func unstage(paths: [String]) async {\n        guard let repo = self.repo, !paths.isEmpty else { return }\n        await service.unstage(in: repo, paths: paths)\n        await refreshStatus()\n    }\n\n    // Folder action: stage remaining if not all staged, otherwise unstage all\n    func applyFolderStaging(for dirKey: String, paths: [String]) async {\n        guard !paths.isEmpty else { return }\n        let stagedSet: Set<String> = Set(changes.compactMap { ($0.staged != nil) ? $0.path : nil })\n        let allStaged = paths.allSatisfy { stagedSet.contains($0) }\n        if allStaged {\n            await unstage(paths: paths)\n        } else {\n            let toStage = paths.filter { !stagedSet.contains($0) }\n            await stage(paths: toStage)\n        }\n    }\n\n    func commit() async {\n        guard let repo = self.repo else { return }\n        let code = await service.commit(in: repo, message: commitMessage)\n        if code == 0 {\n            commitMessage = \"\"\n            await refreshStatus()\n        } else {\n            errorMessage = \"Commit failed (exit code \\(code))\"\n        }\n    }\n\n    // MARK: - Discard\n    // includeStaged=false matches VS Code Git Graph semantics:\n    // only discard unstaged/worktree changes, preserving any staged changes.\n    func discard(paths: [String], includeStaged: Bool = false) async {\n        guard let repo = self.repo else { return }\n        let pathSet = Set(paths)\n        let map: [String: GitService.Change] = Dictionary(uniqueKeysWithValues: changes.map { ($0.path, $0) })\n\n        var untracked: [String] = []\n        var trackedWorktreeOnly: [String] = []\n        var trackedFullReset: [String] = []\n\n        for p in pathSet {\n            guard let change = map[p] else { continue }\n            if change.worktree == .untracked {\n                untracked.append(p)\n                continue\n            }\n            // Tracked file\n            if includeStaged {\n                // Discard both staged and unstaged changes\n                if change.staged != nil || change.worktree != nil {\n                    trackedFullReset.append(p)\n                }\n            } else {\n                // Discard only unstaged/worktree changes, keep any staged state\n                if change.worktree != nil {\n                    trackedWorktreeOnly.append(p)\n                }\n            }\n        }\n\n        if includeStaged {\n            if !trackedFullReset.isEmpty {\n                _ = await service.discardTracked(in: repo, paths: trackedFullReset)\n            }\n        } else {\n            if !trackedWorktreeOnly.isEmpty {\n                _ = await service.discardWorktree(in: repo, paths: trackedWorktreeOnly)\n            }\n        }\n\n        if !untracked.isEmpty {\n            _ = await service.cleanUntracked(in: repo, paths: untracked)\n        }\n        await refreshStatus()\n    }\n\n    // MARK: - Open in external editor (file)\n    func openFile(_ path: String, using editor: EditorApp) {\n        guard let root = repoRoot ?? explorerFallbackRoot else { return }\n        let filePath = root.appendingPathComponent(path).path\n        // Try CLI command first\n        if let exe = Self.findExecutableInPath(editor.cliCommand) {\n            let p = Process()\n            p.executableURL = URL(fileURLWithPath: exe)\n            p.arguments = [filePath]\n            p.standardOutput = Pipe(); p.standardError = Pipe()\n            do {\n                try p.run(); return\n            } catch {\n                // fall through\n            }\n        }\n        // Fallback: open via bundle id\n        if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) {\n            let config = NSWorkspace.OpenConfiguration(); config.activates = true\n            NSWorkspace.shared.open([URL(fileURLWithPath: filePath)], withApplicationAt: appURL, configuration: config) { _, err in\n                if let err {\n                    Task { @MainActor in self.errorMessage = \"Failed to open \\(editor.title): \\(err.localizedDescription)\" }\n                }\n            }\n            return\n        }\n        errorMessage = \"\\(editor.title) is not installed. Please install it or try a different editor.\"\n    }\n\n    func listVisiblePaths(limit: Int) async -> GitService.VisibleFilesResult? {\n        guard let repo else { return nil }\n        return await service.listVisibleFiles(in: repo, limit: limit)\n    }\n\n    private static func findExecutableInPath(_ name: String) -> String? {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n        process.arguments = [name]\n        let pipe = Pipe(); process.standardOutput = pipe; process.standardError = Pipe()\n        do {\n            try process.run(); process.waitUntilExit()\n            guard process.terminationStatus == 0 else { return nil }\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)\n            return (path?.isEmpty == false) ? path : nil\n        } catch { return nil }\n    }\n\n    // MARK: - Commit message generation (minimal pass)\n    func generateCommitMessage(providerId: String? = nil, modelId: String? = nil, maxBytes: Int = 128 * 1024) {\n        // Debounce: if already generating for the same repo, ignore\n        if isGenerating, let current = repoRoot?.path, generatingRepoPath == current {\n            #if DEBUG\n            print(\"[AICommit] Debounced: generation already in progress for repo=\\(current)\")\n            #endif\n            Self.log.info(\"Debounced: generation already in progress for repo=\\(current, privacy: .public)\")\n            return\n        }\n        generatingTask = Task { [weak self] in\n            guard let self else { return }\n            let shouldNotify = SessionPreferencesStore.isCommitMessageNotificationEnabled()\n            let statusToken = StatusBarLogStore.shared.beginTask(\n                \"Generating commit message...\",\n                level: .info,\n                source: \"Git\"\n            )\n            var finalStatus: (message: String, level: StatusBarLogLevel)?\n            defer {\n                if let finalStatus {\n                    StatusBarLogStore.shared.endTask(\n                        statusToken,\n                        message: finalStatus.message,\n                        level: finalStatus.level,\n                        source: \"Git\"\n                    )\n                } else {\n                    StatusBarLogStore.shared.endTask(statusToken)\n                }\n            }\n            guard let repo = self.repo else {\n                if shouldNotify {\n                    await SystemNotifier.shared.notify(\n                        title: \"AI Commit\",\n                        body: \"Cannot generate commit message: not a Git repository.\",\n                        threadId: \"ai-commit\"\n                    )\n                }\n                finalStatus = (\"Not a Git repository\", .error)\n                return\n            }\n            let repoPath = repo.root.path\n            await MainActor.run {\n                self.isGenerating = true\n                self.generatingRepoPath = repoPath\n            }\n            defer { Task { @MainActor in\n                self.isGenerating = false\n                self.generatingRepoPath = nil\n            } }\n            // Fetch staged diff (index vs HEAD)\n            let full = await self.service.stagedUnifiedDiff(in: repo)\n            if full.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                if shouldNotify {\n                    await SystemNotifier.shared.notify(\n                        title: \"AI Commit\",\n                        body: \"No staged changes to summarize.\",\n                        threadId: \"ai-commit\"\n                    )\n                }\n                #if DEBUG\n                print(\"[AICommit] No staged changes; generation skipped\")\n                #endif\n                Self.log.info(\"No staged changes; generation skipped\")\n                finalStatus = (\"No staged changes to summarize\", .warning)\n                return\n            }\n            // Truncate by bytes for safety\n            let truncated = Self.prefixBytes(of: full, maxBytes: maxBytes)\n            let prompt = Self.commitPrompt(diff: truncated)\n            let llm = LLMHTTPService()\n            #if DEBUG\n            print(\"[AICommit] Start generation providerId=\\(providerId ?? \"(auto)\") bytes=\\(truncated.utf8.count)\")\n            #endif\n            Self.log.info(\"Start generation providerId=\\(providerId ?? \"(auto)\", privacy: .public) bytes=\\(truncated.utf8.count)\")\n            do {\n                // Allow a slightly longer timeout for commit generation to reduce provider-specific timeouts\n                var options = LLMHTTPService.Options()\n                options.preferred = .auto\n                options.model = modelId\n                options.timeout = 45\n                options.providerId = providerId\n                options.maxTokens = 800\n                options.systemPrompt = \"Return only the commit message text. No labels, explanations, or extra commentary.\"\n                let res = try await llm.generateText(prompt: prompt, options: options)\n                let raw = res.text.trimmingCharacters(in: .whitespacesAndNewlines)\n                let cleaned = Self.cleanCommitMessage(from: raw)\n                let finalMessage = cleaned.isEmpty ? raw : cleaned\n                await MainActor.run {\n                    guard self.repoRoot?.path == repoPath else {\n                        // Repo changed during generation; drop the result\n                        #if DEBUG\n                        print(\"[AICommit] Repo switched during generation; result discarded for repo=\\(repoPath)\")\n                        #endif\n                        return\n                    }\n                    if finalMessage.isEmpty {\n                        // Leave commit message unchanged; rely on system notification\n                        return\n                    }\n                    self.commitMessage = finalMessage\n                }\n                if finalMessage.isEmpty {\n                    #if DEBUG\n                    print(\"[AICommit] Empty response from provider=\\(res.providerId), elapsedMs=\\(res.elapsedMs)\")\n                    #endif\n                    Self.log.warning(\"Empty commit message from provider=\\(res.providerId, privacy: .public)\")\n                    finalStatus = (\"Empty commit message from provider\", .warning)\n                } else {\n                    let preview = finalMessage.prefix(120)\n                    #if DEBUG\n                    print(\"[AICommit] Success provider=\\(res.providerId) elapsedMs=\\(res.elapsedMs) msg=\\(preview)\")\n                    #endif\n                    Self.log.info(\"Success provider=\\(res.providerId, privacy: .public) elapsedMs=\\(res.elapsedMs) msg=\\(String(preview), privacy: .public)\")\n                    finalStatus = (\"Commit message ready\", .success)\n                }\n                if shouldNotify {\n                    await SystemNotifier.shared.notify(\n                        title: \"AI Commit\",\n                        body: finalMessage.isEmpty\n                            ? \"Generation completed but returned an empty commit message.\"\n                            : \"Generated commit message (\\(res.providerId)) in \\(res.elapsedMs)ms\",\n                        threadId: \"ai-commit\"\n                    )\n                }\n            } catch {\n                #if DEBUG\n                print(\"[AICommit] Error: \\(error.localizedDescription)\")\n                #endif\n                Self.log.error(\"Generation error: \\(error.localizedDescription, privacy: .public)\")\n                if shouldNotify {\n                    await SystemNotifier.shared.notify(\n                        title: \"AI Commit\",\n                        body: \"Generation failed: \\(error.localizedDescription)\",\n                        threadId: \"ai-commit\"\n                    )\n                }\n                finalStatus = (\"Generation failed: \\(error.localizedDescription)\", .error)\n            }\n        }\n    }\n\n    private static func prefixBytes(of s: String, maxBytes: Int) -> String {\n        guard maxBytes > 0 else { return \"\" }\n        let data = s.data(using: .utf8) ?? Data()\n        if data.count <= maxBytes { return s }\n        let slice = data.prefix(maxBytes)\n        return String(data: slice, encoding: .utf8) ?? String(s.prefix(maxBytes / 2))\n    }\n\n    private static func commitPrompt(diff: String) -> String {\n        // Allow user override via Settings › Git Review template stored in preferences.\n        // The template acts as a preamble; we always append the diff after it.\n        let key = \"git.review.commitPromptTemplate\"\n        let outputPrefix = \"Output only the commit message. Do not add any extra text.\"\n        let basePrompt: String\n        if let tpl = UserDefaults.standard.string(forKey: key), !tpl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            basePrompt = tpl\n        } else if let payload = Self.payloadCommitPrompt {\n            basePrompt = payload\n        } else {\n            basePrompt = [\n                \"Write a Conventional Commit in imperative mood.\",\n                \"Include a concise subject line (type: scope? subject).\",\n                \"Optionally add a brief body (2-4 lines) explaining motivation and key changes.\",\n                \"Constraints: subject <= 80 chars; wrap body lines <= 72 chars; no trailing period in subject.\"\n            ].joined(separator: \"\\n\")\n        }\n        return [outputPrefix, \"\", basePrompt, \"\", \"Diff:\", diff].joined(separator: \"\\n\")\n    }\n\n    private static let payloadCommitPrompt: String? = {\n        let bundle = Bundle.main\n        guard let url = bundle.url(forResource: \"commit-message\", withExtension: \"md\", subdirectory: \"payload/prompts\") else {\n            return nil\n        }\n        guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n        let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? nil : trimmed\n    }()\n\n    private static func cleanCommitMessage(from raw: String) -> String {\n        var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n        // Remove surrounding code fences if any\n        if s.hasPrefix(\"```\") {\n            if let range = s.range(of: \"```\", options: [], range: s.index(s.startIndex, offsetBy: 3)..<s.endIndex) {\n                s = String(s[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)\n                if let end = s.range(of: \"```\") { s = String(s[..<end.lowerBound]) }\n            }\n        }\n        // Strip surrounding quotes if the whole text is quoted\n        if (s.hasPrefix(\"\\\"\") && s.hasSuffix(\"\\\"\")) || (s.hasPrefix(\"'\") && s.hasSuffix(\"'\")) {\n            s = String(s.dropFirst().dropLast())\n        }\n        // Collapse spaces\n        while s.contains(\"  \") { s = s.replacingOccurrences(of: \"  \", with: \" \") }\n        return s\n    }\n}\n"
  },
  {
    "path": "models/GitGraphViewModel.swift",
    "content": "import Foundation\nimport SwiftUI\n\n@MainActor\nfinal class GitGraphViewModel: ObservableObject {\n    @Published private(set) var commits: [GitService.GraphCommit] = []\n    @Published var filteredCommits: [GitService.GraphCommit] = []\n    @Published var selectedCommit: GitService.GraphCommit? = nil\n    @Published var searchQuery: String = \"\"\n    @Published private(set) var isLoading: Bool = false\n    @Published private(set) var errorMessage: String? = nil\n    // Graph lane layout\n    struct LaneInfo: Sendable, Hashable {\n        var laneIndex: Int                // index of the commit's own lane\n        var parentLaneIndices: [Int]      // lane indices of parents in next row\n        var activeLaneCount: Int          // lanes count to consider for verticals this row\n        var continuingLanes: Set<Int>     // lanes that should show a vertical line in this row\n        var joinLaneIndices: [Int]        // additional lanes carrying the same commit id (branch joins)\n    }\n    @Published private(set) var laneInfoById: [String: LaneInfo] = [:]\n    @Published private(set) var maxLaneCount: Int = 1\n\n    // Pagination & Incremental Layout State\n    @Published var hasMoreCommits: Bool = true\n    @Published var isLoadingMore: Bool = false\n    private var skip: Int = 0\n    private let pageSize: Int = 25  // Reduced from 50 for faster initial render\n    private var currentLanesState: [String?] = []\n\n    // Pre-computed row data for performance\n    struct CommitRowData: Identifiable, Equatable {\n        let id: String\n        let commit: GitService.GraphCommit\n        let index: Int\n        let laneInfo: LaneInfo?\n        let isFirst: Bool\n        let isLast: Bool\n        let isWorkingTree: Bool\n        let isStriped: Bool\n\n        static func == (lhs: CommitRowData, rhs: CommitRowData) -> Bool {\n            lhs.id == rhs.id &&\n            lhs.index == rhs.index &&\n            lhs.laneInfo == rhs.laneInfo &&\n            lhs.isFirst == rhs.isFirst &&\n            lhs.isLast == rhs.isLast &&\n            lhs.isStriped == rhs.isStriped\n        }\n    }\n    @Published private(set) var rowData: [CommitRowData] = []\n\n    private let service = GitService()\n    private var repo: GitService.Repo? = nil\n    private var laneLayoutTask: Task<Void, Never>? = nil\n    private var laneLayoutGeneration: Int = 0\n    private var refreshTask: Task<Void, Never>? = nil\n    private var detailTask: Task<Void, Never>? = nil\n    private var historyActionTask: Task<Void, Never>? = nil\n\n    // Branch scope controls\n    @Published var showAllBranches: Bool = true\n    @Published var showRemoteBranches: Bool = true\n    // limit is replaced by pagination logic\n    @Published var branches: [String] = []\n    @Published var selectedBranch: String? = nil   // nil = current HEAD when showAllBranches == false\n    @Published private(set) var workingChangesCount: Int = 0\n    @Published var branchSearchQuery: String = \"\"\n    @Published var isLoadingBranches: Bool = false\n    @Published private(set) var fullBranchList: [String] = []  // Cache full list\n    private var branchesTask: Task<Void, Never>? = nil\n\n    // Detail panel state (files + per-file patch)\n    @Published private(set) var detailFiles: [GitService.FileChange] = []\n    @Published var selectedDetailFile: String? = nil\n    @Published private(set) var detailFilePatch: String = \"\"\n    @Published private(set) var isLoadingDetail: Bool = false\n    @Published private(set) var detailMessage: String = \"\"\n    enum HistoryAction: String {\n        case fetch, pull, push\n\n        var displayName: String {\n            switch self {\n            case .fetch: return \"Fetch\"\n            case .pull: return \"Pull\"\n            case .push: return \"Push\"\n            }\n        }\n    }\n    @Published private(set) var historyActionInProgress: HistoryAction? = nil\n\n    deinit {\n        laneLayoutTask?.cancel()\n        refreshTask?.cancel()\n        detailTask?.cancel()\n        historyActionTask?.cancel()\n        branchesTask?.cancel()\n    }\n\n    func attach(to root: URL?) {\n        guard let root else { commits = []; filteredCommits = []; return }\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: root)\n        }\n        self.repo = GitService.Repo(root: root)\n        // Don't load branches immediately - will load on-demand when picker is opened\n        reload()\n    }\n\n    func triggerRefresh() {\n        reload()\n    }\n\n    func reload() {\n        guard let _ = self.repo else { return }\n        refreshTask?.cancel()\n        laneLayoutTask?.cancel()\n        \n        // Reset state\n        skip = 0\n        hasMoreCommits = true\n        currentLanesState = []\n        commits = []\n        filteredCommits = []\n        laneInfoById = [:]\n        maxLaneCount = 1\n        rowData = []\n        laneLayoutGeneration &+= 1\n        \n        refreshTask = Task { [weak self] in\n            guard let self else { return }\n            await loadPage(isInitial: true)\n        }\n    }\n    \n    func loadMore() {\n        guard !isLoading, !isLoadingMore, hasMoreCommits, let _ = self.repo else { return }\n        isLoadingMore = true\n        refreshTask = Task { [weak self] in\n            guard let self else { return }\n            await loadPage(isInitial: false)\n        }\n    }\n\n    private func loadPage(isInitial: Bool) async {\n        guard let repo = self.repo else { return }\n        \n        await MainActor.run {\n            if isInitial { isLoading = true }\n        }\n        \n        let newCommits = await service.logGraphCommits(\n            in: repo,\n            limit: self.pageSize,\n            skip: self.skip,\n            includeAllBranches: self.showAllBranches,\n            includeRemoteBranches: self.showRemoteBranches,\n            singleRef: (self.showAllBranches ? nil : (self.selectedBranch?.isEmpty == false ? self.selectedBranch : nil))\n        )\n        \n        // Working tree virtual entry (only on initial load)\n        var finalList = newCommits\n        if isInitial {\n            let status = await service.status(in: repo)\n            self.workingChangesCount = status.count\n            if self.workingChangesCount > 0 {\n                let headId = newCommits.first?.id\n                let virtual = GitService.GraphCommit(\n                    id: \"::working-tree::\",\n                    shortId: \"*\",\n                    author: \"*\",\n                    date: \"0 seconds ago\",\n                    subject: \"Uncommitted Changes (\\(status.count))\",\n                    parents: headId != nil ? [headId!] : [],\n                    decorations: []\n                )\n                finalList = [virtual] + newCommits\n            }\n        }\n        \n        await MainActor.run {\n            if isInitial {\n                self.commits = finalList\n                self.isLoading = false\n                if self.selectedCommit == nil { self.selectedCommit = finalList.first }\n            } else {\n                // Append new commits\n                self.commits.append(contentsOf: newCommits)\n                self.isLoadingMore = false\n            }\n            \n            if newCommits.count < self.pageSize {\n                self.hasMoreCommits = false\n            }\n            self.skip += newCommits.count\n            \n            // Update filtered list so rows appear immediately\n            self.applyFilter()\n            \n            // Trigger incremental layout\n            self.computeIncrementalLayout(newCommits: isInitial ? finalList : newCommits, isInitial: isInitial)\n        }\n    }\n\n    func applyFilter() {\n        let q = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n        guard !q.isEmpty else {\n            filteredCommits = commits\n            buildRowData()\n            return\n        }\n        // Note: Filtering currently operates only on loaded commits. \n        // Ideally we would search the whole history, but for graph view,\n        // we prioritized the loaded graph structure.\n        let basic = commits.filter { c in\n            if c.subject.lowercased().contains(q) { return true }\n            if c.author.lowercased().contains(q) { return true }\n            if c.shortId.lowercased().contains(q) { return true }\n            if c.decorations.joined(separator: \",\").lowercased().contains(q) { return true }\n            return false\n        }\n        filteredCommits = basic\n        buildRowData()\n        // Optional: Trigger background grep if needed, but omitted here to keep graph stable\n    }\n\n    private func buildRowData() {\n        let count = filteredCommits.count\n        let newRowData = filteredCommits.enumerated().map { idx, commit in\n            CommitRowData(\n                id: commit.id,\n                commit: commit,\n                index: idx,\n                laneInfo: laneInfoById[commit.id],\n                isFirst: idx == 0,\n                isLast: idx == count - 1,\n                isWorkingTree: commit.id == \"::working-tree::\",\n                isStriped: idx % 2 == 1\n            )\n        }\n        \n        // Only update if data actually changed to prevent unnecessary re-renders\n        if newRowData != rowData {\n            rowData = newRowData\n        }\n    }\n\n    func selectCommit(_ c: GitService.GraphCommit) {\n        selectedCommit = c\n        loadDetail(for: c)\n    }\n\n    /// Load detail panel data (files list + first file patch) for the given commit.\n    func loadDetail(for commit: GitService.GraphCommit) {\n        // The synthetic working-tree node does not correspond to a real commit id.\n        // For now, skip detail loading and leave the panel empty.\n        if commit.id == \"::working-tree::\" {\n            detailFiles = []\n            selectedDetailFile = nil\n            detailFilePatch = \"\"\n            isLoadingDetail = false\n            return\n        }\n        guard let repo = self.repo else {\n            detailFiles = []\n            selectedDetailFile = nil\n            detailFilePatch = \"\"\n            detailMessage = \"\"\n            return\n        }\n        detailTask?.cancel()\n        detailTask = Task { [weak self] in\n            guard let self else { return }\n            await MainActor.run {\n                self.isLoadingDetail = true\n                self.detailFiles = []\n                self.detailFilePatch = \"\"\n                self.detailMessage = \"\"\n            }\n            async let filesTask = service.filesChanged(in: repo, commitId: commit.id)\n            async let messageTask = service.commitMessage(in: repo, commitId: commit.id)\n            let (files, message) = await (filesTask, messageTask)\n            if Task.isCancelled { return }\n            await MainActor.run {\n                self.detailFiles = files\n                self.selectedDetailFile = files.first?.path\n                self.detailMessage = message\n            }\n            if let first = files.first {\n                await loadDetailPatch(for: first.path, in: repo, commitId: commit.id)\n            } else {\n                await MainActor.run {\n                    self.detailFilePatch = \"\"\n                    self.isLoadingDetail = false\n                }\n            }\n        }\n    }\n\n    func loadDetailPatch(for path: String) {\n        guard let repo = self.repo, let commit = selectedCommit else { return }\n        detailTask?.cancel()\n        detailTask = Task { [weak self] in\n            await self?.loadDetailPatch(for: path, in: repo, commitId: commit.id)\n        }\n    }\n\n    private func loadDetailPatch(for path: String, in repo: GitService.Repo, commitId: String) async {\n        await MainActor.run {\n            self.isLoadingDetail = true\n            self.detailFilePatch = \"\"\n        }\n        // Show diff of this file in the given commit against its first parent.\n        let text = await service.filePatch(in: repo, commitId: commitId, path: path)\n        if Task.isCancelled { return }\n        await MainActor.run {\n            self.detailFilePatch = text\n            self.isLoadingDetail = false\n        }\n    }\n    \n    private struct LaneLayoutResult: Sendable {\n        let byId: [String: LaneInfo]\n        let maxLaneCount: Int\n        let finalLanes: [String?]\n    }\n\n    // MARK: - Lanes\n    private func computeIncrementalLayout(newCommits: [GitService.GraphCommit], isInitial: Bool) {\n        let snapshot = newCommits\n        let initialLanes = isInitial ? [] : currentLanesState\n        let initialMax = isInitial ? 1 : maxLaneCount\n        let generation = laneLayoutGeneration\n        \n        laneLayoutTask = Task.detached(priority: .userInitiated) {\n            guard let result = Self.computeLaneLayout(\n                for: snapshot, \n                initialLanes: initialLanes, \n                initialMaxLane: initialMax\n            ) else { return }\n            \n            if Task.isCancelled { return }\n\n            await MainActor.run { [weak self] in\n                guard let self else { return }\n                guard self.laneLayoutGeneration == generation else { return }\n                if isInitial {\n                    self.laneInfoById = result.byId\n                } else {\n                    self.laneInfoById.merge(result.byId) { (_, new) in new }\n                }\n                self.maxLaneCount = result.maxLaneCount\n                self.currentLanesState = result.finalLanes\n                self.buildRowData()\n            }\n        }\n    }\n\n    nonisolated private static func computeLaneLayout(\n        for commits: [GitService.GraphCommit],\n        initialLanes: [String?] = [],\n        initialMaxLane: Int = 1\n    ) -> LaneLayoutResult? {\n        guard !commits.isEmpty else {\n            return LaneLayoutResult(byId: [:], maxLaneCount: initialMaxLane, finalLanes: initialLanes)\n        }\n\n        var lanes: [String?] = initialLanes\n        var byId: [String: LaneInfo] = [:]\n        var maxLanes = initialMaxLane\n        var processed = 0\n\n        for commit in commits {\n            if processed & 0x1F == 0, Task.isCancelled {\n                return nil\n            }\n            processed &+= 1\n\n            let before = lanes \n\n            // Determine current lane for this commit\n            let laneIndex: Int\n            if let idx = lanes.firstIndex(where: { $0 == commit.id }) {\n                laneIndex = idx\n            } else if let empty = lanes.firstIndex(where: { $0 == nil }) {\n                laneIndex = empty\n                if empty >= lanes.count { lanes.append(nil) }\n            } else {\n                laneIndex = lanes.count\n                lanes.append(nil)\n            }\n\n            var parentLaneIndices: [Int] = []\n            if let firstParent = commit.parents.first {\n                if laneIndex < lanes.count { lanes[laneIndex] = firstParent } else {\n                    lanes.append(firstParent)\n                }\n                parentLaneIndices.append(laneIndex)\n                \n                if commit.parents.count > 1 {\n                    for p in commit.parents.dropFirst() {\n                        if let existing = lanes.firstIndex(where: { $0 == p }) {\n                            parentLaneIndices.append(existing)\n                        } else if let empty = lanes.firstIndex(where: { $0 == nil }) {\n                            lanes[empty] = p\n                            parentLaneIndices.append(empty)\n                        } else {\n                            lanes.append(p)\n                            parentLaneIndices.append(lanes.count - 1)\n                        }\n                    }\n                }\n            } else {\n                if laneIndex < lanes.count { lanes[laneIndex] = nil }\n            }\n\n            let joinLanes: [Int] = before.enumerated().compactMap { index, value in\n                (value == commit.id && index != laneIndex) ? index : nil\n            }\n            if !joinLanes.isEmpty {\n                for j in joinLanes where j < lanes.count {\n                    lanes[j] = nil\n                }\n            }\n\n            while let last = lanes.last, last == nil { _ = lanes.popLast() }\n\n            let after = lanes\n            let activeCount = max(before.count, after.count)\n            var continuing: Set<Int> = []\n            if activeCount > 0 {\n                for i in 0..<activeCount {\n                    let hasBefore = i < before.count ? (before[i] != nil || i == laneIndex) : false\n                    let hasAfter = i < after.count ? (after[i] != nil) : false\n                    if hasBefore || hasAfter { continuing.insert(i) }\n                }\n            }\n\n            byId[commit.id] = LaneInfo(\n                laneIndex: laneIndex,\n                parentLaneIndices: parentLaneIndices,\n                activeLaneCount: activeCount,\n                continuingLanes: continuing,\n                joinLaneIndices: joinLanes\n            )\n            \n            let rowMax = (parentLaneIndices + joinLanes + [laneIndex]).max() ?? 0\n            maxLanes = max(maxLanes, rowMax + 1)\n        }\n\n        return LaneLayoutResult(byId: byId, maxLaneCount: max(1, maxLanes), finalLanes: lanes)\n    }\n\n    func loadBranches() {\n        guard let repo else { branches = []; fullBranchList = []; return }\n        branchesTask?.cancel()\n        isLoadingBranches = true\n        branchesTask = Task { [weak self] in\n            guard let self else { return }\n            let names = await service.listBranches(in: repo, includeRemoteBranches: showRemoteBranches)\n            if Task.isCancelled { return }\n            await MainActor.run {\n                self.fullBranchList = names\n                self.applyBranchFilter()\n                self.isLoadingBranches = false\n                self.branchesTask = nil\n            }\n        }\n    }\n\n    func applyBranchFilter() {\n        let query = branchSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n        if query.isEmpty {\n            // Limit to first 100 branches for performance\n            branches = Array(fullBranchList.prefix(100))\n        } else {\n            // Filter and limit\n            branches = Array(fullBranchList.filter { $0.lowercased().contains(query) }.prefix(100))\n        }\n    }\n\n    func clearError() {\n        errorMessage = nil\n    }\n\n    func loadCommits() {\n        reload()\n    }\n\n    func fetchRemotes() {\n        performHistoryAction(.fetch)\n    }\n\n    func pullLatest() {\n        performHistoryAction(.pull)\n    }\n\n    func pushCurrent() {\n        performHistoryAction(.push)\n    }\n\n    private func performHistoryAction(_ action: HistoryAction) {\n        guard historyActionInProgress == nil else { return }\n        guard let repo = self.repo else { return }\n        historyActionTask?.cancel()\n        historyActionTask = Task { [weak self] in\n            guard let self else { return }\n            await MainActor.run { self.historyActionInProgress = action }\n            let code: Int32\n            switch action {\n            case .fetch:\n                code = await service.fetchAllRemotes(in: repo)\n            case .pull:\n                code = await service.pullCurrentBranch(in: repo)\n            case .push:\n                code = await service.pushCurrentBranch(in: repo)\n            }\n            if Task.isCancelled {\n                await MainActor.run {\n                    self.historyActionInProgress = nil\n                    self.historyActionTask = nil\n                }\n                return\n            }\n            let failureDetail = (code == 0) ? nil : await self.service.takeLastFailureDescription()\n            await MainActor.run {\n                self.historyActionInProgress = nil\n                self.historyActionTask = nil\n                if code == 0 {\n                    self.errorMessage = nil\n                    self.reload()\n                } else {\n                    if let detail = failureDetail, !detail.isEmpty {\n                        self.errorMessage = detail.trimmingCharacters(in: .whitespacesAndNewlines)\n                    } else {\n                        self.errorMessage = \"\\(action.displayName) failed (exit code \\(code))\"\n                    }\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "models/GitReviewTree.swift",
    "content": "import Foundation\n\nstruct GitReviewNode: Identifiable, Hashable, Sendable {\n    var name: String\n    var fullPath: String?\n    var dirPath: String?\n    var children: [GitReviewNode]? = nil\n\n    var isDirectory: Bool { dirPath != nil }\n\n    var id: String {\n        if let dirPath { return \"dir:\\(dirPath)\" }\n        if let fullPath { return \"file:\\(fullPath)\" }\n        return \"node:\\(name)\"\n    }\n}\n\nstruct GitReviewTreeSnapshot: Equatable, Sendable {\n    var staged: [GitReviewNode]\n    var unstaged: [GitReviewNode]\n\n    static let empty = GitReviewTreeSnapshot(staged: [], unstaged: [])\n}\n\nenum GitReviewTreeBuilder {\n    static func buildSnapshot(from changes: [GitService.Change]) -> GitReviewTreeSnapshot {\n        let staged = changes.filter { $0.staged != nil }\n        // Include all worktree entries (including MM) for unstaged tree\n        let unstaged = changes.filter { $0.worktree != nil }\n        return GitReviewTreeSnapshot(\n            staged: buildTree(from: staged),\n            unstaged: buildTree(from: unstaged)\n        )\n    }\n\n    static func buildTree(from changes: [GitService.Change]) -> [GitReviewNode] {\n        struct BuilderNode {\n            var children: [String: BuilderNode] = [:]\n            var filePath: String?\n        }\n\n        var root = BuilderNode()\n        for change in changes {\n            let components = change.path.split(separator: \"/\").map(String.init)\n            guard !components.isEmpty else { continue }\n\n            func insert(_ index: Int, current: inout BuilderNode) {\n                let key = components[index]\n                if index == components.count - 1 {\n                    var child = current.children[key, default: BuilderNode()]\n                    child.filePath = change.path\n                    current.children[key] = child\n                } else {\n                    var child = current.children[key, default: BuilderNode()]\n                    insert(index + 1, current: &child)\n                    current.children[key] = child\n                }\n            }\n\n            insert(0, current: &root)\n        }\n\n        func convert(_ node: BuilderNode, prefix: String?) -> [GitReviewNode] {\n            var output: [GitReviewNode] = []\n            for (name, child) in node.children {\n                let fullPath = prefix.map { \"\\($0)/\\(name)\" } ?? name\n                if let filePath = child.filePath, child.children.isEmpty {\n                    output.append(GitReviewNode(name: name, fullPath: filePath, dirPath: nil, children: nil))\n                } else {\n                    let childrenNodes = convert(child, prefix: fullPath)\n                    output.append(\n                        GitReviewNode(\n                            name: name,\n                            fullPath: nil,\n                            dirPath: fullPath,\n                            children: explorerSort(childrenNodes)\n                        )\n                    )\n                }\n            }\n            return explorerSort(output)\n        }\n\n        return convert(root, prefix: nil)\n    }\n\n    static func explorerSort(_ nodes: [GitReviewNode]) -> [GitReviewNode] {\n        func category(for node: GitReviewNode) -> Int {\n            let isDot = node.name.hasPrefix(\".\")\n            if node.isDirectory {\n                return isDot ? 1 : 0\n            } else {\n                return isDot ? 3 : 2\n            }\n        }\n        return nodes.sorted {\n            let lhs = category(for: $0)\n            let rhs = category(for: $1)\n            if lhs != rhs { return lhs < rhs }\n            return $0.name.localizedStandardCompare($1.name) == .orderedAscending\n        }\n    }\n}\n"
  },
  {
    "path": "models/GlobalSearchModels.swift",
    "content": "import Foundation\n\nstruct GlobalSearchSnippet: Hashable, Sendable {\n  let text: String\n  let highlightRange: Range<Int>?\n\n  init(text: String, highlightRange: Range<Int>? = nil) {\n    self.text = text\n    self.highlightRange = highlightRange\n  }\n}\n\nenum GlobalSearchSnippetFactory {\n  static func snippet(\n    in text: String,\n    matchRange: Range<String.Index>,\n    radius: Int = 90\n  ) -> GlobalSearchSnippet {\n    let start = text.index(matchRange.lowerBound, offsetBy: -radius, limitedBy: text.startIndex) ?? text.startIndex\n    let end = text.index(matchRange.upperBound, offsetBy: radius, limitedBy: text.endIndex) ?? text.endIndex\n    let snippetRange = start..<end\n    let rawSnippet = String(text[snippetRange])\n    let highlightStart = text.distance(from: snippetRange.lowerBound, to: matchRange.lowerBound)\n    let highlightLength = text.distance(from: matchRange.lowerBound, to: matchRange.upperBound)\n    let range = highlightStart..<(highlightStart + highlightLength)\n    let sanitized = rawSnippet.sanitizedSnippetText(preserving: range)\n    return GlobalSearchSnippet(text: sanitized.text, highlightRange: sanitized.range)\n  }\n}\n\nenum GlobalSearchResultKind: String, CaseIterable, Identifiable, Sendable {\n  case session\n  case note\n  case project\n  case task\n\n  var id: String { rawValue }\n\n  var displayName: String {\n    switch self {\n    case .session: return \"Sessions\"\n    case .note: return \"Notes\"\n    case .project: return \"Projects\"\n    case .task: return \"Tasks\"\n    }\n  }\n\n  var symbolName: String {\n    switch self {\n    case .session: return \"terminal\"\n    case .note: return \"note.text\"\n    case .project: return \"square.grid.2x2\"\n    case .task: return \"checklist\"\n    }\n  }\n}\n\nstruct GlobalSearchScope: OptionSet, Sendable {\n  let rawValue: Int\n\n  static let sessions = GlobalSearchScope(rawValue: 1 << 0)\n  static let notes = GlobalSearchScope(rawValue: 1 << 1)\n  static let projects = GlobalSearchScope(rawValue: 1 << 2)\n  static let tasks = GlobalSearchScope(rawValue: 1 << 3)\n\n  static let all: GlobalSearchScope = [.sessions, .notes, .projects, .tasks]\n}\n\nstruct GlobalSearchPaths: Sendable {\n  var sessionRoots: [URL]\n  var notesRoot: URL?\n  var projectsRoot: URL?\n  var tasksRoot: URL?\n  var projectMetadataRoot: URL? {\n    projectsRoot?.appendingPathComponent(\"metadata\", isDirectory: true)\n  }\n  var taskMetadataRoot: URL? {\n    tasksRoot?.appendingPathComponent(\"metadata\", isDirectory: true)\n  }\n\n  init(sessionRoots: [URL], notesRoot: URL?, projectsRoot: URL?, tasksRoot: URL?) {\n    self.sessionRoots = sessionRoots\n    self.notesRoot = notesRoot\n    self.projectsRoot = projectsRoot\n    self.tasksRoot = tasksRoot\n  }\n}\n\nstruct GlobalSearchHit: Identifiable, Hashable, Sendable {\n  let id: String\n  let kind: GlobalSearchResultKind\n  let fileURL: URL\n  let snippet: GlobalSearchSnippet?\n  let fallbackTitle: String\n  let note: SessionNote?\n  let project: Project?\n  let task: CodMateTask?\n  let metadataDate: Date?\n  let score: Double\n\n  init(\n    id: String,\n    kind: GlobalSearchResultKind,\n    fileURL: URL,\n    snippet: GlobalSearchSnippet?,\n    fallbackTitle: String,\n    note: SessionNote? = nil,\n    project: Project? = nil,\n    task: CodMateTask? = nil,\n    metadataDate: Date? = nil,\n    score: Double = 0\n  ) {\n    self.id = id\n    self.kind = kind\n    self.fileURL = fileURL\n    self.snippet = snippet\n    self.fallbackTitle = fallbackTitle\n    self.note = note\n    self.project = project\n    self.task = task\n    self.metadataDate = metadataDate\n    self.score = score\n  }\n}\n\nstruct GlobalSearchResult: Identifiable, Hashable, Sendable {\n  let id: String\n  let kind: GlobalSearchResultKind\n  let fileURL: URL\n  let snippet: GlobalSearchSnippet?\n  let fallbackTitle: String\n  var sessionSummary: SessionSummary?\n  var note: SessionNote?\n  var project: Project?\n  var task: CodMateTask?\n  var metadataDate: Date?\n  var score: Double\n\n  init(hit: GlobalSearchHit, sessionSummary: SessionSummary? = nil) {\n    self.id = hit.id\n    self.kind = hit.kind\n    self.fileURL = hit.fileURL\n    self.snippet = hit.snippet\n    self.fallbackTitle = hit.fallbackTitle\n    self.sessionSummary = sessionSummary\n    self.note = hit.note\n    self.project = hit.project\n    self.task = hit.task\n    self.metadataDate = hit.metadataDate\n    self.score = hit.score\n  }\n\n  init(\n    id: String,\n    kind: GlobalSearchResultKind,\n    fileURL: URL,\n    snippet: GlobalSearchSnippet?,\n    fallbackTitle: String,\n    sessionSummary: SessionSummary?,\n    note: SessionNote?,\n    project: Project?,\n    task: CodMateTask?,\n    metadataDate: Date?,\n    score: Double\n  ) {\n    self.id = id\n    self.kind = kind\n    self.fileURL = fileURL\n    self.snippet = snippet\n    self.fallbackTitle = fallbackTitle\n    self.sessionSummary = sessionSummary\n    self.note = note\n    self.project = project\n    self.task = task\n    self.metadataDate = metadataDate\n    self.score = score\n  }\n\n  var displayTitle: String {\n    switch kind {\n    case .session:\n      return sessionSummary?.effectiveTitle ?? fallbackTitle\n    case .note:\n      let trimmed = note?.title?.trimmingCharacters(in: .whitespacesAndNewlines)\n      if let trimmed, !trimmed.isEmpty { return trimmed }\n      return fallbackTitle\n    case .project:\n      let trimmed = project?.name.trimmingCharacters(in: .whitespacesAndNewlines)\n      if let trimmed, !trimmed.isEmpty { return trimmed }\n      return fallbackTitle\n    case .task:\n      return task?.effectiveTitle ?? fallbackTitle\n    }\n  }\n\n  var detailLine: String? {\n    switch kind {\n    case .session:\n      guard let summary = sessionSummary else { return fileURL.deletingPathExtension().lastPathComponent }\n      let formatter = DateFormatter()\n      formatter.dateStyle = .medium\n      formatter.timeStyle = .short\n      let timestamp = formatter.string(from: summary.lastUpdatedAt ?? summary.startedAt)\n      return \"Updated · \\(timestamp)\"\n    case .note:\n      if let updated = note?.updatedAt {\n        let formatter = RelativeDateTimeFormatter()\n        let rel = formatter.localizedString(for: updated, relativeTo: Date())\n        return \"Note · \\(rel)\"\n      }\n      return \"Note\"\n    case .project:\n      if let dir = project?.directory { return dir }\n      return \"Project\"\n    case .task:\n      if let taskData = task {\n        let formatter = RelativeDateTimeFormatter()\n        let rel = formatter.localizedString(for: taskData.updatedAt, relativeTo: Date())\n        return \"Task · \\(taskData.status.displayName) · \\(rel)\"\n      }\n      return \"Task\"\n    }\n  }\n}\n\nenum GlobalSearchFilter: Hashable, CaseIterable, Identifiable {\n  case all\n  case notes\n  case projects\n  case sessions\n  case tasks\n\n  static var allCases: [GlobalSearchFilter] { [.all, .projects, .tasks, .notes, .sessions] }\n\n  var id: String { title }\n\n  var title: String {\n    switch self {\n    case .all: return \"All\"\n    case .sessions: return \"Sessions\"\n    case .notes: return \"Notes\"\n    case .projects: return \"Projects\"\n    case .tasks: return \"Tasks\"\n    }\n  }\n\n  var scope: GlobalSearchScope {\n    switch self {\n    case .all: return .all\n    case .sessions: return [.sessions]\n    case .notes: return [.notes]\n    case .projects: return [.projects]\n    case .tasks: return [.tasks]\n    }\n  }\n}\n\nenum GlobalSearchPanelStyle: String, CaseIterable, Identifiable, Sendable {\n  case floating\n  case popover\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .floating: return \"Floating\"\n    case .popover: return \"Popover\"\n    }\n  }\n}\n\nextension GlobalSearchFilter {\n  var kind: GlobalSearchResultKind? {\n    switch self {\n    case .all: return nil\n    case .sessions: return .session\n    case .notes: return .note\n    case .projects: return .project\n    case .tasks: return .task\n    }\n  }\n}\n\nstruct GlobalSearchProgress: Equatable, Hashable, Sendable {\n  enum Phase: String, Sendable { case ripgrep }\n  var phase: Phase\n  var filesProcessed: Int\n  var matchesFound: Int\n  var message: String\n  var isFinished: Bool\n  var isCancelled: Bool\n\n  static func ripgrep(message: String, files: Int, matches: Int, finished: Bool, cancelled: Bool = false)\n    -> GlobalSearchProgress\n  {\n    GlobalSearchProgress(\n      phase: .ripgrep,\n      filesProcessed: files,\n      matchesFound: matches,\n      message: message,\n      isFinished: finished,\n      isCancelled: cancelled\n    )\n  }\n}\n\nextension String {\n  func rangeFromByteOffsets(start: Int, end: Int) -> Range<String.Index>? {\n    guard start >= 0, end >= start else { return nil }\n    guard\n      let lowerUTF8 = utf8.index(utf8.startIndex, offsetBy: start, limitedBy: utf8.endIndex),\n      let upperUTF8 = utf8.index(utf8.startIndex, offsetBy: end, limitedBy: utf8.endIndex),\n      let lower = String.Index(lowerUTF8, within: self),\n      let upper = String.Index(upperUTF8, within: self)\n    else { return nil }\n    return lower..<upper\n  }\n\n  fileprivate func collapsingWhitespace() -> String {\n    var result = \"\"\n    result.reserveCapacity(count)\n    var pendingSpace = false\n    for character in self {\n      if character.isWhitespace {\n        pendingSpace = true\n        continue\n      }\n      if pendingSpace, !result.isEmpty {\n        result.append(\" \")\n      }\n      pendingSpace = false\n      result.append(character)\n    }\n    return result.trimmingCharacters(in: .whitespacesAndNewlines)\n  }\n\n  func sanitizedSnippetText() -> String {\n    sanitizedSnippetText(preserving: nil).text\n  }\n\n  func sanitizedSnippetText(preserving range: Range<Int>?) -> (text: String, range: Range<Int>?) {\n    let characters = Array(self)\n    var mapping = Array(repeating: 0, count: characters.count + 1)\n    var sanitizedIndex = 0\n    var idx = 0\n    var result = \"\"\n    var pendingSpace = false\n    var skipNextLiteral = false\n\n    while idx < characters.count {\n      mapping[idx] = sanitizedIndex\n      let char = characters[idx]\n      if skipNextLiteral {\n        skipNextLiteral = false\n        pendingSpace = true\n        idx += 1\n        continue\n      }\n      if char.isWhitespace {\n        pendingSpace = true\n        idx += 1\n        continue\n      }\n      if char == \"\\\\\", idx + 1 < characters.count {\n        let next = characters[idx + 1]\n        if next == \"n\" || next == \"r\" || next == \"t\" {\n          pendingSpace = true\n          skipNextLiteral = true\n          idx += 1\n          continue\n        }\n      }\n      if pendingSpace && !result.isEmpty {\n        result.append(\" \")\n        sanitizedIndex += 1\n        pendingSpace = false\n      }\n      result.append(char)\n      sanitizedIndex += 1\n      idx += 1\n    }\n    mapping[characters.count] = sanitizedIndex\n\n    let sanitizedRange = range.flatMap { original -> Range<Int>? in\n      guard original.lowerBound < mapping.count, original.upperBound < mapping.count else {\n        return nil\n      }\n      let lower = mapping[original.lowerBound]\n      let upper = mapping[original.upperBound]\n      guard lower <= upper else { return nil }\n      return lower..<upper\n    }\n    return (result, sanitizedRange)\n  }\n}\n"
  },
  {
    "path": "models/GlobalSearchViewModel.swift",
    "content": "import Foundation\n#if canImport(Darwin)\n  import Darwin\n#endif\n\n@MainActor\nfinal class GlobalSearchViewModel: ObservableObject {\n  @Published var query: String = \"\" {\n    didSet {\n      guard query != oldValue else { return }\n      Task { [weak self] in self?.handleQueryChange(oldValue: oldValue) }\n    }\n  }\n  @Published private(set) var results: [GlobalSearchResult] = []\n  @Published private(set) var filteredResults: [GlobalSearchResult] = []\n  @Published var filter: GlobalSearchFilter = .all {\n    didSet {\n      guard oldValue != filter else { return }\n      Task { [weak self] in\n        guard let self else { return }\n        self.applyFilter()\n        let trimmed = self.trimmedQuery\n        guard !trimmed.isEmpty else { return }\n        self.restartSearch(term: trimmed)\n      }\n    }\n  }\n  @Published var isSearching = false\n  @Published var errorMessage: String?\n  @Published var hasFocus = false\n  @Published var ripgrepProgress: GlobalSearchProgress?\n  @Published private(set) var isPanelVisible = false\n\n  var shouldShowPanel: Bool {\n    return isPanelVisible\n  }\n\n  private let service: GlobalSearchService\n  private let preferences: SessionPreferencesStore\n  private weak var sessionListViewModel: SessionListViewModel?\n  private var searchTask: Task<Void, Never>?\n  private var debounceTask: Task<Void, Never>?\n  private var lastRequestSignature: String = \"\"\n  private let debounceNanoseconds: UInt64 = 220_000_000\n  private let maxResults = 160\n  private let maxMatchesPerFile = 3\n  private let batchSize = 12\n  private var seenResultKeys: Set<String> = []\n  private var queryVersion: UInt64 = 0\n\n  init(\n    service: GlobalSearchService = GlobalSearchService(),\n    preferences: SessionPreferencesStore,\n    sessionListViewModel: SessionListViewModel?\n  ) {\n    self.service = service\n    self.preferences = preferences\n    self.sessionListViewModel = sessionListViewModel\n  }\n\n  deinit {\n    searchTask?.cancel()\n    Task { [service] in await service.cancelRipgrep() }\n    debounceTask?.cancel()\n  }\n\n  func submit() {\n    debounceTask?.cancel()\n    let trimmed = trimmedQuery\n    guard !trimmed.isEmpty else { return }\n    restartSearch(term: trimmed)\n  }\n\n  func clearQuery() {\n    query = \"\"\n    errorMessage = nil\n    cancelActiveSearchTasks()\n    debounceTask?.cancel()\n    results.removeAll()\n    filteredResults.removeAll()\n    lastRequestSignature = \"\"\n    ripgrepProgress = nil\n    isSearching = false\n    seenResultKeys.removeAll()\n    queryVersion &+= 1\n  }\n\n  func setFocus(_ active: Bool) {\n    // Defer state mutations to the next runloop to avoid \"Publishing changes from within view updates\"\n    Task { @MainActor [weak self] in\n      guard let self else { return }\n      self.hasFocus = active\n      if active {\n        self.isPanelVisible = true\n      }\n      if !active, self.trimmedQuery.isEmpty {\n        self.results.removeAll()\n        self.filteredResults.removeAll()\n      }\n    }\n  }\n\n  func dismissPanel() {\n    // Defer to avoid mutating during view updates\n    Task { @MainActor [weak self] in\n      guard let self else { return }\n      self.hasFocus = false\n      self.isPanelVisible = false\n    }\n  }\n\n  func resetSearchState() {\n    // Reset asynchronously to avoid view-update reentrancy\n    Task { @MainActor [weak self] in\n      guard let self else { return }\n      self.query = \"\"\n      self.filter = .all\n      self.results.removeAll()\n      self.filteredResults.removeAll()\n      self.ripgrepProgress = nil\n      self.errorMessage = nil\n      self.isSearching = false\n      self.seenResultKeys.removeAll()\n      self.isPanelVisible = true\n      self.hasFocus = true\n    }\n  }\n\n  private var trimmedQuery: String {\n    query.trimmingCharacters(in: .whitespacesAndNewlines)\n  }\n\n  private func handleQueryChange(oldValue: String) {\n    debounceTask?.cancel()\n    let trimmed = trimmedQuery\n    guard !trimmed.isEmpty else {\n      cancelActiveSearchTasks()\n      results.removeAll()\n      filteredResults.removeAll()\n      errorMessage = nil\n      lastRequestSignature = \"\"\n      ripgrepProgress = nil\n      isSearching = false\n      return\n    }\n\n    let versionSnapshot = queryVersion\n    debounceTask = Task { [weak self] in\n      guard let self else { return }\n      if self.queryVersion != versionSnapshot { return }\n      if self.debounceNanoseconds > 0 {\n        try? await Task.sleep(nanoseconds: self.debounceNanoseconds)\n      }\n      if self.queryVersion != versionSnapshot { return }\n      if self.trimmedQuery != trimmed { return }\n      self.restartSearch(term: trimmed)\n    }\n  }\n\n  private func restartSearch(term: String) {\n    cancelActiveSearchTasks()\n    errorMessage = nil\n    results.removeAll()\n    filteredResults.removeAll()\n    isSearching = true\n    ripgrepProgress = nil\n    seenResultKeys.removeAll()\n\n    let request = makeRequest(term: term)\n    let signature = makeSignature(term: term, scope: request.scope)\n    lastRequestSignature = signature\n    let service = self.service\n\n    let requestSignature = signature\n    searchTask = Task { [weak self] in\n      guard let self else { return }\n      await service.search(\n        request: request,\n        onBatch: { [weak self] hits in\n          guard let self else { return }\n          await self.handleBatch(hits, signature: requestSignature)\n        },\n        onProgress: { [weak self] progress in\n          guard let self else { return }\n          await self.handleProgress(progress, signature: requestSignature)\n        },\n        onCompletion: { [weak self] in\n          guard let self else { return }\n          await self.handleCompletion(signature: requestSignature)\n        }\n      )\n    }\n  }\n\n  func cancelBackgroundSearch() {\n    cancelActiveSearchTasks()\n  }\n\n  private func cancelActiveSearchTasks() {\n    searchTask?.cancel()\n    searchTask = nil\n    Task { [service] in await service.cancelRipgrep() }\n  }\n\n  @MainActor\n  private func handleBatch(_ hits: [GlobalSearchHit], signature: String) {\n    guard lastRequestSignature == signature else { return }\n    let hydrated = hydrate(hits: hits)\n    let deduped = hydrated.filter { hit in\n      let key = dedupeKey(for: hit)\n      if seenResultKeys.contains(key) { return false }\n      seenResultKeys.insert(key)\n      return true\n    }\n    guard !deduped.isEmpty else { return }\n    results.append(contentsOf: deduped)\n    sortResults()\n    applyFilter()\n  }\n\n  @MainActor\n  private func handleProgress(_ progress: GlobalSearchProgress, signature: String) {\n    guard lastRequestSignature == signature else { return }\n    ripgrepProgress = progress\n  }\n\n  @MainActor\n  private func handleCompletion(signature: String) {\n    if lastRequestSignature == signature {\n      isSearching = false\n    }\n    searchTask = nil\n  }\n\n  private func dedupeKey(for result: GlobalSearchResult) -> String {\n    var components: [String] = [result.kind.rawValue, result.fileURL.path]\n    if let snippet = result.snippet?.text.lowercased(), !snippet.isEmpty {\n      components.append(\"snippet:\\(snippet)\")\n    } else if let noteId = result.note?.id {\n      components.append(\"note:\\(noteId)\")\n    } else if let projectId = result.project?.id {\n      components.append(\"project:\\(projectId)\")\n    } else if let sessionId = result.sessionSummary?.id {\n      components.append(\"session:\\(sessionId)\")\n    } else {\n      components.append(\"raw:\\(result.id)\")\n    }\n    return components.joined(separator: \"|\")\n  }\n\n  private func hydrate(hits: [GlobalSearchHit]) -> [GlobalSearchResult] {\n    guard !hits.isEmpty else { return [] }\n    let sessionMap: [String: SessionSummary]\n    if let store = sessionListViewModel {\n      let snapshot = store.sessionsSnapshot()\n      sessionMap = Dictionary(uniqueKeysWithValues: snapshot.map { ($0.fileURL.path, $0) })\n    } else {\n      sessionMap = [:]\n    }\n    return hits.map { hit in\n      var summary: SessionSummary? = nil\n      if hit.kind == .session {\n        summary = sessionMap[hit.fileURL.path]\n      } else if hit.kind == .note, summary == nil, let noteId = hit.note?.id {\n        summary = sessionListViewModel?.sessionSummary(withId: noteId)\n      }\n      return GlobalSearchResult(hit: hit, sessionSummary: summary)\n    }\n  }\n\n  private func applyFilter() {\n    if filter == .all {\n      filteredResults = results\n    } else {\n      filteredResults = results.filter { $0.kind == filter.kind }\n    }\n  }\n\n  private func sortResults() {\n    results.sort { lhs, rhs in\n      if lhs.score == rhs.score {\n        return lhs.displayTitle.localizedCaseInsensitiveCompare(rhs.displayTitle) == .orderedAscending\n      }\n      return lhs.score > rhs.score\n    }\n  }\n\n  private func makeRequest(term: String) -> GlobalSearchService.Request {\n    let paths = resolvedPaths()\n    let scope = filter.scope\n    return GlobalSearchService.Request(\n      term: term,\n      scope: scope,\n      paths: paths,\n      maxMatchesPerFile: maxMatchesPerFile,\n      batchSize: batchSize,\n      limit: maxResults\n    )\n  }\n\n  private func resolvedPaths() -> GlobalSearchPaths {\n    let current = preferences.sessionsRoot\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let defaultRoot = SessionPreferencesStore.defaultSessionsRoot(for: home)\n    var sessionRoots: [URL] = []\n    if preferences.isCLIEnabled(.codex) {\n      sessionRoots.append(current)\n      if defaultRoot != current { sessionRoots.append(defaultRoot) }\n    }\n    if preferences.isCLIEnabled(.claude),\n      let claudeRoot = Self.defaultClaudeSessionsRoot(),\n      FileManager.default.fileExists(atPath: claudeRoot.path)\n    {\n      if !sessionRoots.contains(claudeRoot) { sessionRoots.append(claudeRoot) }\n    }\n    if preferences.isCLIEnabled(.gemini),\n      let geminiRoot = Self.defaultGeminiSessionsRoot(),\n      FileManager.default.fileExists(atPath: geminiRoot.path)\n    {\n      if !sessionRoots.contains(geminiRoot) { sessionRoots.append(geminiRoot) }\n    }\n    return GlobalSearchPaths(\n      sessionRoots: sessionRoots,\n      notesRoot: preferences.notesRoot,\n      projectsRoot: preferences.projectsRoot,\n      tasksRoot: Self.defaultTasksRoot()\n    )\n  }\n\n  private func makeSignature(term: String, scope: GlobalSearchScope) -> String {\n    \"\\(term.lowercased())|\\(scope.rawValue)\"\n  }\n\n  private static func defaultClaudeSessionsRoot() -> URL? {\n    #if canImport(Darwin)\n      if let pwDir = getpwuid(getuid())?.pointee.pw_dir {\n        let path = String(cString: pwDir)\n        return URL(fileURLWithPath: path, isDirectory: true)\n          .appendingPathComponent(\".claude\", isDirectory: true)\n          .appendingPathComponent(\"projects\", isDirectory: true)\n      }\n    #endif\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n      return URL(fileURLWithPath: home, isDirectory: true)\n        .appendingPathComponent(\".claude\", isDirectory: true)\n        .appendingPathComponent(\"projects\", isDirectory: true)\n    }\n    let fallback = FileManager.default.homeDirectoryForCurrentUser\n      .appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n    return fallback\n  }\n\n  private static func defaultGeminiSessionsRoot() -> URL? {\n    #if canImport(Darwin)\n      if let pwDir = getpwuid(getuid())?.pointee.pw_dir {\n        let path = String(cString: pwDir)\n        return URL(fileURLWithPath: path, isDirectory: true)\n          .appendingPathComponent(\".gemini\", isDirectory: true)\n          .appendingPathComponent(\"tmp\", isDirectory: true)\n      }\n    #endif\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n      return URL(fileURLWithPath: home, isDirectory: true)\n        .appendingPathComponent(\".gemini\", isDirectory: true)\n        .appendingPathComponent(\"tmp\", isDirectory: true)\n    }\n    let fallback = FileManager.default.homeDirectoryForCurrentUser\n      .appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"tmp\", isDirectory: true)\n    return fallback\n  }\n\n  private static func defaultTasksRoot() -> URL? {\n    #if canImport(Darwin)\n      if let pwDir = getpwuid(getuid())?.pointee.pw_dir {\n        let path = String(cString: pwDir)\n        return URL(fileURLWithPath: path, isDirectory: true)\n          .appendingPathComponent(\".codmate\", isDirectory: true)\n          .appendingPathComponent(\"tasks\", isDirectory: true)\n      }\n    #endif\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n      return URL(fileURLWithPath: home, isDirectory: true)\n        .appendingPathComponent(\".codmate\", isDirectory: true)\n        .appendingPathComponent(\"tasks\", isDirectory: true)\n    }\n    let fallback = FileManager.default.homeDirectoryForCurrentUser\n      .appendingPathComponent(\".codmate\", isDirectory: true)\n      .appendingPathComponent(\"tasks\", isDirectory: true)\n    return fallback\n  }\n}\n"
  },
  {
    "path": "models/HookCommandVariableCatalog.swift",
    "content": "import Foundation\n\nenum HookVariableKind: String, CaseIterable, Sendable, Codable {\n  case env\n  case stdin\n\n  var displayName: String {\n    switch self {\n    case .env: return \"Environment\"\n    case .stdin: return \"Stdin JSON\"\n    }\n  }\n\n  var shortLabel: String {\n    switch self {\n    case .env: return \"ENV\"\n    case .stdin: return \"STDIN\"\n    }\n  }\n}\n\nenum HookVariableProvider: String, CaseIterable, Sendable, Codable {\n  case codex\n  case claude\n  case gemini\n\n  var displayName: String {\n    switch self {\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude Code\"\n    case .gemini: return \"Gemini CLI\"\n    }\n  }\n}\n\nstruct HookVariableDescriptor: Identifiable, Hashable, Sendable {\n  let name: String\n  let kind: HookVariableKind\n  let description: String\n  let providers: Set<HookVariableProvider>\n  let note: String?\n\n  var id: String { \"\\(kind.rawValue):\\(name)\" }\n}\n\nenum HookCommandVariableCatalog {\n  static let all: [HookVariableDescriptor] = {\n    let bundled = loadBundledVariables() ?? fallbackVariables\n    return merge(bundled)\n  }()\n\n  static func variables(kind: HookVariableKind) -> [HookVariableDescriptor] {\n    all.filter { $0.kind == kind }\n  }\n\n  private static func merge(_ vars: [HookVariableDescriptor]) -> [HookVariableDescriptor] {\n    var map: [String: HookVariableDescriptor] = [:]\n    for variable in vars {\n      if let existing = map[variable.id] {\n        let providers = existing.providers.union(variable.providers)\n        let description = existing.description.count >= variable.description.count ? existing.description : variable.description\n        let note = mergeNotes(existing.note, variable.note)\n        map[variable.id] = HookVariableDescriptor(\n          name: existing.name,\n          kind: existing.kind,\n          description: description,\n          providers: providers,\n          note: note\n        )\n      } else {\n        map[variable.id] = variable\n      }\n    }\n    return map.values.sorted {\n      if $0.kind != $1.kind { return $0.kind.rawValue < $1.kind.rawValue }\n      return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n    }\n  }\n\n  private static func mergeNotes(_ a: String?, _ b: String?) -> String? {\n    let left = a?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    let right = b?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    if left.isEmpty { return right.isEmpty ? nil : right }\n    if right.isEmpty { return left }\n    if left == right { return left }\n    return \"\\(left) · \\(right)\"\n  }\n\n  private struct HookVariableFile: Codable {\n    let variables: [HookVariableRecord]\n  }\n\n  private struct HookVariableRecord: Codable {\n    let name: String\n    let kind: HookVariableKind\n    let description: String\n    let providers: [HookVariableProvider]\n    let note: String?\n\n    func toDescriptor() -> HookVariableDescriptor {\n      HookVariableDescriptor(\n        name: name,\n        kind: kind,\n        description: description,\n        providers: Set(providers),\n        note: note\n      )\n    }\n  }\n\n  private static func loadBundledVariables() -> [HookVariableDescriptor]? {\n    let bundle = Bundle.main\n    var urls: [URL] = []\n    if let url = bundle.url(forResource: \"hook-variables\", withExtension: \"json\") {\n      urls.append(url)\n    }\n    if let url = bundle.url(\n      forResource: \"hook-variables\",\n      withExtension: \"json\",\n      subdirectory: \"payload\"\n    ) {\n      urls.append(url)\n    }\n    for url in urls {\n      guard let data = try? Data(contentsOf: url) else { continue }\n      let decoder = JSONDecoder()\n      if let file = try? decoder.decode(HookVariableFile.self, from: data) {\n        return file.variables.map { $0.toDescriptor() }\n      }\n      if let list = try? decoder.decode([HookVariableRecord].self, from: data) {\n        return list.map { $0.toDescriptor() }\n      }\n    }\n    return nil\n  }\n\n  private static let fallbackVariables: [HookVariableDescriptor] = claudeVariables + geminiVariables\n\n  private static let claudeVariables: [HookVariableDescriptor] = [\n    HookVariableDescriptor(\n      name: \"CLAUDE_PROJECT_DIR\",\n      kind: .env,\n      description: \"Project root directory\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"CLAUDE_ENV_FILE\",\n      kind: .env,\n      description: \"Path to environment file\",\n      providers: [.claude],\n      note: \"SessionStart/Setup\"\n    ),\n    HookVariableDescriptor(\n      name: \"session_id\",\n      kind: .stdin,\n      description: \"Session identifier\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"transcript_path\",\n      kind: .stdin,\n      description: \"Transcript JSON path\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"cwd\",\n      kind: .stdin,\n      description: \"Current working directory\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"permission_mode\",\n      kind: .stdin,\n      description: \"Permission mode\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"hook_event_name\",\n      kind: .stdin,\n      description: \"Hook event name\",\n      providers: [.claude],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"tool_name\",\n      kind: .stdin,\n      description: \"Tool name\",\n      providers: [.claude],\n      note: \"PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure\"\n    ),\n    HookVariableDescriptor(\n      name: \"tool_input\",\n      kind: .stdin,\n      description: \"Tool input JSON\",\n      providers: [.claude],\n      note: \"PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure\"\n    ),\n    HookVariableDescriptor(\n      name: \"tool_use_id\",\n      kind: .stdin,\n      description: \"Tool use identifier\",\n      providers: [.claude],\n      note: \"PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure\"\n    ),\n    HookVariableDescriptor(\n      name: \"tool_response\",\n      kind: .stdin,\n      description: \"Tool response JSON\",\n      providers: [.claude],\n      note: \"PostToolUse/PostToolUseFailure\"\n    ),\n    HookVariableDescriptor(\n      name: \"message\",\n      kind: .stdin,\n      description: \"Notification message\",\n      providers: [.claude],\n      note: \"Notification\"\n    ),\n    HookVariableDescriptor(\n      name: \"notification_type\",\n      kind: .stdin,\n      description: \"Notification type\",\n      providers: [.claude],\n      note: \"Notification\"\n    ),\n    HookVariableDescriptor(\n      name: \"prompt\",\n      kind: .stdin,\n      description: \"User prompt\",\n      providers: [.claude],\n      note: \"UserPromptSubmit\"\n    ),\n    HookVariableDescriptor(\n      name: \"stop_hook_active\",\n      kind: .stdin,\n      description: \"Stop hook state\",\n      providers: [.claude],\n      note: \"Stop/SubagentStop\"\n    ),\n    HookVariableDescriptor(\n      name: \"agent_id\",\n      kind: .stdin,\n      description: \"Subagent identifier\",\n      providers: [.claude],\n      note: \"SubagentStart/SubagentStop\"\n    ),\n    HookVariableDescriptor(\n      name: \"agent_transcript_path\",\n      kind: .stdin,\n      description: \"Subagent transcript JSON path\",\n      providers: [.claude],\n      note: \"SubagentStop\"\n    ),\n    HookVariableDescriptor(\n      name: \"trigger\",\n      kind: .stdin,\n      description: \"Compaction trigger\",\n      providers: [.claude],\n      note: \"PreCompact/Setup\"\n    ),\n    HookVariableDescriptor(\n      name: \"custom_instructions\",\n      kind: .stdin,\n      description: \"Custom instructions\",\n      providers: [.claude],\n      note: \"PreCompact\"\n    ),\n    HookVariableDescriptor(\n      name: \"source\",\n      kind: .stdin,\n      description: \"Session start source\",\n      providers: [.claude],\n      note: \"SessionStart\"\n    ),\n    HookVariableDescriptor(\n      name: \"model\",\n      kind: .stdin,\n      description: \"Model name\",\n      providers: [.claude],\n      note: \"SessionStart\"\n    ),\n    HookVariableDescriptor(\n      name: \"agent_type\",\n      kind: .stdin,\n      description: \"Agent type\",\n      providers: [.claude],\n      note: \"SessionStart/SubagentStart\"\n    ),\n    HookVariableDescriptor(\n      name: \"reason\",\n      kind: .stdin,\n      description: \"Session end reason\",\n      providers: [.claude],\n      note: \"SessionEnd\"\n    ),\n  ]\n\n  private static let geminiVariables: [HookVariableDescriptor] = [\n    HookVariableDescriptor(\n      name: \"GEMINI_PROJECT_DIR\",\n      kind: .env,\n      description: \"Project root directory\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"GEMINI_SESSION_ID\",\n      kind: .env,\n      description: \"Session identifier\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"GEMINI_CWD\",\n      kind: .env,\n      description: \"Current working directory\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"CLAUDE_PROJECT_DIR\",\n      kind: .env,\n      description: \"Alias for GEMINI_PROJECT_DIR\",\n      providers: [.gemini],\n      note: \"Gemini alias\"\n    ),\n    HookVariableDescriptor(\n      name: \"session_id\",\n      kind: .stdin,\n      description: \"Session identifier\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"transcript_path\",\n      kind: .stdin,\n      description: \"Transcript JSON path\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"cwd\",\n      kind: .stdin,\n      description: \"Current working directory\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"hook_event_name\",\n      kind: .stdin,\n      description: \"Hook event name\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"timestamp\",\n      kind: .stdin,\n      description: \"Event timestamp\",\n      providers: [.gemini],\n      note: nil\n    ),\n    HookVariableDescriptor(\n      name: \"tool_name\",\n      kind: .stdin,\n      description: \"Tool name\",\n      providers: [.gemini],\n      note: \"BeforeTool/AfterTool\"\n    ),\n    HookVariableDescriptor(\n      name: \"tool_input\",\n      kind: .stdin,\n      description: \"Tool input JSON\",\n      providers: [.gemini],\n      note: \"BeforeTool/AfterTool\"\n    ),\n    HookVariableDescriptor(\n      name: \"tool_response\",\n      kind: .stdin,\n      description: \"Tool response JSON\",\n      providers: [.gemini],\n      note: \"AfterTool\"\n    ),\n    HookVariableDescriptor(\n      name: \"mcp_context\",\n      kind: .stdin,\n      description: \"MCP context JSON\",\n      providers: [.gemini],\n      note: \"BeforeTool/AfterTool\"\n    ),\n    HookVariableDescriptor(\n      name: \"prompt\",\n      kind: .stdin,\n      description: \"User prompt\",\n      providers: [.gemini],\n      note: \"BeforeAgent/AfterAgent\"\n    ),\n    HookVariableDescriptor(\n      name: \"prompt_response\",\n      kind: .stdin,\n      description: \"Agent response\",\n      providers: [.gemini],\n      note: \"AfterAgent\"\n    ),\n    HookVariableDescriptor(\n      name: \"stop_hook_active\",\n      kind: .stdin,\n      description: \"Stop hook state\",\n      providers: [.gemini],\n      note: \"AfterAgent\"\n    ),\n    HookVariableDescriptor(\n      name: \"llm_request\",\n      kind: .stdin,\n      description: \"Model request JSON\",\n      providers: [.gemini],\n      note: \"BeforeModel/BeforeToolSelection/AfterModel\"\n    ),\n    HookVariableDescriptor(\n      name: \"llm_response\",\n      kind: .stdin,\n      description: \"Model response JSON\",\n      providers: [.gemini],\n      note: \"AfterModel\"\n    ),\n    HookVariableDescriptor(\n      name: \"source\",\n      kind: .stdin,\n      description: \"Session start source\",\n      providers: [.gemini],\n      note: \"SessionStart\"\n    ),\n    HookVariableDescriptor(\n      name: \"reason\",\n      kind: .stdin,\n      description: \"Session end reason\",\n      providers: [.gemini],\n      note: \"SessionEnd\"\n    ),\n    HookVariableDescriptor(\n      name: \"notification_type\",\n      kind: .stdin,\n      description: \"Notification type\",\n      providers: [.gemini],\n      note: \"Notification\"\n    ),\n    HookVariableDescriptor(\n      name: \"message\",\n      kind: .stdin,\n      description: \"Notification message\",\n      providers: [.gemini],\n      note: \"Notification\"\n    ),\n    HookVariableDescriptor(\n      name: \"details\",\n      kind: .stdin,\n      description: \"Notification details JSON\",\n      providers: [.gemini],\n      note: \"Notification\"\n    ),\n    HookVariableDescriptor(\n      name: \"trigger\",\n      kind: .stdin,\n      description: \"Compression trigger\",\n      providers: [.gemini],\n      note: \"PreCompress\"\n    ),\n  ]\n}\n"
  },
  {
    "path": "models/HookEventCatalog.swift",
    "content": "import Foundation\n\nstruct HookEventMatcher: Identifiable, Hashable, Sendable {\n  let value: String\n  let description: String?\n  let providers: Set<HookVariableProvider>?\n\n  var id: String { value }\n}\n\nstruct HookEventDescriptor: Identifiable, Hashable, Sendable {\n  let name: String\n  let description: String\n  let providers: Set<HookVariableProvider>\n  let aliases: [HookVariableProvider: String]\n  let supportsMatcher: Bool\n  let matchers: [HookEventMatcher]\n  let note: String?\n\n  var id: String { name }\n}\n\nstruct HookEventProviderResolution: Sendable {\n  let name: String\n  let canonicalName: String\n  let isKnown: Bool\n  let isSupported: Bool\n}\n\nenum HookEventCatalog {\n  static let all: [HookEventDescriptor] = {\n    let bundled = loadBundledEvents() ?? fallbackEvents\n    return merge(bundled)\n  }()\n\n  static var canonicalEvents: [String] { all.map(\\.name) }\n\n  static func descriptor(for eventName: String) -> HookEventDescriptor? {\n    let trimmed = eventName.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return nil }\n    if let match = all.first(where: { $0.name.caseInsensitiveCompare(trimmed) == .orderedSame }) {\n      return match\n    }\n    return all.first(where: { descriptor in\n      descriptor.aliases.values.contains { $0.caseInsensitiveCompare(trimmed) == .orderedSame }\n    })\n  }\n\n  static func description(for eventName: String) -> String {\n    guard let descriptor = descriptor(for: eventName) else {\n      return \"Custom event. Ensure the selected CLIs support this event name.\"\n    }\n    return descriptor.description\n  }\n\n  static func detailText(for eventName: String) -> String {\n    guard let descriptor = descriptor(for: eventName) else {\n      return \"Custom event. Ensure the selected CLIs support this event name.\"\n    }\n    let parts = [descriptor.description, descriptor.note].compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }\n    return parts.filter { !$0.isEmpty }.joined(separator: \" \")\n  }\n\n  static func supportsMatcher(_ eventName: String) -> Bool {\n    guard let descriptor = descriptor(for: eventName) else {\n      return true\n    }\n    return descriptor.supportsMatcher\n  }\n\n  static func supportsMatcher(_ eventName: String, provider: HookVariableProvider) -> Bool {\n    guard let descriptor = descriptor(for: eventName) else {\n      return true\n    }\n    guard descriptor.supportsMatcher, descriptor.providers.contains(provider) else {\n      return false\n    }\n    return matcherSupport(descriptor, provider: provider)\n  }\n\n  static func supportsMatcher(_ eventName: String, targets: HookTargets) -> Bool {\n    let enabled = targets.enabledProviders()\n    return enabled.contains { supportsMatcher(eventName, provider: $0) }\n  }\n\n  static func matchers(for eventName: String, targets: HookTargets? = nil) -> [HookEventMatcher] {\n    guard let descriptor = descriptor(for: eventName) else { return [] }\n    let base = descriptor.matchers\n    guard let targets else { return base }\n    let enabled = targets.enabledProviders()\n    return base.filter { matcher in\n      guard let providers = matcher.providers, !providers.isEmpty else { return true }\n      return !providers.isDisjoint(with: enabled)\n    }\n  }\n\n  static func matcherDescription(for eventName: String, matcher: String) -> String? {\n    let trimmed = matcher.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return nil }\n    return matchers(for: eventName).first(where: {\n      $0.value.caseInsensitiveCompare(trimmed) == .orderedSame\n    })?.description\n  }\n\n  static func resolveProviderEvent(_ eventName: String, for provider: HookVariableProvider) -> HookEventProviderResolution {\n    let trimmed = eventName.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else {\n      return HookEventProviderResolution(\n        name: trimmed,\n        canonicalName: trimmed,\n        isKnown: false,\n        isSupported: false\n      )\n    }\n    if let descriptor = descriptor(for: trimmed) {\n      let supported = descriptor.providers.contains(provider)\n      let name = descriptor.aliases[provider] ?? descriptor.name\n      return HookEventProviderResolution(\n        name: name,\n        canonicalName: descriptor.name,\n        isKnown: true,\n        isSupported: supported\n      )\n    }\n    return HookEventProviderResolution(\n      name: trimmed,\n      canonicalName: trimmed,\n      isKnown: false,\n      isSupported: true\n    )\n  }\n\n  static func canonicalName(for eventName: String, provider: HookVariableProvider) -> String {\n    resolveProviderEvent(eventName, for: provider).canonicalName\n  }\n\n  static func defaultName(event: String, matcher: String?, command: HookCommand?) -> String {\n    let e = event.trimmingCharacters(in: .whitespacesAndNewlines)\n    let m = matcher?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let base = e.isEmpty ? \"Hook\" : e\n    let cmd = command?.command.trimmingCharacters(in: .whitespacesAndNewlines)\n    let cmdShort: String? = {\n      guard let cmd, !cmd.isEmpty else { return nil }\n      return URL(fileURLWithPath: cmd).lastPathComponent\n    }()\n    let parts = [base, (m?.isEmpty == false ? m : nil), cmdShort].compactMap { $0 }\n    return parts.joined(separator: \" · \")\n  }\n\n  private struct HookEventFile: Codable {\n    let events: [HookEventRecord]\n  }\n\n  private struct HookEventRecord: Codable {\n    let name: String\n    let description: String\n    let providers: [HookVariableProvider]\n    let aliases: [String: String]?\n    let supportsMatcher: Bool?\n    let matchers: [HookEventMatcherRecord]?\n    let note: String?\n\n    func toDescriptor() -> HookEventDescriptor {\n      let aliasMap: [HookVariableProvider: String] = (aliases ?? [:]).reduce(into: [:]) { out, pair in\n        if let provider = HookVariableProvider(rawValue: pair.key) {\n          out[provider] = pair.value\n        }\n      }\n      let matcherList = (matchers ?? []).map { $0.toMatcher() }\n      let matcherSupport = supportsMatcher ?? !matcherList.isEmpty\n      return HookEventDescriptor(\n        name: name,\n        description: description,\n        providers: Set(providers),\n        aliases: aliasMap,\n        supportsMatcher: matcherSupport,\n        matchers: matcherList,\n        note: note\n      )\n    }\n  }\n\n  private struct HookEventMatcherRecord: Codable {\n    let value: String\n    let description: String?\n    let providers: [HookVariableProvider]?\n\n    func toMatcher() -> HookEventMatcher {\n      HookEventMatcher(\n        value: value,\n        description: description,\n        providers: providers.map(Set.init)\n      )\n    }\n  }\n\n  private static func loadBundledEvents() -> [HookEventDescriptor]? {\n    let bundle = Bundle.main\n    var urls: [URL] = []\n    if let url = bundle.url(forResource: \"hook-events\", withExtension: \"json\") {\n      urls.append(url)\n    }\n    if let url = bundle.url(\n      forResource: \"hook-events\",\n      withExtension: \"json\",\n      subdirectory: \"payload\"\n    ) {\n      urls.append(url)\n    }\n    for url in urls {\n      guard let data = try? Data(contentsOf: url) else { continue }\n      let decoder = JSONDecoder()\n      if let file = try? decoder.decode(HookEventFile.self, from: data) {\n        return file.events.map { $0.toDescriptor() }\n      }\n      if let list = try? decoder.decode([HookEventRecord].self, from: data) {\n        return list.map { $0.toDescriptor() }\n      }\n    }\n    return nil\n  }\n\n  private static func merge(_ events: [HookEventDescriptor]) -> [HookEventDescriptor] {\n    var map: [String: HookEventDescriptor] = [:]\n    var order: [String] = []\n    for event in events {\n      let key = event.name.lowercased()\n      if map[key] == nil {\n        order.append(key)\n      }\n      if let existing = map[key] {\n        let providers = existing.providers.union(event.providers)\n        let description = existing.description.count >= event.description.count ? existing.description : event.description\n        var aliases = existing.aliases\n        for (provider, alias) in event.aliases where aliases[provider] == nil {\n          aliases[provider] = alias\n        }\n        let supportsMatcher = existing.supportsMatcher || event.supportsMatcher\n        let matchers = mergeMatchers(existing.matchers, event.matchers)\n        let note = mergeNotes(existing.note, event.note)\n        map[key] = HookEventDescriptor(\n          name: existing.name,\n          description: description,\n          providers: providers,\n          aliases: aliases,\n          supportsMatcher: supportsMatcher,\n          matchers: matchers,\n          note: note\n        )\n      } else {\n        map[key] = event\n      }\n    }\n    return order.compactMap { map[$0] }\n  }\n\n  private static func mergeMatchers(_ lhs: [HookEventMatcher], _ rhs: [HookEventMatcher]) -> [HookEventMatcher] {\n    var map: [String: HookEventMatcher] = [:]\n    for matcher in lhs + rhs {\n      let key = matcher.value\n      if let existing = map[key] {\n        let providers = mergeProviderSets(existing.providers, matcher.providers)\n        let description = existing.description ?? matcher.description\n        map[key] = HookEventMatcher(value: key, description: description, providers: providers)\n      } else {\n        map[key] = matcher\n      }\n    }\n    return map.values.sorted { $0.value.localizedCaseInsensitiveCompare($1.value) == .orderedAscending }\n  }\n\n  private static func matcherSupport(_ descriptor: HookEventDescriptor, provider: HookVariableProvider) -> Bool {\n    let matchers = descriptor.matchers\n    guard !matchers.isEmpty else { return true }\n    return matchers.contains { matcher in\n      guard let providers = matcher.providers, !providers.isEmpty else { return true }\n      return providers.contains(provider)\n    }\n  }\n\n  private static func mergeProviderSets(\n    _ lhs: Set<HookVariableProvider>?,\n    _ rhs: Set<HookVariableProvider>?\n  ) -> Set<HookVariableProvider>? {\n    if lhs == nil { return rhs }\n    if rhs == nil { return lhs }\n    return lhs!.union(rhs!)\n  }\n\n  private static func mergeNotes(_ a: String?, _ b: String?) -> String? {\n    let left = a?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    let right = b?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    if left.isEmpty { return right.isEmpty ? nil : right }\n    if right.isEmpty { return left }\n    if left == right { return left }\n    return \"\\(left) · \\(right)\"\n  }\n\n  private static let fallbackEvents: [HookEventDescriptor] = [\n    HookEventDescriptor(\n      name: \"Setup\",\n      description: \"Load context and configure the environment during repository initialization or maintenance.\",\n      providers: [.claude],\n      aliases: [:],\n      supportsMatcher: false,\n      matchers: [],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"SessionStart\",\n      description: \"Runs when a session starts.\",\n      providers: [.claude, .gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"startup\", description: \"Session starts fresh.\", providers: [.gemini]),\n        HookEventMatcher(value: \"resume\", description: \"Session resumes from history.\", providers: [.gemini]),\n        HookEventMatcher(value: \"clear\", description: \"Session is cleared and restarted.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"UserPromptSubmit\",\n      description: \"Runs when the user submits a prompt.\",\n      providers: [.claude, .gemini],\n      aliases: [.gemini: \"BeforeAgent\"],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"PreToolUse\",\n      description: \"Runs before a tool is called.\",\n      providers: [.claude, .gemini],\n      aliases: [.gemini: \"BeforeTool\"],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"Bash\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Edit\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Read\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write|Edit\", description: \"Regex example.\", providers: [.claude]),\n        HookEventMatcher(value: \"Notebook.*\", description: \"Regex example.\", providers: [.claude]),\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini]),\n        HookEventMatcher(value: \"write_.*\", description: \"Regex example.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"PermissionRequest\",\n      description: \"Runs when a tool permission is requested.\",\n      providers: [.claude],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"Bash\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Edit\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Read\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write|Edit\", description: \"Regex example.\", providers: [.claude]),\n        HookEventMatcher(value: \"Notebook.*\", description: \"Regex example.\", providers: [.claude])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"PostToolUse\",\n      description: \"Runs after a tool call succeeds.\",\n      providers: [.claude, .gemini],\n      aliases: [.gemini: \"AfterTool\"],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"Bash\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Edit\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Read\", description: \"Tool name.\", providers: [.claude]),\n        HookEventMatcher(value: \"Write|Edit\", description: \"Regex example.\", providers: [.claude]),\n        HookEventMatcher(value: \"Notebook.*\", description: \"Regex example.\", providers: [.claude]),\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini]),\n        HookEventMatcher(value: \"write_.*\", description: \"Regex example.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"PostToolUseFailure\",\n      description: \"Runs after a tool call fails.\",\n      providers: [.claude],\n      aliases: [:],\n      supportsMatcher: false,\n      matchers: [],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"SubagentStart\",\n      description: \"Runs when a subagent (Task tool call) starts.\",\n      providers: [.claude],\n      aliases: [:],\n      supportsMatcher: false,\n      matchers: [],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"SubagentStop\",\n      description: \"Runs when a subagent (Task tool call) finishes.\",\n      providers: [.claude],\n      aliases: [:],\n      supportsMatcher: false,\n      matchers: [],\n      note: \"Prompt-based hooks are supported for this event.\"\n    ),\n    HookEventDescriptor(\n      name: \"Stop\",\n      description: \"Runs when the assistant finishes responding.\",\n      providers: [.claude, .gemini, .codex],\n      aliases: [.gemini: \"AfterAgent\"],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini])\n      ],\n      note: \"Prompt-based hooks are supported for this event.\"\n    ),\n    HookEventDescriptor(\n      name: \"PreCompact\",\n      description: \"Runs before context compaction.\",\n      providers: [.claude, .gemini],\n      aliases: [.gemini: \"PreCompress\"],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"SessionEnd\",\n      description: \"Runs when a session ends.\",\n      providers: [.claude, .gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"exit\", description: \"Session exits.\", providers: [.gemini]),\n        HookEventMatcher(value: \"clear\", description: \"Session is cleared.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"Notification\",\n      description: \"Runs when the CLI raises a notification.\",\n      providers: [.claude, .gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [\n        HookEventMatcher(value: \"*\", description: \"Wildcard matcher.\", providers: [.gemini])\n      ],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"BeforeModel\",\n      description: \"Runs before a request is sent to the model.\",\n      providers: [.gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"AfterModel\",\n      description: \"Runs after the model responds, before tool selection.\",\n      providers: [.gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [],\n      note: nil\n    ),\n    HookEventDescriptor(\n      name: \"BeforeToolSelection\",\n      description: \"Runs before tool selection.\",\n      providers: [.gemini],\n      aliases: [:],\n      supportsMatcher: true,\n      matchers: [],\n      note: nil\n    ),\n  ]\n}\n\nprivate extension HookTargets {\n  func enabledProviders() -> Set<HookVariableProvider> {\n    var providers: Set<HookVariableProvider> = []\n    if codex { providers.insert(.codex) }\n    if claude { providers.insert(.claude) }\n    if gemini { providers.insert(.gemini) }\n    return providers\n  }\n}\n"
  },
  {
    "path": "models/HookSyncWarning.swift",
    "content": "import Foundation\n\nstruct HookSyncWarning: Identifiable, Equatable {\n  let id = UUID()\n  let provider: HookTarget\n  let message: String\n}\n\n"
  },
  {
    "path": "models/Hooks.swift",
    "content": "import Foundation\n\nenum HookTarget: String, Codable, CaseIterable, Sendable {\n  case codex\n  case claude\n  case gemini\n\n  var displayName: String {\n    switch self {\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude\"\n    case .gemini: return \"Gemini\"\n    }\n  }\n\n  var usageProvider: UsageProviderKind {\n    switch self {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n\n  var baseKind: SessionSource.Kind { usageProvider.baseKind }\n}\n\nstruct HookTargets: Codable, Equatable, Hashable, Sendable {\n  var codex: Bool\n  var claude: Bool\n  var gemini: Bool\n\n  init(codex: Bool = true, claude: Bool = true, gemini: Bool = true) {\n    self.codex = codex\n    self.claude = claude\n    self.gemini = gemini\n  }\n\n  func isEnabled(for target: HookTarget) -> Bool {\n    switch target {\n    case .codex: return codex\n    case .claude: return claude\n    case .gemini: return gemini\n    }\n  }\n\n  mutating func setEnabled(_ value: Bool, for target: HookTarget) {\n    switch target {\n    case .codex: codex = value\n    case .claude: claude = value\n    case .gemini: gemini = value\n    }\n  }\n\n  var allEnabled: Bool { codex && claude && gemini }\n}\n\nstruct HookCommand: Codable, Equatable, Hashable, Sendable {\n  var command: String\n  var args: [String]?\n  var env: [String: String]?\n  var timeoutMs: Int?\n\n  private enum CodingKeys: String, CodingKey {\n    case command\n    case args\n    case env\n    case timeoutMs\n  }\n\n  private struct KeyValuePair: Codable, Hashable {\n    var key: String\n    var value: String\n  }\n\n  init(command: String, args: [String]? = nil, env: [String: String]? = nil, timeoutMs: Int? = nil) {\n    self.command = command\n    self.args = args\n    self.env = env\n    self.timeoutMs = timeoutMs\n  }\n\n  init(from decoder: Decoder) throws {\n    let container = try decoder.container(keyedBy: CodingKeys.self)\n    command = try container.decode(String.self, forKey: .command)\n    args = try container.decodeIfPresent([String].self, forKey: .args)\n    timeoutMs = try container.decodeIfPresent(Int.self, forKey: .timeoutMs)\n    if let dict = try? container.decodeIfPresent([String: String].self, forKey: .env) {\n      env = dict\n    } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .env) {\n      env = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) })\n    } else {\n      env = nil\n    }\n  }\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encode(command, forKey: .command)\n    try container.encodeIfPresent(args, forKey: .args)\n    try container.encodeIfPresent(env, forKey: .env)\n    try container.encodeIfPresent(timeoutMs, forKey: .timeoutMs)\n  }\n}\n\nstruct HookRule: Codable, Identifiable, Equatable, Hashable, Sendable {\n  var id: String\n  var name: String\n  var description: String?\n  var event: String\n  var matcher: String?\n  var commands: [HookCommand]\n  var enabled: Bool\n  /// nil means enabled for all targets (default).\n  var targets: HookTargets?\n  var source: String\n  var createdAt: Date\n  var updatedAt: Date\n\n  init(\n    id: String = UUID().uuidString,\n    name: String,\n    description: String? = nil,\n    event: String,\n    matcher: String? = nil,\n    commands: [HookCommand],\n    enabled: Bool = true,\n    targets: HookTargets? = nil,\n    source: String = \"user\",\n    createdAt: Date = Date(),\n    updatedAt: Date = Date()\n  ) {\n    self.id = id\n    self.name = name\n    self.description = description\n    self.event = event\n    self.matcher = matcher\n    self.commands = commands\n    self.enabled = enabled\n    self.targets = targets\n    self.source = source\n    self.createdAt = createdAt\n    self.updatedAt = updatedAt\n  }\n\n  func isEnabled(for target: HookTarget) -> Bool {\n    guard enabled else { return false }\n    return (targets?.isEnabled(for: target) ?? true)\n  }\n}\n"
  },
  {
    "path": "models/HooksViewModel.swift",
    "content": "import Foundation\n\n@MainActor\nfinal class HooksViewModel: ObservableObject {\n  @Published var rules: [HookRule] = []\n  @Published var selectedRuleId: String? = nil\n  @Published var searchText: String = \"\"\n  @Published var showAddSheet = false\n  @Published var editingRule: HookRule? = nil\n  @Published var syncWarnings: [HookSyncWarning] = []\n  @Published var errorMessage: String? = nil\n  @Published var isLoading = false\n  @Published var showImportSheet = false\n  @Published var importCandidates: [HookImportCandidate] = []\n  @Published var isImporting = false\n  @Published var importStatusMessage: String? = nil\n\n  private let store = HooksStore()\n  private let syncService = HooksSyncService()\n\n  var selectedRule: HookRule? {\n    guard let id = selectedRuleId else { return nil }\n    return rules.first(where: { $0.id == id })\n  }\n\n  var filteredRules: [HookRule] {\n    let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    if query.isEmpty { return rules }\n    return rules.filter { rule in\n      rule.name.localizedCaseInsensitiveContains(query) ||\n      rule.event.localizedCaseInsensitiveContains(query) ||\n      (rule.matcher?.localizedCaseInsensitiveContains(query) ?? false) ||\n      rule.commands.contains(where: { $0.command.localizedCaseInsensitiveContains(query) })\n    }\n  }\n\n  func load() async {\n    isLoading = true\n    defer { isLoading = false }\n    rules = await store.list()\n  }\n\n  // MARK: - CRUD\n\n  func addRule(_ rule: HookRule) async {\n    do {\n      try await store.upsert(rule)\n      await load()\n      selectedRuleId = rule.id\n      await applyToProviders()\n    } catch {\n      errorMessage = \"Failed to save hook\"\n    }\n  }\n\n  func updateRule(_ rule: HookRule) async {\n    do {\n      try await store.upsert(rule)\n      await load()\n      await applyToProviders()\n    } catch {\n      errorMessage = \"Failed to save hook\"\n    }\n  }\n\n  func deleteRule(id: String) async {\n    do {\n      try await store.delete(id: id)\n      if selectedRuleId == id { selectedRuleId = nil }\n      await load()\n      await applyToProviders()\n    } catch {\n      errorMessage = \"Failed to delete hook\"\n    }\n  }\n\n  func updateRuleEnabled(id: String, value: Bool) {\n    updateLocalRule(id: id) { $0.enabled = value }\n    Task {\n      do {\n        try await store.update(id: id) { rule in\n          rule.enabled = value\n          rule.updatedAt = Date()\n        }\n        await applyToProviders()\n      } catch {\n        errorMessage = \"Failed to update hook\"\n      }\n    }\n  }\n\n  func updateRuleTarget(id: String, target: HookTarget, value: Bool) {\n    updateLocalRule(id: id) { rule in\n      var targets = rule.targets ?? HookTargets()\n      targets.setEnabled(value, for: target)\n      rule.targets = targets.allEnabled ? nil : targets\n    }\n    Task {\n      do {\n        try await store.update(id: id) { rule in\n          var targets = rule.targets ?? HookTargets()\n          targets.setEnabled(value, for: target)\n          rule.targets = targets.allEnabled ? nil : targets\n          rule.updatedAt = Date()\n        }\n        await applyToProviders()\n      } catch {\n        errorMessage = \"Failed to update hook\"\n      }\n    }\n  }\n\n  private func updateLocalRule(id: String, mutate: (inout HookRule) -> Void) {\n    guard let idx = rules.firstIndex(where: { $0.id == id }) else { return }\n    mutate(&rules[idx])\n  }\n\n  // MARK: - Import\n\n  func beginImportFromHome() {\n    showImportSheet = true\n    Task { await loadImportCandidatesFromHome() }\n  }\n\n  func loadImportCandidatesFromHome() async {\n    isImporting = true\n    importStatusMessage = \"Scanning…\"\n\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n        directory: home,\n        purpose: .generalAccess,\n        message: \"Authorize your Home folder to import hooks\"\n      )\n    }\n\n    let existing = await store.list()\n    let existingSignatures = Set(existing.map { HooksImportService.hookSignature($0) })\n\n    let scanned = await Task.detached(priority: .userInitiated) {\n      await HooksImportService.scan(scope: .home)\n    }.value\n\n    var candidates = scanned\n    for idx in candidates.indices {\n      let signature = candidates[idx].signature\n      candidates[idx].hasConflict = existingSignatures.contains(signature)\n      candidates[idx].resolution = candidates[idx].hasConflict ? .skip : .overwrite\n      candidates[idx].renameName = candidates[idx].rule.name\n    }\n\n    await MainActor.run {\n      self.importCandidates = candidates\n      self.isImporting = false\n      self.importStatusMessage = candidates.isEmpty ? \"No hooks found.\" : nil\n    }\n  }\n\n  func cancelImport() {\n    showImportSheet = false\n    importCandidates = []\n    importStatusMessage = nil\n  }\n\n  func importSelectedHooks() async {\n    let selected = importCandidates.filter { $0.isSelected }\n    guard !selected.isEmpty else {\n      importStatusMessage = \"No hooks selected.\"\n      return\n    }\n\n    let existing = await store.list()\n    let existingBySignature = Dictionary(grouping: existing, by: { HooksImportService.hookSignature($0) })\n\n    var importedCount = 0\n    var importedCandidateIds: Set<UUID> = []\n\n    for item in selected {\n      switch item.resolution {\n      case .skip:\n        continue\n      case .overwrite:\n        if let existingRule = existingBySignature[item.signature]?.first {\n          var updated = item.rule\n          updated.id = existingRule.id\n          updated.createdAt = existingRule.createdAt\n          updated.updatedAt = Date()\n          do { try await store.upsert(updated) } catch { continue }\n        } else {\n          var fresh = item.rule\n          fresh.id = UUID().uuidString\n          fresh.createdAt = Date()\n          fresh.updatedAt = Date()\n          do { try await store.upsert(fresh) } catch { continue }\n        }\n        importedCount += 1\n        importedCandidateIds.insert(item.id)\n      case .rename:\n        let newName = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !newName.isEmpty else { continue }\n        var fresh = item.rule\n        fresh.id = UUID().uuidString\n        fresh.name = newName\n        fresh.createdAt = Date()\n        fresh.updatedAt = Date()\n        do { try await store.upsert(fresh) } catch { continue }\n        importedCount += 1\n        importedCandidateIds.insert(item.id)\n      }\n    }\n\n    await load()\n    await applyToProviders()\n    importStatusMessage = \"Imported \\(importedCount) hook(s).\"\n    if !importedCandidateIds.isEmpty {\n      importCandidates.removeAll { importedCandidateIds.contains($0.id) }\n    }\n    if importCandidates.isEmpty {\n      closeImportSheetAfterDelay()\n    }\n  }\n\n  private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n    Task { @MainActor in\n      try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n      self.showImportSheet = false\n      self.importStatusMessage = nil\n    }\n  }\n\n  // MARK: - Apply\n\n  func applyToProviders() async {\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n        directory: home,\n        purpose: .generalAccess,\n        message: \"Authorize your Home folder to apply hooks\"\n      )\n    }\n\n    let warnings = await syncService.syncGlobal(rules: rules)\n    syncWarnings = warnings\n\n    if !warnings.isEmpty {\n      errorMessage = \"Applied with \\(warnings.count) warning(s)\"\n    } else {\n      errorMessage = nil\n    }\n  }\n}\n\n"
  },
  {
    "path": "models/InternalSkill.swift",
    "content": "import Foundation\n\nenum InternalSkillIOMode: String, Codable, Sendable {\n  case stdin\n  case file\n}\n\nenum InternalSkillOutputMode: String, Codable, Sendable {\n  case stdout\n  case file\n}\n\nstruct InternalSkillInvocation: Codable, Hashable, Sendable {\n  var provider: SessionSource.Kind\n  var executable: String?\n  var args: [String]\n  var inputMode: InternalSkillIOMode\n  var outputMode: InternalSkillOutputMode\n  var timeoutSeconds: Double?\n}\n\nstruct InternalSkillAssetPaths: Codable, Hashable, Sendable {\n  var skill: String?\n  var prompt: String?\n  var schema: String?\n  var docs: String?\n}\n\nstruct InternalSkillDefinition: Codable, Identifiable, Hashable, Sendable {\n  var id: String\n  var feature: WizardFeature\n  var title: String\n  var description: String?\n  var version: String?\n  var assets: InternalSkillAssetPaths?\n  var invocations: [InternalSkillInvocation]\n  var docsSources: [WizardDocSource]?\n\n  var displayTitle: String { title.isEmpty ? id : title }\n}\n\nstruct InternalSkillsIndex: Codable, Hashable, Sendable {\n  var skills: [InternalSkillDefinition]\n}\n\nstruct InternalSkillAsset: Hashable, Sendable {\n  var definition: InternalSkillDefinition\n  var rootURL: URL\n  var skillMarkdown: String?\n  var prompt: String?\n  var schema: String?\n  var docsOverrides: [WizardDocSource]\n}\n\nstruct WizardDocSource: Codable, Hashable, Sendable {\n  var feature: WizardFeature\n  var provider: String?\n  var url: String\n  var maxChars: Int?\n  var cacheTTLHours: Int?\n}\n\nstruct WizardDocSnippet: Codable, Hashable, Sendable {\n  var url: String\n  var provider: String?\n  var text: String\n}\n"
  },
  {
    "path": "models/LocalAuthProvider.swift",
    "content": "import Foundation\n\nenum LocalAuthProvider: String, CaseIterable, Identifiable {\n  case codex\n  case claude\n  case gemini\n  case antigravity\n  case qwen\n\n  var id: String { rawValue }\n\n  var displayName: String {\n    switch self {\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude\"\n    case .gemini: return \"Gemini\"\n    case .antigravity: return \"Antigravity\"\n    case .qwen: return \"Qwen Code\"\n    }\n  }\n\n  var loginFlag: String {\n    switch self {\n    case .gemini: return \"--login\"\n    case .codex: return \"--codex-login\"\n    case .claude: return \"--claude-login\"\n    case .antigravity: return \"--antigravity-login\"\n    case .qwen: return \"--qwen-login\"\n    }\n  }\n\n  var authAliases: [String] {\n    switch self {\n    case .codex:\n      return [\"codex\", \"openai\"]\n    case .claude:\n      return [\"claude\", \"anthropic\"]\n    case .gemini:\n      return [\"gemini\"]\n    case .antigravity:\n      return [\"antigravity\"]\n    case .qwen:\n      return [\"qwen\", \"qwen-code\", \"qwen_code\"]\n    }\n  }\n}\n"
  },
  {
    "path": "models/MCPServer.swift",
    "content": "import Foundation\n\n// MARK: - MCP Server Models\n\npublic enum MCPServerKind: String, Codable, Sendable { case stdio, sse, streamable_http }\n\npublic struct MCPCapability: Codable, Identifiable, Hashable, Sendable {\n    public var id: String { name }\n    public var name: String\n    public var enabled: Bool\n}\n\npublic struct MCPServerMeta: Codable, Equatable, Sendable {\n    public var description: String?\n    public var version: String?\n    public var websiteUrl: String?\n    public var repositoryURL: String?\n}\n\npublic enum MCPServerTarget: String, Codable, CaseIterable, Sendable {\n    case codex\n    case claude\n    case gemini\n\n    var baseKind: SessionSource.Kind {\n        switch self {\n        case .codex: return .codex\n        case .claude: return .claude\n        case .gemini: return .gemini\n        }\n    }\n}\n\npublic struct MCPServerTargets: Codable, Equatable, Hashable, Sendable {\n    public var codex: Bool\n    public var claude: Bool\n    public var gemini: Bool\n\n    public init(codex: Bool = true, claude: Bool = true, gemini: Bool = true) {\n        self.codex = codex\n        self.claude = claude\n        self.gemini = gemini\n    }\n\n    public func isEnabled(for target: MCPServerTarget) -> Bool {\n        switch target {\n        case .codex: return codex\n        case .claude: return claude\n        case .gemini: return gemini\n        }\n    }\n\n    public mutating func setEnabled(_ value: Bool, for target: MCPServerTarget) {\n        switch target {\n        case .codex:\n            codex = value\n        case .claude:\n            claude = value\n        case .gemini:\n            gemini = value\n        }\n    }\n}\n\npublic struct MCPServer: Codable, Identifiable, Equatable, Sendable {\n    public var id: String { name }\n    public var name: String\n    public var kind: MCPServerKind\n\n    // stdio\n    public var command: String?\n    public var args: [String]?\n    public var env: [String: String]?\n\n    // network\n    public var url: String?\n    public var headers: [String: String]?\n\n    // meta\n    public var meta: MCPServerMeta?\n\n    // dynamic\n    public var enabled: Bool\n    public var capabilities: [MCPCapability]\n    public var targets: MCPServerTargets?\n\n    public init(\n        name: String,\n        kind: MCPServerKind,\n        command: String? = nil,\n        args: [String]? = nil,\n        env: [String: String]? = nil,\n        url: String? = nil,\n        headers: [String: String]? = nil,\n        meta: MCPServerMeta? = nil,\n        enabled: Bool = true,\n        capabilities: [MCPCapability] = [],\n        targets: MCPServerTargets? = nil\n    ) {\n        self.name = name\n        self.kind = kind\n        self.command = command\n        self.args = args\n        self.env = env\n        self.url = url\n        self.headers = headers\n        self.meta = meta\n        self.enabled = enabled\n        self.capabilities = capabilities\n        self.targets = targets\n    }\n\n    public func isEnabled(for target: MCPServerTarget) -> Bool {\n        guard enabled else { return false }\n        return (targets?.isEnabled(for: target) ?? true)\n    }\n\n    public func withTargets(_ update: (inout MCPServerTargets) -> Void) -> MCPServer {\n        var copy = self\n        var current = copy.targets ?? MCPServerTargets()\n        update(&current)\n        copy.targets = current\n        return copy\n    }\n}\n\n// A lightweight draft parsed from import payloads before persistence\npublic struct MCPServerDraft: Codable, Sendable {\n    public var name: String?\n    public var kind: MCPServerKind\n    public var command: String?\n    public var args: [String]?\n    public var env: [String: String]?\n    public var url: String?\n    public var headers: [String: String]?\n    public var meta: MCPServerMeta?\n}\n\npublic extension Array where Element == MCPServer {\n    func enabledServers(for target: MCPServerTarget) -> [MCPServer] {\n        filter { $0.isEnabled(for: target) }\n    }\n}\n"
  },
  {
    "path": "models/MCPServersViewModel.swift",
    "content": "import Foundation\nimport SwiftUI\n\n@MainActor\nfinal class MCPServersViewModel: ObservableObject {\n    enum Tab: Hashable { case importWizard, servers, advanced }\n\n    // UI state\n    @Published var activeTab: Tab = .importWizard\n    @Published var importText: String = \"\"\n    @Published var importError: String? = nil\n    @Published var isParsing: Bool = false\n    @Published var drafts: [MCPServerDraft] = []\n\n    @Published var servers: [MCPServer] = []\n    @Published var selectedServerName: String? = nil\n    @Published var errorMessage: String? = nil\n    @Published var testInProgress: Bool = false\n    @Published var testMessage: String? = nil\n    private var testTask: Task<Void, Never>? = nil\n    @Published var showImportSheet: Bool = false\n    @Published var importCandidates: [MCPImportCandidate] = []\n    @Published var isImporting: Bool = false\n    @Published var importStatusMessage: String? = nil\n\n    // Editor/Form state\n    @Published var isEditingExisting: Bool = false\n    @Published var originalName: String? = nil\n    @Published var formName: String = \"\"\n    @Published var formKind: MCPServerKind = .stdio\n    @Published var formURL: String = \"\"\n    @Published var formCommand: String = \"\"\n    @Published var formArgs: String = \"\"               // space-separated\n    @Published var formArgsJSONText: String = \"[]\"      // JSON array\n    @Published var formArgsUseJSON: Bool = false\n    @Published var formEnvText: String = \"\"            // key=value per line\n    @Published var formEnvJSONText: String = \"{}\"       // JSON object\n    @Published var formEnvUseJSON: Bool = false\n    @Published var formHeadersText: String = \"\"        // key=value per line\n    @Published var formHeadersJSONText: String = \"{}\"   // JSON object\n    @Published var formHeadersUseJSON: Bool = false\n    @Published var formEnabled: Bool = true\n    @Published var formTargetsCodex: Bool = true\n    @Published var formTargetsClaude: Bool = true\n    @Published var formTargetsGemini: Bool = true\n\n    private let store = MCPServersStore()\n    private let tester = MCPQuickTestService()\n\n    func loadText(_ text: String) {\n        importText = text\n        parseImportText()\n    }\n\n    func clearImport() {\n        importText = \"\"\n        drafts = []\n        importError = nil\n        isParsing = false\n    }\n\n    func loadServers() async {\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(directory: codmate, purpose: .generalAccess, message: \"Authorize ~/.codmate to read MCP servers\")\n        }\n        let list = await store.list()\n        self.servers = list\n\n        // Auto-select first server if none selected (matching Providers behavior)\n        if let currentName = selectedServerName, !list.contains(where: { $0.name == currentName }) {\n            selectedServerName = list.first?.name\n        } else if selectedServerName == nil {\n            selectedServerName = list.first?.name\n        }\n    }\n\n    // MARK: - Import (Home)\n    func beginImportFromHome() {\n        showImportSheet = true\n        Task { await loadImportCandidatesFromHome() }\n    }\n\n    func loadImportCandidatesFromHome() async {\n        isImporting = true\n        importStatusMessage = \"Scanning…\"\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: home,\n                purpose: .generalAccess,\n                message: \"Authorize your Home folder to import MCP servers\"\n            )\n        }\n\n        let existing = await store.list()\n        let existingNames = Set(existing.map(\\.name))\n        let managedSignatures = Set(existing.map { MCPImportService.signature(for: $0) })\n\n        let scanned = await Task.detached(priority: .userInitiated) {\n            MCPImportService.scan(scope: .home)\n        }.value\n\n        // CodMate store is the source of truth; provider configs can drift if edited by other tools.\n        let filtered = MCPImportService.filterManagedCandidates(scanned, managedSignatures: managedSignatures)\n        let candidates = filtered.map { item -> MCPImportCandidate in\n            var updated = item\n            if existingNames.contains(item.name) {\n                updated.hasConflict = true\n                updated.isSelected = false\n                updated.resolution = .skip\n                updated.renameName = item.name\n            }\n            return updated\n        }\n\n        if candidates.isEmpty {\n            importStatusMessage = \"No MCP servers found.\"\n        } else {\n            importStatusMessage = nil\n        }\n\n        importCandidates = candidates\n        isImporting = false\n    }\n\n    func cancelImport() {\n        showImportSheet = false\n        importCandidates = []\n        importStatusMessage = nil\n    }\n\n    func importSelectedServers() async {\n        let selected = importCandidates.filter { $0.isSelected }\n        guard !selected.isEmpty else {\n            importStatusMessage = \"No servers selected.\"\n            return\n        }\n\n        let resolvedNames = selected.compactMap { item -> String? in\n            let resolution = item.resolution\n            switch resolution {\n            case .skip:\n                return nil\n            case .overwrite:\n                return item.name\n            case .rename:\n                let trimmed = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines)\n                return trimmed.isEmpty ? nil : trimmed\n            }\n        }\n        let duplicates = Dictionary(grouping: resolvedNames, by: { $0 }).filter { $1.count > 1 }.keys\n        if !duplicates.isEmpty {\n            importStatusMessage = \"Resolve duplicate names before importing.\"\n            return\n        }\n\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: \"Authorize ~/.codmate to save MCP servers\")\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: \"Authorize ~/.codex to update Codex config\")\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: \"Authorize your Home folder to update Claude config\")\n        }\n\n        var incoming: [MCPServer] = []\n        var importedCandidateIds: Set<UUID> = []\n        for item in selected {\n            let resolution = item.resolution\n            switch resolution {\n            case .skip:\n                continue\n            case .overwrite, .rename:\n                let finalName = (resolution == .rename ? item.renameName : item.name)\n                    .trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !finalName.isEmpty else { continue }\n                let meta = MCPServerMeta(description: item.description, version: nil, websiteUrl: nil, repositoryURL: nil)\n                let server = MCPServer(\n                    name: finalName,\n                    kind: item.kind,\n                    command: item.command,\n                    args: item.args,\n                    env: item.env,\n                    url: item.url,\n                    headers: item.headers,\n                    meta: meta,\n                    enabled: true,\n                    capabilities: [],\n                    targets: MCPServerTargets()\n                )\n                incoming.append(server)\n                importedCandidateIds.insert(item.id)\n            }\n        }\n\n        do {\n            try await store.upsertMany(incoming)\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n            importStatusMessage = \"Imported \\(incoming.count) server(s).\"\n            if !importedCandidateIds.isEmpty {\n                importCandidates.removeAll { importedCandidateIds.contains($0.id) }\n            }\n            if importCandidates.isEmpty {\n                closeImportSheetAfterDelay()\n            }\n        } catch {\n            importStatusMessage = \"Import failed: \\(error.localizedDescription)\"\n        }\n    }\n\n    private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n        Task { @MainActor in\n            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n            self.showImportSheet = false\n            self.importStatusMessage = nil\n        }\n    }\n\n    func startNewForm() {\n        isEditingExisting = false\n        originalName = nil\n        formName = \"\"\n        formKind = .stdio\n        formURL = \"\"\n        formCommand = \"\"\n        formArgs = \"\"\n        formArgsJSONText = \"[]\"\n        formArgsUseJSON = false\n        formEnvText = \"\"\n        formEnvJSONText = \"{}\"\n        formEnvUseJSON = false\n        formHeadersText = \"\"\n        formHeadersJSONText = \"{}\"\n        formHeadersUseJSON = false\n        formEnabled = true\n        formTargetsCodex = true\n        formTargetsClaude = true\n        formTargetsGemini = true\n        testMessage = nil\n    }\n\n    func startEditForm(from server: MCPServer) {\n        isEditingExisting = true\n        originalName = server.name\n        formName = server.name\n        formKind = server.kind\n        formURL = server.url ?? \"\"\n        formCommand = server.command ?? \"\"\n        let argsArr = server.args ?? []\n        formArgs = argsArr.joined(separator: \"\\n\")\n        formArgsJSONText = (try? Self.jsonString(argsArr)) ?? \"[]\"\n        formArgsUseJSON = false\n        formEnvText = Self.serializePairs(server.env)\n        formEnvJSONText = (try? Self.jsonString(server.env ?? [:])) ?? \"{}\"\n        formEnvUseJSON = false\n        formHeadersText = Self.serializePairs(server.headers)\n        formHeadersJSONText = (try? Self.jsonString(server.headers ?? [:])) ?? \"{}\"\n        formHeadersUseJSON = false\n        formEnabled = server.enabled\n        let targets = server.targets ?? MCPServerTargets()\n        formTargetsCodex = targets.codex\n        formTargetsClaude = targets.claude\n        formTargetsGemini = targets.gemini\n        testMessage = nil\n    }\n\n    func formCanSave() -> Bool {\n        !formName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n    }\n\n    private static func parsePairs(_ text: String) -> [String: String]? {\n        let lines = text.split(separator: \"\\n\")\n        var dict: [String: String] = [:]\n        for line in lines {\n            let raw = line.trimmingCharacters(in: .whitespaces)\n            if raw.isEmpty { continue }\n            if let eq = raw.firstIndex(of: \"=\") {\n                let k = String(raw[..<eq]).trimmingCharacters(in: .whitespaces)\n                let v = String(raw[raw.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n                if !k.isEmpty { dict[k] = v }\n            }\n        }\n        return dict.isEmpty ? nil : dict\n    }\n\n    private static func serializePairs(_ dict: [String: String]?) -> String {\n        guard let dict, !dict.isEmpty else { return \"\" }\n        return dict.keys.sorted().map { \"\\($0)=\\(dict[$0]!)\" }.joined(separator: \"\\n\")\n    }\n\n    private static func jsonString<T: Encodable>(_ value: T) throws -> String {\n        let enc = JSONEncoder()\n        enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]\n        let data = try enc.encode(AnyEncodable(value))\n        return String(data: data, encoding: .utf8) ?? \"{}\"\n    }\n\n    private static func parseJSONStringDict(_ text: String) -> [String: String]? {\n        guard let data = text.data(using: .utf8) else { return nil }\n        if let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {\n            var out: [String: String] = [:]\n            for (k, v) in obj { out[k] = String(describing: v) }\n            return out.isEmpty ? nil : out\n        }\n        if let obj = try? JSONDecoder().decode([String: String].self, from: data) { return obj }\n        return nil\n    }\n\n    private static func parseJSONStringArray(_ text: String) -> [String]? {\n        guard let data = text.data(using: .utf8) else { return nil }\n        if let arr = try? JSONSerialization.jsonObject(with: data) as? [Any] {\n            let out = arr.map { String(describing: $0) }\n            return out\n        }\n        if let arr = try? JSONDecoder().decode([String].self, from: data) { return arr }\n        return nil\n    }\n\n    private func buildServerFromForm() -> MCPServer {\n        let trimmedName = formName.trimmingCharacters(in: .whitespacesAndNewlines)\n        let args: [String] = formArgsUseJSON\n            ? (Self.parseJSONStringArray(formArgsJSONText) ?? [])\n            : formArgs\n                .split(whereSeparator: { $0.isWhitespace })\n                .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }\n                .filter { !$0.isEmpty }\n        let env: [String: String]? = formEnvUseJSON\n            ? Self.parseJSONStringDict(formEnvJSONText)\n            : Self.parsePairs(formEnvText)\n        let headers: [String: String]? = formHeadersUseJSON\n            ? Self.parseJSONStringDict(formHeadersJSONText)\n            : Self.parsePairs(formHeadersText)\n        return MCPServer(\n            name: trimmedName,\n            kind: formKind,\n            command: formCommand.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : formCommand,\n            args: args.isEmpty ? nil : args,\n            env: env,\n            url: formURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : formURL,\n            headers: headers,\n            meta: nil,\n            enabled: formEnabled,\n            capabilities: servers.first(where: { $0.name == originalName ?? formName })?.capabilities ?? [],\n            targets: MCPServerTargets(\n                codex: formTargetsCodex,\n                claude: formTargetsClaude,\n                gemini: formTargetsGemini\n            )\n        )\n    }\n\n    // JSON preview of the current form as a single server object (without capabilities)\n    func formJSONPreview() -> String {\n        let obj = buildServerFromForm()\n        struct Preview: Encodable {\n            let name: String\n            let kind: MCPServerKind\n            let command: String?\n            let args: [String]?\n            let env: [String: String]?\n            let url: String?\n            let headers: [String: String]?\n            let meta: MCPServerMeta?\n            let enabled: Bool\n        }\n        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)\n        let enc = JSONEncoder()\n        enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]\n        if let data = try? enc.encode(preview), let s = String(data: data, encoding: .utf8) { return s }\n        return \"{}\"\n    }\n\n    func saveForm() async -> Bool {\n        guard formCanSave() else { return false }\n        let item = buildServerFromForm()\n        do {\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: \"Authorize ~/.codmate to save MCP servers\")\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: \"Authorize ~/.codex to update Codex config\")\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: \"Authorize your Home folder to update Claude config\")\n            }\n            if isEditingExisting, let original = originalName, original != item.name {\n                // Rename: remove old record first to avoid duplicate entries\n                try await store.delete(name: original)\n            }\n            try await store.upsert(item)\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n            originalName = item.name\n            isEditingExisting = true\n            return true\n        } catch {\n            errorMessage = \"Failed to save: \\(error.localizedDescription)\"\n            return false\n        }\n    }\n\n    func deleteServer(named name: String) async {\n        do {\n            try await store.delete(name: name)\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n        } catch {\n            errorMessage = \"Failed to delete: \\(error.localizedDescription)\"\n        }\n    }\n\n    func parseImportText() {\n        let trimmed = importText.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else {\n            importError = nil\n            drafts = []\n            isParsing = false\n            return\n        }\n        isParsing = true\n        importError = nil\n        Task.detached {\n            do {\n                let ds = try UniImportMCPNormalizer.parseText(trimmed)\n                await MainActor.run {\n                    self.drafts = ds\n                    self.importError = ds.isEmpty ? \"No servers detected\" : nil\n                    // Autofill the form with the first detected draft in New mode\n                    if !self.isEditingExisting, let first = ds.first {\n                        self.applyDraftToForm(first)\n                    }\n                }\n            } catch {\n                await MainActor.run {\n                    self.drafts = []\n                    self.importError = (error as? LocalizedError)?.errorDescription ?? \"Failed to parse input\"\n                }\n            }\n            await MainActor.run { self.isParsing = false }\n        }\n    }\n\n    private func applyDraftToForm(_ d: MCPServerDraft) {\n        formName = (d.name ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n        formKind = d.kind\n        formURL = d.url ?? \"\"\n        formCommand = d.command ?? \"\"\n        if let arr = d.args, !arr.isEmpty {\n            formArgs = arr.joined(separator: \"\\n\")\n            formArgsJSONText = (try? Self.jsonString(arr)) ?? formArgsJSONText\n        }\n        formEnvText = Self.serializePairs(d.env)\n        formEnvJSONText = (try? Self.jsonString(d.env ?? [:])) ?? formEnvJSONText\n        formHeadersText = Self.serializePairs(d.headers)\n        formHeadersJSONText = (try? Self.jsonString(d.headers ?? [:])) ?? formHeadersJSONText\n        formEnabled = true\n    }\n\n    // MARK: - Quick Test (lightweight)\n    func testCurrentForm() async {\n        testInProgress = true\n        testMessage = nil\n        defer { testInProgress = false }\n        let server = buildServerFromForm()\n        let result = await tester.test(server: server, timeoutSeconds: 6)\n        switch result {\n        case .success(let r):\n            let name = r.serverName?.isEmpty == false ? \" to \\(r.serverName!)\" : \"\"\n            var parts: [String] = []\n            if r.hasTools { parts.append(\"Tools \\(r.tools)\") }\n            if r.hasPrompts { parts.append(\"Prompts \\(r.prompts)\") }\n            if r.hasResources { parts.append(\"Resources \\(r.resources)\") }\n            if r.models > 0 { parts.append(\"Models \\(r.models)\") }\n            testMessage = \"Connected\\(name) — \" + (parts.isEmpty ? \"(no declared capabilities)\" : parts.joined(separator: \", \"))\n        case .failure(let e):\n            let reason = (e as MCPQuickTestError).errorDescription ?? \"failed\"\n            testMessage = \"Unreachable — \\(reason)\"\n        }\n    }\n\n    func startTest() {\n        testTask?.cancel()\n        testTask = Task { await self.testCurrentForm() }\n    }\n\n    func cancelTest() {\n        testTask?.cancel()\n        Task { await tester.cancelActive() }\n        testInProgress = false\n        testMessage = \"Cancelled\"\n    }\n\n    func importDrafts() async {\n        guard !drafts.isEmpty else { return }\n        do {\n            var incoming: [MCPServer] = []\n            for d in drafts {\n                let name = d.name ?? \"imported-server\"\n                let srv = MCPServer(\n                    name: name,\n                    kind: d.kind,\n                    command: d.command,\n                    args: d.args,\n                    env: d.env,\n                    url: d.url,\n                    headers: d.headers,\n                    meta: d.meta,\n                    enabled: true,\n                    capabilities: [],\n                    targets: MCPServerTargets()\n                )\n                incoming.append(srv)\n            }\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: \"Authorize ~/.codmate to save MCP servers\")\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: \"Authorize ~/.codex to update Codex config\")\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: \"Authorize your Home folder to update Claude config\")\n            }\n            try await store.upsertMany(incoming)\n            await loadServers()\n            // Apply enabled servers into Codex config.toml\n            await applyEnabledServersToAllProviders()\n            // Reset import UI\n            drafts = []\n            importText = \"\"\n            importError = nil\n        } catch {\n            errorMessage = \"Failed to save servers: \\(error.localizedDescription)\"\n        }\n    }\n\n    func setServerEnabled(_ server: MCPServer, _ enabled: Bool) async {\n        do {\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n            }\n            if enabled {\n                var updated = server\n                var targets = updated.targets ?? MCPServerTargets()\n                targets.codex = true\n                targets.claude = true\n                targets.gemini = true\n                updated.targets = targets\n                updated.enabled = true\n                try await store.upsert(updated)\n            } else {\n                var updated = server\n                var targets = updated.targets ?? MCPServerTargets()\n                targets.codex = false\n                targets.claude = false\n                targets.gemini = false\n                updated.targets = targets\n                updated.enabled = false\n                try await store.upsert(updated)\n            }\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n        } catch {\n            errorMessage = \"Failed to update: \\(error.localizedDescription)\"\n        }\n    }\n\n    func setCapabilityEnabled(_ server: MCPServer, _ cap: MCPCapability, _ enabled: Bool) async {\n        do {\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n            }\n            try await store.setCapabilityEnabled(name: server.name, capability: cap.name, enabled: enabled)\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n        } catch {\n            errorMessage = \"Failed to update: \\(error.localizedDescription)\"\n        }\n    }\n\n    func isServerEnabled(_ server: MCPServer, for target: MCPServerTarget) -> Bool {\n        server.isEnabled(for: target)\n    }\n\n    func setServerTargetEnabled(_ server: MCPServer, target: MCPServerTarget, enabled: Bool) async {\n        do {\n            if SecurityScopedBookmarks.shared.isSandboxed {\n                let home = SessionPreferencesStore.getRealUserHomeURL()\n                let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess)\n                _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess)\n            }\n            var updated = server.withTargets { targets in\n                targets.setEnabled(enabled, for: target)\n            }\n            // Preserve existing capabilities; auto-enable when user flips a provider on.\n            let hasAnyTarget = (updated.targets?.codex ?? false)\n                || (updated.targets?.claude ?? false)\n                || (updated.targets?.gemini ?? false)\n            if enabled {\n                updated.enabled = true\n            } else if !hasAnyTarget {\n                updated.enabled = false\n            }\n            try await store.upsert(updated)\n            await loadServers()\n            await applyEnabledServersToAllProviders()\n        } catch {\n            errorMessage = \"Failed to update: \\(error.localizedDescription)\"\n        }\n    }\n\n    // Stub for capability discovery via MCP Swift SDK (to be integrated)\n    func refreshCapabilities(for server: MCPServer) async {\n        // TODO: Integrate MCP Swift SDK handshake and tools discovery\n        // For MVP, keep existing capabilities untouched.\n        await loadServers()\n        await applyEnabledServersToAllProviders()\n    }\n\n    private func applyEnabledServersToAllProviders() async {\n        let list = await store.list()\n\n        // 1. Codex\n        let codex = CodexConfigService()\n        try? await codex.applyMCPServers(list)\n\n        // 2. Claude Code (User settings export)\n        try? await store.exportEnabledForClaudeConfig(servers: list)\n\n        // 3. Gemini CLI\n        let gemini = GeminiSettingsService()\n        try? await gemini.applyMCPServers(list)\n    }\n}\n\n// A tiny type eraser to help JSONEncoder with generic values\nprivate struct AnyEncodable: Encodable {\n    private let encodeImpl: (Encoder) throws -> Void\n    init<T: Encodable>(_ value: T) { encodeImpl = value.encode }\n    func encode(to encoder: Encoder) throws { try encodeImpl(encoder) }\n}\n"
  },
  {
    "path": "models/OverviewAggregate.swift",
    "content": "import Foundation\n\nstruct OverviewSourceAggregate: Sendable {\n  let kind: SessionSource.Kind\n  let sessionCount: Int\n  let totalTokens: Int\n  let totalDuration: TimeInterval\n  let userMessages: Int\n  let assistantMessages: Int\n  let toolInvocations: Int\n}\n\nstruct OverviewDailyPoint: Sendable {\n  let day: Date  // start of day in local time\n  let kind: SessionSource.Kind\n  let sessionCount: Int\n  let totalTokens: Int\n  let totalDuration: TimeInterval\n}\n\nstruct OverviewAggregate: Sendable {\n  let totalSessions: Int\n  let totalTokens: Int\n  let totalDuration: TimeInterval\n  let userMessages: Int\n  let assistantMessages: Int\n  let toolInvocations: Int\n  let sources: [OverviewSourceAggregate]\n  let daily: [OverviewDailyPoint]\n  let generatedAt: Date\n}\n\nstruct SessionIndexCoverage: Sendable {\n  let sessionCount: Int\n  let lastFullIndexAt: Date?\n  let sources: [SessionSource.Kind]\n}\n\nstruct OverviewAggregateScope: Sendable {\n  let dateDimension: DateDimension\n  let start: Date\n  let end: Date\n  let projectIds: Set<String>?\n}\n"
  },
  {
    "path": "models/PathTree.swift",
    "content": "import Foundation\n\nstruct PathTreeNode: Identifiable, Hashable {\n    let id: String       // absolute path for uniqueness\n    let name: String\n    var count: Int\n    var children: [PathTreeNode]? // OutlineGroup expects optional children\n}\n\nextension Array where Element == SessionSummary {\n    func buildPathTree() -> PathTreeNode? {\n        guard !isEmpty else { return nil }\n        // Determine common root from all cwd paths\n        let paths: [[String]] = self.map { URL(fileURLWithPath: $0.cwd, isDirectory: true).pathComponents }\n\n        func commonPrefixPathComponents(_ arrays: [[String]]) -> [String] {\n            guard var prefix = arrays.first else { return [] }\n            for comps in arrays.dropFirst() {\n                let n = Swift.min(prefix.count, comps.count)\n                var i = 0\n                while i < n, prefix[i] == comps[i] { i += 1 }\n                prefix = [String](prefix.prefix(i))\n                if prefix.isEmpty { break }\n            }\n            return prefix\n        }\n\n        let commonPrefix = commonPrefixPathComponents(paths)\n\n        let rootPath: String = commonPrefix.isEmpty ? \"/\" : NSString.path(withComponents: commonPrefix)\n        let rootID = rootPath\n        let rootName = commonPrefix.last ?? \"/\"\n        let root = PathTreeNode(id: rootID, name: rootName.isEmpty ? \"/\" : rootName, count: 0, children: [])\n\n        var nodeMap: [String: Int] = [root.id: 0] // id -> index in flat array\n        var flat: [PathTreeNode] = [root]\n\n        func ensureNode(pathComponents: [String]) -> Int {\n            let fullPath = NSString.path(withComponents: pathComponents)\n            if let idx = nodeMap[fullPath] { return idx }\n            let name = pathComponents.last ?? \"/\"\n            let node = PathTreeNode(id: fullPath, name: name, count: 0, children: [])\n            nodeMap[fullPath] = flat.count\n            flat.append(node)\n            return flat.count - 1\n        }\n\n        for s in self {\n            let comps = URL(fileURLWithPath: s.cwd, isDirectory: true).pathComponents\n            let start = commonPrefix.count\n            guard start <= comps.count else { continue }\n            var pathSoFar = [String](commonPrefix)\n            var parentIdx = 0\n            for i in start..<comps.count {\n                pathSoFar.append(comps[i])\n                let idx = ensureNode(pathComponents: pathSoFar)\n                // Link parent->child if not linked yet\n                let childNode = flat[idx] // Copy to local variable to avoid overlapping access\n                if flat[parentIdx].children == nil { flat[parentIdx].children = [] }\n                if !(flat[parentIdx].children?.contains(where: { $0.id == childNode.id }) ?? false) {\n                    flat[parentIdx].children?.append(childNode)\n                }\n                parentIdx = idx\n                // Increase count for each node along the path\n                flat[idx].count += 1\n            }\n            // Increase root count too\n            flat[0].count += 1\n        }\n\n        // Reconstruct tree from flat map preserving children that were appended with stale copies\n        func rebuild(from node: PathTreeNode) -> PathTreeNode {\n            var newNode = flat[nodeMap[node.id]!]\n            let rebuilt = (node.children ?? []).map { rebuild(from: $0) }.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }\n            newNode.children = rebuilt.isEmpty ? nil : rebuilt\n            return newNode\n        }\n\n        return rebuild(from: flat[0])\n    }\n}\n\nextension Dictionary where Key == String, Value == Int {\n    // Build a tree from a map of cwd -> count\n    func buildPathTreeFromCounts() -> PathTreeNode? {\n        guard !isEmpty else { return nil }\n        let allPaths = self.keys.map { URL(fileURLWithPath: $0, isDirectory: true).pathComponents }\n        // common prefix\n        var prefix = allPaths.first ?? []\n        for comps in allPaths.dropFirst() {\n            let n = Swift.min(prefix.count, comps.count)\n            var i = 0\n            while i < n, prefix[i] == comps[i] { i += 1 }\n            prefix = Array(prefix.prefix(i))\n            if prefix.isEmpty { break }\n        }\n        let rootPath = prefix.isEmpty ? \"/\" : NSString.path(withComponents: prefix)\n        let rootName = prefix.last ?? \"/\"\n        let root = PathTreeNode(id: rootPath, name: rootName.isEmpty ? \"/\" : rootName, count: 0, children: [])\n\n        var nodeMap: [String: Int] = [root.id: 0]\n        var flat: [PathTreeNode] = [root]\n\n        func ensureNode(_ comps: [String]) -> Int {\n            let full = NSString.path(withComponents: comps)\n            if let idx = nodeMap[full] { return idx }\n            let name = comps.last ?? \"/\"\n            nodeMap[full] = flat.count\n            flat.append(PathTreeNode(id: full, name: name, count: 0, children: []))\n            return flat.count - 1\n        }\n\n        for (cwd, cnt) in self {\n            var comps = URL(fileURLWithPath: cwd, isDirectory: true).pathComponents\n            if comps.starts(with: prefix) { comps.removeFirst(prefix.count) }\n            var pathSoFar = prefix\n            var parentIdx = 0\n            for part in comps {\n                pathSoFar.append(part)\n                let idx = ensureNode(pathSoFar)\n                // Copy to local variable to avoid overlapping access\n                let childNode = flat[idx]\n                if flat[parentIdx].children == nil { flat[parentIdx].children = [] }\n                if !(flat[parentIdx].children?.contains(where: { $0.id == childNode.id }) ?? false) {\n                    flat[parentIdx].children?.append(childNode)\n                }\n                // accumulate counts up the chain\n                flat[idx].count += cnt\n                parentIdx = idx\n            }\n            flat[0].count += cnt\n        }\n\n        func rebuild(from node: PathTreeNode) -> PathTreeNode {\n            var newNode = flat[nodeMap[node.id]!]\n            let rebuilt = (node.children ?? []).map { rebuild(from: $0) }.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }\n            newNode.children = rebuilt.isEmpty ? nil : rebuilt\n            return newNode\n        }\n\n        return rebuild(from: flat[0])\n    }\n}\n"
  },
  {
    "path": "models/Project.swift",
    "content": "import Foundation\n\nstruct Project: Identifiable, Hashable, Sendable, Codable {\n    var id: String\n    var name: String\n    var directory: String? // Optional: projects are virtual; directory not required\n    var trustLevel: String?\n    var overview: String?\n    var instructions: String?\n    var profileId: String?\n    var profile: ProjectProfile?\n    var parentId: String?\n    var sources: Set<ProjectSessionSource> = Set(ProjectSessionSource.allCases)\n}\n\nstruct ProjectProfile: Codable, Hashable, Sendable {\n    var model: String?\n    var sandbox: SandboxMode?\n    var approval: ApprovalPolicy?\n    var fullAuto: Bool?\n    var dangerouslyBypass: Bool?\n    // Extra runtime enrichments\n    var pathPrepend: [String]?\n    var env: [String:String]?\n}\n\nenum ProjectSessionSource: String, CaseIterable, Codable, Sendable, Identifiable {\n    case codex\n    case claude\n    case gemini\n\n    var id: String { rawValue }\n\n    var displayName: String {\n        switch self {\n        case .codex: return \"Codex\"\n        case .claude: return \"Claude\"\n        case .gemini: return \"Gemini\"\n        }\n    }\n}\n\nextension ProjectSessionSource {\n    static var allSet: Set<ProjectSessionSource> { Set(allCases) }\n\n    var baseKind: SessionSource.Kind {\n        switch self {\n        case .codex: return .codex\n        case .claude: return .claude\n        case .gemini: return .gemini\n        }\n    }\n\n    var sessionSource: SessionSource {\n        switch self {\n        case .codex: return .codexLocal\n        case .claude: return .claudeLocal\n        case .gemini: return .geminiLocal\n        }\n    }\n}\n\nextension SessionSource {\n    var projectSource: ProjectSessionSource {\n        switch self {\n        case .codexLocal, .codexRemote: return .codex\n        case .claudeLocal, .claudeRemote: return .claude\n        case .geminiLocal, .geminiRemote: return .gemini\n        }\n    }\n\n    func friendlyModelName(for raw: String) -> String {\n        switch self {\n        case .codexLocal, .codexRemote, .geminiLocal, .geminiRemote:\n            return raw\n        case .claudeLocal, .claudeRemote:\n            return Self.normalizeClaudeModel(raw)\n        }\n    }\n\n    private static func normalizeClaudeModel(_ raw: String) -> String {\n        var name = raw\n        if name.hasPrefix(\"claude-\") {\n            name.removeFirst(\"claude-\".count)\n        }\n\n        if let dash = name.lastIndex(of: \"-\"), dash != name.startIndex {\n            let suffix = name[name.index(after: dash)...]\n            if suffix.count == 8, suffix.allSatisfy({ $0.isNumber }) {\n                name = String(name[..<dash])\n            }\n        }\n\n        let parts = name.split(separator: \"-\")\n        guard let head = parts.first else { return raw }\n        let tail = parts.dropFirst()\n        if !tail.isEmpty, tail.allSatisfy({ $0.allSatisfy({ $0.isNumber }) }) {\n            let version = tail.joined(separator: \".\")\n            return \"\\(head)-\\(version)\"\n        }\n        return name\n    }\n}\n"
  },
  {
    "path": "models/ProjectExtensionsModels.swift",
    "content": "import Foundation\n\nstruct ProjectMCPConfig: Codable, Hashable, Sendable {\n  var id: String\n  var isSelected: Bool\n  var targets: MCPServerTargets\n}\n\nstruct ProjectSkillConfig: Codable, Hashable, Sendable {\n  var id: String\n  var isSelected: Bool\n  var targets: MCPServerTargets\n}\n\nstruct ProjectExtensionsConfig: Codable, Hashable, Sendable {\n  var projectId: String\n  var mcpServers: [ProjectMCPConfig]\n  var skills: [ProjectSkillConfig]\n  var updatedAt: Date\n}\n"
  },
  {
    "path": "models/ProjectExtensionsViewModel.swift",
    "content": "import Foundation\n\nstruct ProjectMCPSelection: Identifiable, Hashable {\n    var id: String { server.name }\n    var server: MCPServer\n    var isSelected: Bool\n    var targets: MCPServerTargets\n    \n    func hash(into hasher: inout Hasher) {\n        hasher.combine(id)\n    }\n    \n    static func == (lhs: ProjectMCPSelection, rhs: ProjectMCPSelection) -> Bool {\n        lhs.id == rhs.id\n    }\n}\n\n@MainActor\nfinal class ProjectExtensionsViewModel: ObservableObject {\n    private let extensionsStore = ProjectExtensionsStore()\n    private let skillsStore = SkillsStore()\n    private let mcpStore = MCPServersStore()\n    private let applier = ProjectExtensionsApplier()\n    private var skillRecords: [SkillRecord] = []\n    private var projectId: String?\n    private var projectDirectory: URL?\n    private var projectTrustLevel: String?\n\n    @Published var skills: [SkillSummary] = []\n    @Published var mcpSelections: [ProjectMCPSelection] = []\n    @Published var isLoading: Bool = false\n    @Published var errorMessage: String?\n    @Published var showMCPImportSheet: Bool = false\n    @Published var showSkillsImportSheet: Bool = false\n    @Published var mcpImportCandidates: [MCPImportCandidate] = []\n    @Published var skillsImportCandidates: [SkillImportCandidate] = []\n    @Published var isImportingMCP: Bool = false\n    @Published var isImportingSkills: Bool = false\n    @Published var mcpImportStatusMessage: String?\n    @Published var skillsImportStatusMessage: String?\n\n    func load(projectId: String?, projectDirectory: String, trustLevel: String? = nil) async {\n        isLoading = true\n        defer { isLoading = false }\n\n        self.projectId = projectId\n        let dir = projectDirectory.trimmingCharacters(in: .whitespacesAndNewlines)\n        self.projectDirectory = dir.isEmpty ? nil : URL(fileURLWithPath: dir, isDirectory: true)\n        self.projectTrustLevel = normalizeTrustLevel(trustLevel)\n\n        skillRecords = await skillsStore.list()\n        let config: ProjectExtensionsConfig?\n        if let projectId {\n            config = await extensionsStore.load(projectId: projectId)\n        } else {\n            config = nil\n        }\n\n        let skillConfigMap = config?.skills.reduce(into: [String: ProjectSkillConfig]()) { $0[$1.id] = $1 } ?? [:]\n        skills = skillRecords.map { record in\n            let cfg = skillConfigMap[record.id]\n            return SkillSummary(\n                id: record.id,\n                name: record.name,\n                description: record.description,\n                summary: record.summary,\n                tags: record.tags,\n                source: record.source,\n                path: record.path,\n                isSelected: cfg?.isSelected ?? false,\n                targets: cfg?.targets ?? record.targets\n            )\n        }\n\n        let servers = await mcpStore.list()\n        let mcpConfigMap = config?.mcpServers.reduce(into: [String: ProjectMCPConfig]()) { $0[$1.id] = $1 } ?? [:]\n        mcpSelections = servers.map { server in\n            let targets = server.targets ?? MCPServerTargets()\n            let cfg = mcpConfigMap[server.name]\n            return ProjectMCPSelection(\n                server: server,\n                isSelected: cfg?.isSelected ?? false,\n                targets: cfg?.targets ?? targets\n            )\n        }\n    }\n\n    func updateMCPSelection(id: String, isSelected: Bool) {\n        guard let idx = mcpSelections.firstIndex(where: { $0.id == id }) else { return }\n        mcpSelections[idx].isSelected = isSelected\n        if !isSelected {\n            mcpSelections[idx].targets.codex = false\n            mcpSelections[idx].targets.claude = false\n            mcpSelections[idx].targets.gemini = false\n        } else {\n            mcpSelections[idx].targets.codex = true\n            mcpSelections[idx].targets.claude = true\n            mcpSelections[idx].targets.gemini = true\n        }\n        Task { await persistAndApplyIfPossible() }\n    }\n\n    func updateMCPTarget(id: String, target: MCPServerTarget, value: Bool) {\n        guard let idx = mcpSelections.firstIndex(where: { $0.id == id }) else { return }\n        mcpSelections[idx].targets.setEnabled(value, for: target)\n        if value && !mcpSelections[idx].isSelected {\n            mcpSelections[idx].isSelected = true\n        } else if !mcpSelections[idx].targets.codex && !mcpSelections[idx].targets.claude && !mcpSelections[idx].targets.gemini {\n            mcpSelections[idx].isSelected = false\n        }\n        Task { await persistAndApplyIfPossible() }\n    }\n\n    func updateSkillTarget(id: String, target: MCPServerTarget, value: Bool) {\n        guard let idx = skills.firstIndex(where: { $0.id == id }) else { return }\n        var updated = skills[idx]\n        updated.targets.setEnabled(value, for: target)\n        if value && !updated.isSelected {\n            updated.isSelected = true\n        } else if !updated.targets.codex && !updated.targets.claude && !updated.targets.gemini {\n            updated.isSelected = false\n        }\n        skills[idx] = updated\n        Task { await persistAndApplyIfPossible() }\n    }\n\n    func updateSkillSelection(id: String, value: Bool) {\n        guard let idx = skills.firstIndex(where: { $0.id == id }) else { return }\n        skills[idx].isSelected = value\n        if !value {\n            skills[idx].targets.codex = false\n            skills[idx].targets.claude = false\n            skills[idx].targets.gemini = false\n        } else {\n            skills[idx].targets.codex = true\n            skills[idx].targets.claude = true\n            skills[idx].targets.gemini = true\n        }\n        Task { await persistAndApplyIfPossible() }\n    }\n\n    func persistSelections(projectId: String, directory: String?, trustLevel: String?) async {\n        self.projectId = projectId\n        if let dir = directory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty {\n            self.projectDirectory = URL(fileURLWithPath: dir, isDirectory: true)\n        }\n        self.projectTrustLevel = normalizeTrustLevel(trustLevel)\n        await persistAndApplyIfPossible()\n    }\n\n    // MARK: - Project Import\n    func beginProjectMCPImport() {\n        showMCPImportSheet = true\n        Task { await loadProjectMCPCandidates() }\n    }\n\n    func beginProjectSkillsImport() {\n        showSkillsImportSheet = true\n        Task { await loadProjectSkillsCandidates() }\n    }\n\n    func loadProjectMCPCandidates() async {\n        isImportingMCP = true\n        mcpImportStatusMessage = \"Scanning…\"\n        guard let projectDirectory else {\n            mcpImportCandidates = []\n            mcpImportStatusMessage = \"Choose a project directory first.\"\n            isImportingMCP = false\n            return\n        }\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: projectDirectory,\n                purpose: .generalAccess,\n                message: \"Authorize project directory to import MCP servers\"\n            )\n        }\n\n        let existing = await mcpStore.list()\n        let existingNames = Set(existing.map(\\.name))\n        let managedSignatures = Set(existing.map { MCPImportService.signature(for: $0) })\n\n        let scanned = await Task.detached(priority: .userInitiated) {\n            MCPImportService.scan(scope: .project(directory: projectDirectory))\n        }.value\n\n        // CodMate store is the source of truth; provider configs can drift if edited by other tools.\n        let filtered = MCPImportService.filterManagedCandidates(scanned, managedSignatures: managedSignatures)\n        let candidates = filtered.map { item -> MCPImportCandidate in\n            var updated = item\n            if existingNames.contains(item.name) {\n                updated.hasConflict = true\n                updated.isSelected = false\n                updated.resolution = .skip\n                updated.renameName = item.name\n            }\n            return updated\n        }\n\n        if candidates.isEmpty {\n            mcpImportStatusMessage = \"No MCP servers found.\"\n        } else {\n            mcpImportStatusMessage = nil\n        }\n\n        mcpImportCandidates = candidates\n        isImportingMCP = false\n    }\n\n    func loadProjectSkillsCandidates() async {\n        isImportingSkills = true\n        skillsImportStatusMessage = \"Scanning…\"\n        guard let projectDirectory else {\n            skillsImportCandidates = []\n            skillsImportStatusMessage = \"Choose a project directory first.\"\n            isImportingSkills = false\n            return\n        }\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: projectDirectory,\n                purpose: .generalAccess,\n                message: \"Authorize project directory to import skills\"\n            )\n        }\n\n        let scanned = await Task.detached(priority: .userInitiated) {\n            await SkillsImportService.scan(scope: .project(directory: projectDirectory))\n        }.value\n        let existing = await skillsStore.list()\n        let managedIds = Set(existing.map(\\.id))\n        // CodMate store is the source of truth; provider directories can drift if edited by other tools.\n        let filtered = scanned.filter { !managedIds.contains($0.id) }\n\n        var candidates: [SkillImportCandidate] = []\n        for item in filtered {\n            var updated = item\n            if let conflict = await skillsStore.conflictInfo(forProposedId: item.id) {\n                updated.hasConflict = true\n                updated.isSelected = false\n                updated.resolution = .skip\n                updated.renameId = conflict.suggestedId\n                updated.suggestedId = conflict.suggestedId\n                updated.conflictDetail = conflict.existingIsManaged\n                    ? \"Existing CodMate-managed skill\"\n                    : \"Skill already exists\"\n            }\n            candidates.append(updated)\n        }\n\n        skillsImportCandidates = candidates\n        isImportingSkills = false\n        skillsImportStatusMessage = candidates.isEmpty ? \"No skills found.\" : nil\n    }\n\n    func cancelProjectMCPImport() {\n        showMCPImportSheet = false\n        mcpImportCandidates = []\n        mcpImportStatusMessage = nil\n    }\n\n    func cancelProjectSkillsImport() {\n        showSkillsImportSheet = false\n        skillsImportCandidates = []\n        skillsImportStatusMessage = nil\n    }\n\n    func importProjectMCPSelections() async {\n        let selected = mcpImportCandidates.filter { $0.isSelected }\n        guard !selected.isEmpty else {\n            mcpImportStatusMessage = \"No servers selected.\"\n            return\n        }\n\n        let resolvedNames = selected.compactMap { item -> String? in\n            let resolution = item.resolution\n            switch resolution {\n            case .skip:\n                return nil\n            case .overwrite:\n                return item.name\n            case .rename:\n                let trimmed = item.renameName.trimmingCharacters(in: .whitespacesAndNewlines)\n                return trimmed.isEmpty ? nil : trimmed\n            }\n        }\n        let duplicates = Dictionary(grouping: resolvedNames, by: { $0 }).filter { $1.count > 1 }.keys\n        if !duplicates.isEmpty {\n            mcpImportStatusMessage = \"Resolve duplicate names before importing.\"\n            return\n        }\n\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codmate, purpose: .generalAccess, message: \"Authorize ~/.codmate to save MCP servers\")\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: codex, purpose: .generalAccess, message: \"Authorize ~/.codex to update Codex config\")\n            _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(directory: home, purpose: .generalAccess, message: \"Authorize your Home folder to update Claude config\")\n        }\n\n        var incoming: [MCPServer] = []\n        var importedCandidateIds: Set<UUID> = []\n        for item in selected {\n            let resolution = item.resolution\n            switch resolution {\n            case .skip:\n                continue\n            case .overwrite, .rename:\n                let finalName = (resolution == .rename ? item.renameName : item.name)\n                    .trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !finalName.isEmpty else { continue }\n                let meta = MCPServerMeta(description: item.description, version: nil, websiteUrl: nil, repositoryURL: nil)\n                let server = MCPServer(\n                    name: finalName,\n                    kind: item.kind,\n                    command: item.command,\n                    args: item.args,\n                    env: item.env,\n                    url: item.url,\n                    headers: item.headers,\n                    meta: meta,\n                    enabled: true,\n                    capabilities: [],\n                    targets: MCPServerTargets()\n                )\n                incoming.append(server)\n                importedCandidateIds.insert(item.id)\n            }\n        }\n\n        do {\n            try await mcpStore.upsertMany(incoming)\n            await load(projectId: projectId, projectDirectory: projectDirectory?.path ?? \"\")\n            let importedNames = Set(incoming.map(\\.name))\n            for idx in mcpSelections.indices where importedNames.contains(mcpSelections[idx].id) {\n                mcpSelections[idx].isSelected = true\n            }\n            await persistAndApplyIfPossible()\n            mcpImportStatusMessage = \"Imported \\(incoming.count) server(s).\"\n            if !importedCandidateIds.isEmpty {\n                mcpImportCandidates.removeAll { importedCandidateIds.contains($0.id) }\n            }\n            if mcpImportCandidates.isEmpty {\n                closeMCPImportSheetAfterDelay()\n            }\n        } catch {\n            mcpImportStatusMessage = \"Import failed: \\(error.localizedDescription)\"\n        }\n    }\n\n    func importProjectSkillsSelections() async {\n        let selected = skillsImportCandidates.filter { $0.isSelected }\n        guard !selected.isEmpty else {\n            skillsImportStatusMessage = \"No skills selected.\"\n            return\n        }\n\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: codmate,\n                purpose: .generalAccess,\n                message: \"Authorize ~/.codmate to import skills\"\n            )\n        }\n\n        var importedIds: [String] = []\n        var importedCandidateIds: Set<String> = []\n        var importedCandidates: [SkillImportCandidate] = []\n        for item in selected {\n            let resolution = item.hasConflict ? item.resolution : .overwrite\n            switch resolution {\n            case .skip:\n                continue\n            case .overwrite:\n                let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil)\n                let outcome = await skillsStore.install(request: req, resolution: .overwrite)\n                if case .installed(let record) = outcome {\n                    await skillsStore.markImported(id: record.id)\n                    importedIds.append(record.id)\n                    importedCandidateIds.insert(item.id)\n                    importedCandidates.append(item)\n                }\n            case .rename:\n                let newId = item.renameId.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !newId.isEmpty else { continue }\n                let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil)\n                let outcome = await skillsStore.install(request: req, resolution: .rename(newId))\n                if case .installed(let record) = outcome {\n                    await skillsStore.markImported(id: record.id)\n                    importedIds.append(record.id)\n                    importedCandidateIds.insert(item.id)\n                    importedCandidates.append(item)\n                }\n            }\n        }\n\n        if let projectDirectory, !importedCandidates.isEmpty {\n            removeImportedProjectProviderCopies(importedCandidates, projectDirectory: projectDirectory)\n        }\n        await load(projectId: projectId, projectDirectory: projectDirectory?.path ?? \"\")\n        let importedSet = Set(importedIds)\n        for idx in skills.indices where importedSet.contains(skills[idx].id) {\n            skills[idx].isSelected = true\n        }\n        await persistAndApplyIfPossible()\n        skillsImportStatusMessage = \"Imported \\(importedIds.count) skill(s).\"\n        if !importedCandidateIds.isEmpty {\n            skillsImportCandidates.removeAll { importedCandidateIds.contains($0.id) }\n        }\n        if skillsImportCandidates.isEmpty {\n            closeSkillsImportSheetAfterDelay()\n        }\n    }\n\n    private func closeMCPImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n        Task { @MainActor in\n            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n            self.showMCPImportSheet = false\n            self.mcpImportStatusMessage = nil\n        }\n    }\n\n    private func closeSkillsImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n        Task { @MainActor in\n            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n            self.showSkillsImportSheet = false\n            self.skillsImportStatusMessage = nil\n        }\n    }\n\n    private func removeImportedProjectProviderCopies(\n        _ items: [SkillImportCandidate],\n        projectDirectory: URL\n    ) {\n        let providerRoots: [String: URL] = [\n            \"Codex\": projectDirectory.appendingPathComponent(\".codex\", isDirectory: true)\n                .appendingPathComponent(\"skills\", isDirectory: true),\n            \"Claude\": projectDirectory.appendingPathComponent(\".claude\", isDirectory: true)\n                .appendingPathComponent(\"skills\", isDirectory: true),\n            \"Gemini\": projectDirectory.appendingPathComponent(\".gemini\", isDirectory: true)\n                .appendingPathComponent(\"skills\", isDirectory: true)\n        ]\n        let fm = FileManager.default\n        for item in items {\n            if item.sourcePaths.isEmpty {\n                for source in item.sources {\n                    guard let root = providerRoots[source] else { continue }\n                    let dir = URL(fileURLWithPath: item.sourcePath, isDirectory: true)\n                    if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) {\n                        try? fm.removeItem(at: dir)\n                    }\n                }\n                continue\n            }\n            for (source, path) in item.sourcePaths {\n                guard let root = providerRoots[source] else { continue }\n                let dir = URL(fileURLWithPath: path).deletingLastPathComponent()\n                if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) {\n                    try? fm.removeItem(at: dir)\n                }\n            }\n        }\n    }\n\n    private func persistAndApplyIfPossible() async {\n        guard let projectId else { return }\n\n        let config = ProjectExtensionsConfig(\n            projectId: projectId,\n            mcpServers: mcpSelections.map { entry in\n                ProjectMCPConfig(id: entry.id, isSelected: entry.isSelected, targets: entry.targets)\n            },\n            skills: skills.map { skill in\n                ProjectSkillConfig(id: skill.id, isSelected: skill.isSelected, targets: skill.targets)\n            },\n            updatedAt: Date()\n        )\n        await extensionsStore.save(config)\n\n        guard let projectDirectory,\n              FileManager.default.fileExists(atPath: projectDirectory.path)\n        else { return }\n        AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n            directory: projectDirectory,\n            purpose: .generalAccess,\n            message: \"Authorize project directory to update Extensions\"\n        )\n        let selections = skills.map { skill in\n            SkillsSyncService.SkillSelection(id: skill.id, isSelected: skill.isSelected, targets: skill.targets)\n        }\n        await applier.apply(\n            projectDirectory: projectDirectory,\n            mcpSelections: mcpSelections,\n            skillRecords: skillRecords,\n            skillSelections: selections,\n            trustLevel: projectTrustLevel\n        )\n    }\n\n    private func normalizeTrustLevel(_ value: String?) -> String? {\n        let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed?.isEmpty == false ? trimmed : nil\n    }\n}\n"
  },
  {
    "path": "models/ProjectOverviewViewModel.swift",
    "content": "import Combine\nimport Foundation\n\n@MainActor\nfinal class ProjectOverviewViewModel: ObservableObject {\n  @Published private(set) var snapshot: ProjectOverviewSnapshot = .empty\n  @Published private(set) var isLoading: Bool = true\n\n  private let sessionListViewModel: SessionListViewModel\n  private var project: Project\n  private var cancellables: Set<AnyCancellable> = []\n  private var pendingRefreshTask: Task<Void, Never>? = nil\n  private var hasLoadedOnce: Bool = false\n\n  init(sessionListViewModel: SessionListViewModel, project: Project) {\n    self.sessionListViewModel = sessionListViewModel\n    self.project = project\n    bindPublishers()\n    recomputeSnapshot()\n  }\n\n  deinit {\n    pendingRefreshTask?.cancel()\n  }\n\n  func forceRefresh() {\n    pendingRefreshTask?.cancel()\n    pendingRefreshTask = nil\n    recomputeSnapshot()\n  }\n\n  func updateProject(_ newProject: Project) {\n      guard newProject.id == project.id else { return } // Only update if it's the same project\n      project = newProject\n      recomputeSnapshot()\n  }\n\n  private func bindPublishers() {\n    sessionListViewModel.$sections\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$awaitingFollowupIDs\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$usageSnapshots\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$projects\n      .sink { [weak self] _ in self?.scheduleSnapshotRefresh() }\n      .store(in: &cancellables)\n\n    sessionListViewModel.$isLoading\n      .receive(on: DispatchQueue.main)\n      .sink { [weak self] value in\n        guard let self else { return }\n        // Always sync parent loading state, but show loading during initial computation\n        if self.hasLoadedOnce {\n          self.isLoading = value\n        } else {\n          // During initial load, stay loading until first snapshot completes\n          self.isLoading = true\n        }\n      }\n      .store(in: &cancellables)\n  }\n\n  private func scheduleSnapshotRefresh() {\n    pendingRefreshTask?.cancel()\n    pendingRefreshTask = Task { [weak self] in\n      try? await Task.sleep(nanoseconds: 120_000_000)\n      guard !Task.isCancelled else { return }\n      guard let self else { return }\n      \n      // Mark as loaded early so loading state can sync properly after first computation\n      let isFirstLoad = !self.hasLoadedOnce\n      if isFirstLoad {\n        await MainActor.run {\n          self.hasLoadedOnce = true\n          self.isLoading = true\n        }\n      } else {\n        await MainActor.run {\n          self.isLoading = self.sessionListViewModel.isLoading\n        }\n      }\n      \n      // Capture data on MainActor\n      // Filter sessions on MainActor because projectId(for:) accesses MainActor state\n      let filteredSessions = self.sessionListViewModel.sections.flatMap { $0.sessions }\n      var allowedProjects = Set([self.project.id])\n      let descendants = self.sessionListViewModel.collectDescendants(\n        of: self.project.id,\n        in: self.sessionListViewModel.projects\n      )\n      allowedProjects.formUnion(descendants)\n      var projectSessions: [SessionSummary] = filteredSessions.filter {\n        guard let pid = self.sessionListViewModel.projectId(for: $0) else { return false }\n        return allowedProjects.contains(pid)\n      }\n\n      // If the filtered view is empty but counts indicate data, fall back to a local filter pass.\n      if projectSessions.isEmpty,\n         !self.sessionListViewModel.isLoading {\n        let trimmedSearch = self.sessionListViewModel.searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n        let trimmedQuick = self.sessionListViewModel.quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines)\n        let hasScopeFilters = self.sessionListViewModel.selectedPath != nil || !trimmedSearch.isEmpty || !trimmedQuick.isEmpty\n        let visibleCount = self.sessionListViewModel.projectCountsDisplay()[self.project.id]?.visible ?? 0\n        if !hasScopeFilters, visibleCount > 0 {\n          let allowedSourcesByProject = self.sessionListViewModel.projects.reduce(\n            into: [String: Set<ProjectSessionSource>]()\n          ) { $0[$1.id] = $1.sources }\n          let descriptors = SessionListViewModel.makeDayDescriptors(\n            selectedDays: self.sessionListViewModel.selectedDays,\n            singleDay: self.sessionListViewModel.selectedDay\n          )\n          let filterByDay = !descriptors.isEmpty\n          let fallback = self.sessionListViewModel.allSessions.filter { session in\n            guard let pid = self.sessionListViewModel.projectId(for: session),\n                  allowedProjects.contains(pid)\n            else { return false }\n            let allowedSources = allowedSourcesByProject[pid] ?? ProjectSessionSource.allSet\n            guard allowedSources.contains(session.source.projectSource) else { return false }\n            if filterByDay {\n              return self.sessionListViewModel.matchesDayFilters(session, descriptors: descriptors)\n            }\n            return true\n          }\n          if !fallback.isEmpty {\n            projectSessions = fallback\n          }\n        }\n      }\n\n      let usageSnapshots = self.sessionListViewModel.usageSnapshots\n      \n      // Run computation in background\n      let newSnapshot = await Self.computeSnapshot(\n        projectSessions: projectSessions,\n        usageSnapshots: usageSnapshots\n      )\n      \n      guard !Task.isCancelled else { return }\n      await MainActor.run {\n        self.snapshot = newSnapshot\n        self.isLoading = self.sessionListViewModel.isLoading\n      }\n    }\n  }\n\n  private static func computeSnapshot(\n    projectSessions: [SessionSummary],\n    usageSnapshots: [UsageProviderKind: UsageProviderSnapshot]\n  ) async -> ProjectOverviewSnapshot {\n    let now = Date()\n    \n    func anchorDate(for session: SessionSummary) -> Date {\n      session.lastUpdatedAt ?? session.startedAt\n    }\n\n    let totalDuration = projectSessions.reduce(0) { $0 + $1.duration }\n    let totalTokens = projectSessions.reduce(0) { $0 + $1.actualTotalTokens }\n    let userMessages = projectSessions.reduce(0) { $0 + $1.userMessageCount }\n    let assistantMessages = projectSessions.reduce(0) { $0 + $1.assistantMessageCount }\n    let totalToolInvocations = projectSessions.reduce(0) { $0 + $1.toolInvocationCount }\n\n    let recentTop = Array(\n      projectSessions\n        .sorted { anchorDate(for: $0) > anchorDate(for: $1) }\n        .prefix(5)\n    )\n\n    let sourceStats = buildSourceStats(from: projectSessions)\n    let activityData = projectSessions.generateChartData()\n\n    return ProjectOverviewSnapshot(\n      totalSessions: projectSessions.count,\n      totalDuration: totalDuration,\n      totalTokens: totalTokens,\n      userMessages: userMessages,\n      assistantMessages: assistantMessages,\n      totalToolInvocations: totalToolInvocations,\n      recentSessions: recentTop,\n      sourceStats: sourceStats,\n      activityChartData: activityData,\n      usageSnapshots: usageSnapshots,\n      lastUpdated: now\n    )\n  }\n  \n  private static func buildSourceStats(from sessions: [SessionSummary]) -> [ProjectOverviewSnapshot.SourceStat] {\n    var groups: [SessionSource.Kind: [SessionSummary]] = [:]\n    for session in sessions {\n      groups[session.source.baseKind, default: []].append(session)\n    }\n    \n    let kinds: [SessionSource.Kind] = [.codex, .claude, .gemini]\n    \n    var stats: [ProjectOverviewSnapshot.SourceStat] = kinds.compactMap { kind in\n      let group = groups[kind] ?? []\n      let count = group.count\n      guard count > 0 else { return nil }\n      \n      let totalDuration = group.reduce(0) { $0 + $1.duration }\n      let totalTokens = group.reduce(0) { $0 + $1.actualTotalTokens }\n      \n      return ProjectOverviewSnapshot.SourceStat(\n        kind: kind,\n        sessionCount: count,\n        totalTokens: totalTokens,\n        avgTokens: 0, // Not used for display anymore\n        avgDuration: count > 0 ? totalDuration / Double(count) : 0,\n        isAll: false\n      )\n    }\n    \n    // Add \"All\" summary if there's data\n    if !sessions.isEmpty {\n      let totalDuration = sessions.reduce(0) { $0 + $1.duration }\n      let totalTokens = sessions.reduce(0) { $0 + $1.actualTotalTokens }\n      let count = sessions.count\n      \n      let allStat = ProjectOverviewSnapshot.SourceStat(\n        kind: .codex, // Placeholder kind, ignored when isAll is true\n        sessionCount: count,\n        totalTokens: totalTokens,\n        avgTokens: 0,\n        avgDuration: count > 0 ? totalDuration / Double(count) : 0,\n        isAll: true\n      )\n      stats.insert(allStat, at: 0)\n    }\n    \n    return stats\n  }\n\n  private func recomputeSnapshot() {\n    scheduleSnapshotRefresh()\n  }\n\n  func resolveProject(for session: SessionSummary) -> (id: String, name: String)? {\n    // For ProjectOverview, it should always be THIS project\n    return (id: project.id, name: project.name)\n  }\n}\n\nstruct ProjectOverviewSnapshot: Equatable {\n  // SourceStat needs to be defined within ProjectOverviewSnapshot now\n  struct SourceStat: Identifiable, Equatable {\n    let kind: SessionSource.Kind\n    let sessionCount: Int\n    let totalTokens: Int\n    let avgTokens: Double\n    let avgDuration: TimeInterval\n    var isAll: Bool = false\n    \n    var id: String { isAll ? \"all\" : kind.rawValue }\n    \n    var displayName: String {\n      if isAll { return \"All\" }\n      switch kind {\n      case .codex: return \"Codex\"\n      case .claude: return \"Claude\"\n      case .gemini: return \"Gemini\"\n      }\n    }\n  }\n\n  var totalSessions: Int\n  var totalDuration: TimeInterval\n  var totalTokens: Int\n  var userMessages: Int\n  var assistantMessages: Int\n  var totalToolInvocations: Int // New field\n  var recentSessions: [SessionSummary]\n  var sourceStats: [SourceStat]\n  var activityChartData: ActivityChartData\n  var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot]\n  var lastUpdated: Date\n\n  static let empty = ProjectOverviewSnapshot(\n    totalSessions: 0,\n    totalDuration: 0,\n    totalTokens: 0,\n    userMessages: 0,\n    assistantMessages: 0,\n    totalToolInvocations: 0,\n    recentSessions: [],\n    sourceStats: [],\n    activityChartData: .empty,\n    usageSnapshots: [:],\n    lastUpdated: .distantPast\n  )\n}\n"
  },
  {
    "path": "models/ProjectWorkspaceMode.swift",
    "content": "import Foundation\n\nenum ProjectWorkspaceMode: String, Codable, Hashable, CaseIterable {\n    case overview\n    case tasks\n    case sessions  // For \"Other\" - manage unassigned sessions\n    case review\n    case agents\n    case memory\n    case settings\n}\n"
  },
  {
    "path": "models/ProjectWorkspaceViewModel+Generation.swift",
    "content": "import Foundation\nimport SwiftUI\n\nextension ProjectWorkspaceViewModel {\n    /// Generates title and description for a task based on its sessions' metadata\n    /// Uses strategy B: only reads session titles and comments (fast, lightweight)\n    /// - Parameters:\n    ///   - task: The task to generate for\n    ///   - currentTitle: The current title being edited (may differ from task.title)\n    ///   - currentDescription: The current description being edited (may differ from task.description)\n    ///   - force: If true, skip confirmation dialog\n    func generateTitleAndDescription(for task: CodMateTask, currentTitle: String? = nil, currentDescription: String? = nil, force: Bool = false) async {\n        // Check if task already has title or description\n        let hasTitle = !task.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        let hasDescription = task.description?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false\n\n        if !force && (hasTitle || hasDescription) {\n            // Show confirmation dialog\n            let shouldProceed = await confirmOverwrite(taskTitle: task.effectiveTitle)\n            guard shouldProceed else { return }\n        }\n\n        let statusToken = StatusBarLogStore.shared.beginTask(\n            \"Generating task title & description...\",\n            level: .info,\n            source: \"Tasks\"\n        )\n        var finalStatus: (message: String, level: StatusBarLogLevel)?\n        defer {\n            if let finalStatus {\n                StatusBarLogStore.shared.endTask(\n                    statusToken,\n                    message: finalStatus.message,\n                    level: finalStatus.level,\n                    source: \"Tasks\"\n                )\n            } else {\n                StatusBarLogStore.shared.endTask(statusToken)\n            }\n        }\n\n        // Set loading state\n        isGeneratingTitleDescription = true\n        generatingTaskId = task.id\n        defer {\n            isGeneratingTitleDescription = false\n            generatingTaskId = nil\n        }\n\n        // Get sessions for this task\n        let sessions = getSessionsForTask(task.id)\n\n        // Special case: no sessions exist\n        if sessions.isEmpty {\n            // Use current editing values if provided, otherwise use task values\n            let titleToUse = currentTitle ?? task.title\n            let descToUse = currentDescription ?? task.description ?? \"\"\n\n            // If both title and description are empty, nothing to generate from\n            let hasTitleContent = !titleToUse.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n            let hasDescContent = !descToUse.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n\n            guard hasTitleContent || hasDescContent else {\n                finalStatus = (\"No task content to generate from\", .warning)\n                return\n            }\n\n            // Generate based on available content (title and/or description)\n            let ok = await generateFromContent(title: titleToUse, description: descToUse)\n            finalStatus = ok ? (\"Task title ready\", .success) : (\"Task generation failed\", .error)\n            return\n        }\n\n        // Build material from session metadata (title + comment only)\n        let material = buildSessionMetadataMaterial(sessions: sessions)\n\n        // Load prompt template\n        guard let promptTemplate = loadPromptTemplate(named: \"task-title-and-description\") else {\n            finalStatus = (\"Missing task prompt template\", .error)\n            return\n        }\n\n        // Build full prompt\n        let fullPrompt = promptTemplate + material\n\n        // Call LLM\n        guard let response = await callLLM(prompt: fullPrompt) else {\n            finalStatus = (\"Task generation failed (no response)\", .error)\n            return\n        }\n\n        // Parse response\n        guard let parsed = Self.parseTitleDescriptionResponse(response) else {\n            finalStatus = (\"Failed to parse task response\", .error)\n            return\n        }\n\n        // Update generated content state - EditTaskSheet will pick these up\n        generatedTaskTitle = parsed.title\n        generatedTaskDescription = parsed.description.isEmpty ? nil : parsed.description\n        finalStatus = (\"Task title & description ready\", .success)\n    }\n\n    // MARK: - Private Helpers\n\n    /// Generate title and description based on existing content (when no sessions exist)\n    private func generateFromContent(title: String, description: String) async -> Bool {\n        // Load prompt template for content-based generation\n        guard let promptTemplate = loadPromptTemplate(named: \"task-title-only\") else {\n            return false\n        }\n\n        // Build prompt with current title and/or description\n        var contentLines: [String] = []\n\n        let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)\n        let trimmedDesc = description.trimmingCharacters(in: .whitespacesAndNewlines)\n\n        if !trimmedTitle.isEmpty {\n            contentLines.append(\"Current title: \\(trimmedTitle)\")\n        }\n        if !trimmedDesc.isEmpty {\n            contentLines.append(\"Current description: \\(trimmedDesc)\")\n        }\n\n        let fullPrompt = promptTemplate + \"\\n\\n\" + contentLines.joined(separator: \"\\n\")\n\n        // Call LLM\n        guard let response = await callLLM(prompt: fullPrompt) else { return false }\n\n        // Parse response\n        guard let parsed = Self.parseTitleDescriptionResponse(response) else { return false }\n\n        // Update generated content state\n        generatedTaskTitle = parsed.title\n        generatedTaskDescription = parsed.description.isEmpty ? nil : parsed.description\n        return true\n    }\n\n    private func buildSessionMetadataMaterial(sessions: [SessionSummary]) -> String {\n        var lines: [String] = []\n\n        for (index, session) in sessions.enumerated() {\n            let title = session.effectiveTitle\n            let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n\n            lines.append(\"Session \\(index + 1): \\\"\\(title)\\\"\")\n            if !comment.isEmpty {\n                // Limit comment to 200 characters to keep material compact\n                let snippet = comment.count > 200 ? String(comment.prefix(200)) + \"…\" : comment\n                lines.append(\"  - \\(snippet)\")\n            }\n            lines.append(\"\")\n        }\n\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func loadPromptTemplate(named name: String) -> String? {\n        guard let url = Bundle.main.url(forResource: name, withExtension: \"md\", subdirectory: \"payload/prompts\") else {\n            return nil\n        }\n        return try? String(contentsOf: url, encoding: .utf8)\n    }\n\n    private func callLLM(prompt: String) async -> String? {\n        let llm = LLMHTTPService()\n        var options = LLMHTTPService.Options()\n        options.preferred = .auto\n        options.timeout = 45\n        options.maxTokens = 500\n        options.systemPrompt = \"Return only the JSON object. No labels, explanations, or extra commentary.\"\n\n        // Use the same provider/model configuration as session generation\n        if let providerId = UserDefaults.standard.string(forKey: \"git.review.commitProviderId\"), !providerId.isEmpty {\n            options.providerId = providerId\n        }\n        if let modelId = UserDefaults.standard.string(forKey: \"git.review.commitModelId\"), !modelId.isEmpty {\n            options.model = modelId\n        }\n\n        do {\n            let res = try await llm.generateText(prompt: prompt, options: options)\n            return res.text\n        } catch {\n            return nil\n        }\n    }\n\n    private static func parseTitleDescriptionResponse(_ raw: String) -> (title: String, description: String)? {\n        // Remove code fences if present\n        var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n        if cleaned.hasPrefix(\"```json\") {\n            cleaned = cleaned.dropFirst(7).trimmingCharacters(in: .whitespacesAndNewlines)\n        } else if cleaned.hasPrefix(\"```\") {\n            cleaned = cleaned.dropFirst(3).trimmingCharacters(in: .whitespacesAndNewlines)\n        }\n        if cleaned.hasSuffix(\"```\") {\n            cleaned = String(cleaned.dropLast(3)).trimmingCharacters(in: .whitespacesAndNewlines)\n        }\n\n        // Parse JSON\n        guard let data = cleaned.data(using: .utf8) else { return nil }\n        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n            return nil\n        }\n\n        guard let title = json[\"title\"] as? String,\n              let description = json[\"description\"] as? String else {\n            return nil\n        }\n\n        return (\n            title: title.trimmingCharacters(in: .whitespacesAndNewlines),\n            description: description.trimmingCharacters(in: .whitespacesAndNewlines)\n        )\n    }\n\n    @MainActor\n    private func confirmOverwrite(taskTitle: String) async -> Bool {\n        return await withCheckedContinuation { continuation in\n            DispatchQueue.main.async {\n                let alert = NSAlert()\n                alert.messageText = \"Overwrite Existing Content?\"\n                alert.informativeText = \"This task already has a title or description. Do you want to generate new ones?\"\n                alert.addButton(withTitle: \"Generate\")\n                alert.addButton(withTitle: \"Cancel\")\n                alert.alertStyle = .warning\n\n                let response = alert.runModal()\n                continuation.resume(returning: response == .alertFirstButtonReturn)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "models/ProjectWorkspaceViewModel.swift",
    "content": "import Foundation\nimport SwiftUI\n\n@MainActor\nclass ProjectWorkspaceViewModel: ObservableObject {\n    @Published var selectedMode: ProjectWorkspaceMode = .tasks\n    @Published var tasks: [CodMateTask] = []\n\n    // Task title/description generation state\n    @Published var isGeneratingTitleDescription: Bool = false\n    @Published var generatingTaskId: UUID? = nil\n\n    // Temporary edit state for generated content\n    @Published var generatedTaskTitle: String? = nil\n    @Published var generatedTaskDescription: String? = nil\n\n    private let tasksStore: TasksStore\n    private let sessionListViewModel: SessionListViewModel\n    private let contextTreeshaker = ContextTreeshaker()\n\n    init(tasksStore: TasksStore = TasksStore(), sessionListViewModel: SessionListViewModel) {\n        self.tasksStore = tasksStore\n        self.sessionListViewModel = sessionListViewModel\n    }\n\n    // MARK: - Task Management\n\n    func loadTasks(for projectId: String) async {\n        let loaded = await tasksStore.listTasks(for: projectId)\n        await MainActor.run {\n            self.tasks = loaded\n        }\n    }\n\n    func createTask(title: String, description: String?, projectId: String) async {\n        let task = CodMateTask(\n            title: title,\n            description: description,\n            projectId: projectId\n        )\n        await tasksStore.upsertTask(task)\n        await loadTasks(for: projectId)\n    }\n\n    func updateTask(_ task: CodMateTask) async {\n        // Enforce 0/1 membership: a session can belong to at most one task\n        var normalized = task\n        // Deduplicate session IDs within this task\n        let uniqueIds = Array(Set(normalized.sessionIds))\n        normalized.sessionIds = uniqueIds\n\n        let projectId = normalized.projectId\n        let idsSet = Set(uniqueIds)\n\n        // Remove these sessions from all other tasks in the same project\n        for var other in tasks where other.id != normalized.id && other.projectId == projectId {\n            let filtered = other.sessionIds.filter { !idsSet.contains($0) }\n            if filtered != other.sessionIds {\n                other.sessionIds = filtered\n                await tasksStore.upsertTask(other)\n            }\n        }\n\n        await tasksStore.upsertTask(normalized)\n        await loadTasks(for: projectId)\n    }\n\n    func deleteTask(_ taskId: UUID, projectId: String) async {\n        await tasksStore.deleteTask(id: taskId)\n        await loadTasks(for: projectId)\n    }\n\n    func assignSessionsToTask(_ sessionIds: [String], taskId: UUID?) async {\n        await tasksStore.assignSessions(sessionIds, to: taskId)\n        // Reload tasks to reflect the changes\n        if let task = tasks.first(where: { $0.id == taskId }) {\n            await loadTasks(for: task.projectId)\n        }\n    }\n\n    func addContextToTask(_ item: ContextItem, taskId: UUID) async {\n        await tasksStore.addContextItem(item, to: taskId)\n        if let task = tasks.first(where: { $0.id == taskId }) {\n            await loadTasks(for: task.projectId)\n        }\n    }\n\n    func removeContextFromTask(_ contextId: UUID, taskId: UUID) async {\n        await tasksStore.removeContextItem(id: contextId, from: taskId)\n        if let task = tasks.first(where: { $0.id == taskId }) {\n            await loadTasks(for: task.projectId)\n        }\n    }\n\n    // MARK: - Shared Task Context\n\n    /// Regenerates the shared context file for the given task.\n    /// The file is written to ~/.codmate/tasks/context-<taskId>.md and contains a\n    /// compact markdown snapshot of the most recent sessions under this task.\n    func syncTaskContext(taskId: UUID, maxSessions: Int = 5) async -> URL? {\n        // Prefer in-memory snapshot; fall back to store when needed\n        let task: CodMateTask\n        if let cached = tasks.first(where: { $0.id == taskId }) {\n            task = cached\n        } else if let loaded = await tasksStore.getTask(id: taskId) {\n            task = loaded\n        } else {\n            return nil\n        }\n\n        // Resolve sessions for this task from the global list\n        let allSessions = sessionListViewModel.allSessions\n        let sessionsForTask = allSessions.filter { task.sessionIds.contains($0.id) }\n        let sortedSessions = sessionsForTask.sorted { lhs, rhs in\n            let lDate = lhs.lastUpdatedAt ?? lhs.startedAt\n            let rDate = rhs.lastUpdatedAt ?? rhs.startedAt\n            return lDate < rDate\n        }\n        let limited = Array(sortedSessions.suffix(maxSessions))\n\n        // Build slim markdown using the same engine as the legacy New With Context flow…\n        var options = TreeshakeOptions()\n        let kinds = sessionListViewModel.preferences.markdownVisibleKinds\n        options.visibleKinds = kinds\n        options.includeReasoning = kinds.contains(.reasoning)\n        options.includeToolSummary = kinds.contains(.infoOther)\n\n        let body: String\n        if limited.isEmpty {\n            body = \"_No sessions available for this task yet._\"\n        } else {\n            body = await contextTreeshaker.generateMarkdown(for: limited, options: options)\n        }\n\n        var headerLines: [String] = [\n            \"# Task: \\(task.effectiveTitle)\",\n            \"\",\n            \"- Updated: \\(Date().formatted(date: .abbreviated, time: .shortened))\",\n            \"- Project: \\(task.projectId)\",\n            \"- Status: \\(task.status.displayName)\"\n        ]\n\n        if let desc = task.effectiveDescription {\n            headerLines.append(\"- Description: \\(desc)\")\n        }\n\n        let sessionList = task.sessionIds.joined(separator: \", \")\n        if !sessionList.isEmpty {\n            headerLines.append(\"- Sessions: \\(sessionList)\")\n        }\n\n        headerLines.append(\"\")\n\n        if !sortedSessions.isEmpty {\n            headerLines.append(\"## Sessions in this Task\")\n            headerLines.append(\"\")\n\n            for session in sortedSessions {\n                headerLines.append(\"- \\(session.effectiveTitle)\")\n\n                if let rawComment = session.userComment?\n                    .trimmingCharacters(in: .whitespacesAndNewlines),\n                    !rawComment.isEmpty\n                {\n                    let snippet =\n                        rawComment.count > 200\n                        ? String(rawComment.prefix(200)) + \"…\"\n                        : rawComment\n                    headerLines.append(\"  - Note: \\(snippet)\")\n                } else if let rawInstructions = session.instructions?\n                    .trimmingCharacters(in: .whitespacesAndNewlines),\n                    !rawInstructions.isEmpty\n                {\n                    let snippet =\n                        rawInstructions.count > 200\n                        ? String(rawInstructions.prefix(200)) + \"…\"\n                        : rawInstructions\n                    headerLines.append(\"  - Instructions: \\(snippet)\")\n                }\n            }\n\n            headerLines.append(\"\")\n        }\n\n        headerLines.append(\"## Shared Context\")\n        headerLines.append(\"\")\n\n        let content = (headerLines + [body]).joined(separator: \"\\n\")\n\n        let fm = FileManager.default\n        let paths = TasksStore.Paths.default(fileManager: fm)\n        let root = paths.root\n        do {\n            try fm.createDirectory(at: root, withIntermediateDirectories: true)\n            let url = root.appendingPathComponent(\"context-\\(taskId.uuidString).md\", isDirectory: false)\n            try content.write(to: url, atomically: true, encoding: .utf8)\n            return url\n        } catch {\n            return nil\n        }\n    }\n\n    // MARK: - Task With Sessions\n\n    func enrichTasksWithSessions() -> [TaskWithSessions] {\n        let allSessions = sessionListViewModel.allSessions\n        return tasks.map { task in\n            let sessions = allSessions.filter { task.sessionIds.contains($0.id) }\n            // Keep session ordering consistent with the main list\n            // by reusing the current sort order.\n            let sorted = sessionListViewModel.sortOrder.sort(\n                sessions,\n                visibleKinds: sessionListViewModel.preferences.timelineVisibleKinds\n            )\n            return TaskWithSessions(task: task, sessions: sorted)\n        }\n    }\n\n    func getSessionsForTask(_ taskId: UUID) -> [SessionSummary] {\n        guard let task = tasks.first(where: { $0.id == taskId }) else { return [] }\n        let allSessions = sessionListViewModel.allSessions\n        return allSessions.filter { task.sessionIds.contains($0.id) }\n    }\n\n    // MARK: - Overview Statistics\n\n    func getProjectStatistics(for projectId: String) -> ProjectStatistics {\n        let projectSessions = sessionListViewModel.allSessions.filter { session in\n            sessionListViewModel.projectIdForSession(session.id) == projectId\n        }\n\n        let totalDuration = projectSessions.reduce(0) { $0 + $1.duration }\n        let totalTokens = projectSessions.reduce(0) { $0 + $1.actualTotalTokens }\n        let totalEvents = projectSessions.reduce(0) { $0 + $1.eventCount }\n\n        let projectTasks = tasks.filter { $0.projectId == projectId }\n        let completedTasks = projectTasks.filter { $0.status == .completed }.count\n        let inProgressTasks = projectTasks.filter { $0.status == .inProgress }.count\n        let pendingTasks = projectTasks.filter { $0.status == .pending }.count\n\n        return ProjectStatistics(\n            totalSessions: projectSessions.count,\n            totalTasks: projectTasks.count,\n            completedTasks: completedTasks,\n            inProgressTasks: inProgressTasks,\n            pendingTasks: pendingTasks,\n            totalDuration: totalDuration,\n            totalTokens: totalTokens,\n            totalEvents: totalEvents\n        )\n    }\n}\n\nstruct ProjectStatistics {\n    let totalSessions: Int\n    let totalTasks: Int\n    let completedTasks: Int\n    let inProgressTasks: Int\n    let pendingTasks: Int\n    let totalDuration: TimeInterval\n    let totalTokens: Int\n    let totalEvents: Int\n\n    var taskCompletionRate: Double {\n        guard totalTasks > 0 else { return 0 }\n        return Double(completedTasks) / Double(totalTasks)\n    }\n\n    var averageSessionDuration: TimeInterval {\n        guard totalSessions > 0 else { return 0 }\n        return totalDuration / Double(totalSessions)\n    }\n\n    var averageTokensPerSession: Double {\n        guard totalSessions > 0 else { return 0 }\n        return Double(totalTokens) / Double(totalSessions)\n    }\n}\n"
  },
  {
    "path": "models/RefreshRequest.swift",
    "content": "import Foundation\n\nenum RefreshRequestKind: String {\n  case context\n  case global\n}\n\nenum RefreshRequest {\n  static let userInfoKey = \"refreshKind\"\n\n  static func userInfo(for kind: RefreshRequestKind) -> [AnyHashable: Any] {\n    [userInfoKey: kind.rawValue]\n  }\n\n  static func kind(from userInfo: [AnyHashable: Any]?) -> RefreshRequestKind {\n    guard\n      let userInfo,\n      let raw = userInfo[userInfoKey] as? String,\n      let kind = RefreshRequestKind(rawValue: raw)\n    else {\n      return .context\n    }\n    return kind\n  }\n}\n"
  },
  {
    "path": "models/ReviewPanelState.swift",
    "content": "import Foundation\n\n// Lightweight, per-session UI state for the Review (Git Changes) panel.\n// Keeps tree expansion/selection, view mode, and in-progress commit message.\nstruct ReviewPanelState: Equatable {\n    enum Mode: Equatable {\n        case diff\n        case browser\n        case graph\n    }\n\n    // Legacy combined set (pre-branching); still read for backward restore.\n    var expandedDirs: Set<String> = []\n    // New: independent expansion for staged/unstaged trees\n    var expandedDirsStaged: Set<String> = []\n    var expandedDirsUnstaged: Set<String> = []\n    var selectedPath: String? = nil\n    // true = staged side; false = unstaged; nil = default (unstaged)\n    var selectedSideStaged: Bool? = nil\n    var showPreview: Bool = false\n    var commitMessage: String = \"\"\n    var mode: Mode = .diff\n    var expandedDirsBrowser: Set<String> = []\n    // Whether the Git Graph view is visible in the right detail area.\n    // This is a lightweight UI flag; it is safe if not restored.\n    // Kept here to allow persistence across app runs if desired.\n    var showGraph: Bool = false\n}\n\nextension ReviewPanelState.Mode {\n    mutating func toggle() {\n        self = (self == .diff) ? .browser : .diff\n    }\n}\n"
  },
  {
    "path": "models/SessionEvent.swift",
    "content": "import Foundation\n\nstruct SessionRow: Decodable {\n    let timestamp: Date\n    let kind: Kind\n\n    enum CodingKeys: String, CodingKey {\n        case timestamp\n        case type\n        case payload\n        case message\n    }\n\n    init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        timestamp = try container.decode(Date.self, forKey: .timestamp)\n        let type = try container.decode(String.self, forKey: .type)\n\n        switch type {\n        case \"session_meta\":\n            let payload = try container.decode(SessionMetaPayload.self, forKey: .payload)\n            kind = .sessionMeta(payload)\n        case \"turn_context\":\n            let payload = try container.decode(TurnContextPayload.self, forKey: .payload)\n            kind = .turnContext(payload)\n        case \"event_msg\":\n            let payload = try container.decode(EventMessagePayload.self, forKey: .payload)\n            kind = .eventMessage(payload)\n        case \"response_item\":\n            let payload = try container.decode(ResponseItemPayload.self, forKey: .payload)\n            kind = .responseItem(payload)\n        case \"assistant\":\n            // assistant messages use \"message\" field instead of \"payload\"\n            let message = try container.decode(AssistantMessage.self, forKey: .message)\n            kind = .assistantMessage(AssistantMessagePayload(message: message))\n        default:\n            let payload = try container.decode(JSONValue.self, forKey: .payload)\n            kind = .unknown(type: type, payload: payload)\n        }\n    }\n\n    enum Kind {\n        case sessionMeta(SessionMetaPayload)\n        case turnContext(TurnContextPayload)\n        case eventMessage(EventMessagePayload)\n        case responseItem(ResponseItemPayload)\n        case assistantMessage(AssistantMessagePayload)\n        case unknown(type: String, payload: JSONValue)\n    }\n}\n\nstruct SessionMetaPayload: Decodable {\n    let id: String\n    let timestamp: Date\n    let cwd: String\n    let originator: String\n    let cliVersion: String\n    let instructions: String?\n\n    enum CodingKeys: String, CodingKey {\n        case id\n        case timestamp\n        case cwd\n        case originator\n        case cliVersion = \"cli_version\"\n        case instructions\n    }\n}\n\nstruct TurnContextPayload: Decodable {\n    let cwd: String?\n    let approvalPolicy: String?\n    let model: String?\n    let effort: String?\n    let summary: String?\n\n    enum CodingKeys: String, CodingKey {\n        case cwd\n        case approvalPolicy = \"approval_policy\"\n        case model\n        case effort\n        case summary\n    }\n}\n\nstruct EventMessagePayload: Decodable {\n    let type: String\n    let message: String?\n    let kind: String?\n    let text: String?\n    let reason: String?\n    let info: JSONValue?\n    let rateLimits: JSONValue?\n    let images: [String]?\n\n    enum CodingKeys: String, CodingKey {\n        case type\n        case message\n        case kind\n        case text\n        case reason\n        case info\n        case rateLimits = \"rate_limits\"\n        case images\n    }\n\n    init(\n        type: String,\n        message: String?,\n        kind: String?,\n        text: String?,\n        reason: String?,\n        info: JSONValue?,\n        rateLimits: JSONValue?,\n        images: [String]? = nil\n    ) {\n        self.type = type\n        self.message = message\n        self.kind = kind\n        self.text = text\n        self.reason = reason\n        self.info = info\n        self.rateLimits = rateLimits\n        self.images = images\n    }\n}\n\nstruct ResponseItemPayload: Decodable {\n    let type: String\n    let status: String?\n    let callID: String?\n    let name: String?\n    let content: [ResponseContentBlock]?\n    let summary: [ResponseSummaryItem]?\n    let encryptedContent: String?\n    let role: String?\n    let arguments: JSONValue?\n    let input: JSONValue?\n    let output: JSONValue?\n    let ghostCommit: JSONValue?\n\n    enum CodingKeys: String, CodingKey {\n        case type\n        case status\n        case callID = \"call_id\"\n        case name\n        case content\n        case summary\n        case encryptedContent = \"encrypted_content\"\n        case role\n        case arguments\n        case input\n        case output\n        case ghostCommit = \"ghost_commit\"\n    }\n}\n\nstruct ResponseContentBlock: Decodable {\n    let type: String\n    let text: String?\n}\n\nstruct ResponseSummaryItem: Decodable {\n    let type: String\n    let text: String?\n}\n\nstruct MessageUsage: Decodable {\n    let inputTokens: Int?\n    let outputTokens: Int?\n    let cacheReadInputTokens: Int?\n    let cacheCreationInputTokens: Int?\n\n    enum CodingKeys: String, CodingKey {\n        case inputTokens = \"input_tokens\"\n        case outputTokens = \"output_tokens\"\n        case cacheReadInputTokens = \"cache_read_input_tokens\"\n        case cacheCreationInputTokens = \"cache_creation_input_tokens\"\n    }\n\n    /// Total tokens according to Claude Code billing formula:\n    /// input_tokens + output_tokens + cache_read_input_tokens + cache_creation_input_tokens\n    var totalTokens: Int {\n        (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheReadInputTokens ?? 0) + (cacheCreationInputTokens ?? 0)\n    }\n}\n\nstruct AssistantMessage: Decodable {\n    let id: String?\n    let type: String?\n    let role: String?\n    let usage: MessageUsage?\n}\n\nstruct AssistantMessagePayload: Decodable {\n    let message: AssistantMessage?\n}\n\nenum JSONValue: Decodable {\n    case string(String)\n    case number(Double)\n    case bool(Bool)\n    case object([String: JSONValue])\n    case array([JSONValue])\n    case null\n\n    var objectValue: [String: JSONValue]? {\n        if case let .object(dict) = self { return dict }\n        return nil\n    }\n\n    var intValue: Int? {\n        switch self {\n        case .number(let value):\n            if value.isFinite {\n                return Int(value)\n            }\n            return nil\n        case .string(let string):\n            return Int(string.trimmingCharacters(in: .whitespacesAndNewlines))\n        case .bool(let flag):\n            return flag ? 1 : 0\n        default:\n            return nil\n        }\n    }\n\n    init(from decoder: Decoder) throws {\n        // Try keyed container first (for objects)\n        if let keyedContainer = try? decoder.container(keyedBy: DynamicCodingKey.self) {\n            var dict: [String: JSONValue] = [:]\n            for key in keyedContainer.allKeys {\n                let value = try keyedContainer.decode(JSONValue.self, forKey: key)\n                dict[key.stringValue] = value\n            }\n            self = .object(dict)\n            return\n        }\n\n        // Try unkeyed container (for arrays)\n        if var arrayContainer = try? decoder.unkeyedContainer() {\n            var items: [JSONValue] = []\n            while !arrayContainer.isAtEnd {\n                let value = try arrayContainer.decode(JSONValue.self)\n                items.append(value)\n            }\n            self = .array(items)\n            return\n        }\n\n        // Finally try single value container (for primitives)\n        if let container = try? decoder.singleValueContainer() {\n            if container.decodeNil() {\n                self = .null\n            } else if let value = try? container.decode(Bool.self) {\n                self = .bool(value)\n            } else if let value = try? container.decode(Double.self) {\n                self = .number(value)\n            } else if let value = try? container.decode(String.self) {\n                self = .string(value)\n            } else {\n                self = .null\n            }\n            return\n        }\n\n        self = .null\n    }\n}\n\nstruct DynamicCodingKey: CodingKey {\n    var stringValue: String\n    var intValue: Int?\n\n    init?(stringValue: String) {\n        self.stringValue = stringValue\n    }\n\n    init?(intValue: Int) {\n        self.intValue = intValue\n        self.stringValue = \"\\(intValue)\"\n    }\n}\n\nstruct SessionSummaryBuilder {\n    private(set) var id: String?\n    private(set) var startedAt: Date?\n    private(set) var lastUpdatedAt: Date?\n    private(set) var cliVersion: String?\n    private(set) var cwd: String?\n    private(set) var originator: String?\n    private(set) var instructions: String?\n    private(set) var model: String?\n    private(set) var approvalPolicy: String?\n    private(set) var userMessageCount: Int = 0\n    private(set) var assistantMessageCount: Int = 0\n    private(set) var toolInvocationCount: Int = 0\n    private(set) var responseCounts: [String: Int] = [:]\n    private(set) var turnContextCount: Int = 0\n    private(set) var messageTypeCounts: [MessageVisibilityKind: Int] = [:]\n    private var seenToolCallIDs: Set<String> = []\n    private(set) var totalTokens: Int = 0\n    private(set) var tokenInput: Int = 0\n    private(set) var tokenOutput: Int = 0\n    private(set) var tokenCacheRead: Int = 0\n    private(set) var tokenCacheCreation: Int = 0\n    private(set) var eventCount: Int = 0\n    private(set) var lineCount: Int = 0\n    private(set) var fileSizeBytes: UInt64?\n    private(set) var source: SessionSource = .codexLocal\n    var parseLevel: SessionSummary.ParseLevel? = nil\n\n    var hasEssentialMetadata: Bool {\n        id != nil && startedAt != nil && cliVersion != nil && cwd != nil\n    }\n\n    mutating func setFileSize(_ size: UInt64?) {\n        fileSizeBytes = size\n    }\n\n    mutating func setSource(_ source: SessionSource) {\n        self.source = source\n    }\n\n    mutating func seedTotalTokens(_ total: Int) {\n        if total > totalTokens {\n            totalTokens = total\n        }\n    }\n\n    mutating func seedLastUpdated(_ date: Date) {\n        if let existing = lastUpdatedAt {\n            if date > existing { lastUpdatedAt = date }\n        } else {\n            lastUpdatedAt = date\n        }\n    }\n\n    mutating func accumulateIncrementalTokens(\n        input: Int?,\n        output: Int?,\n        cacheRead: Int?,\n        cacheCreation: Int?\n    ) {\n        if let value = input, value > 0 { tokenInput += value }\n        if let value = output, value > 0 { tokenOutput += value }\n        if let value = cacheRead, value > 0 { tokenCacheRead += value }\n        if let value = cacheCreation, value > 0 { tokenCacheCreation += value }\n    }\n\n    mutating func seedTokenSnapshot(\n        input: Int?,\n        output: Int?,\n        cacheRead: Int?,\n        cacheCreation: Int?\n    ) {\n        if let value = input, value > tokenInput { tokenInput = value }\n        if let value = output, value > tokenOutput { tokenOutput = value }\n        if let value = cacheRead, value > tokenCacheRead { tokenCacheRead = value }\n        if let value = cacheCreation, value > tokenCacheCreation { tokenCacheCreation = value }\n    }\n\n    func currentTokenBreakdown() -> SessionTokenBreakdown? {\n        let input = max(tokenInput, 0)\n        let output = max(tokenOutput, 0)\n        let cacheRead = max(tokenCacheRead, 0)\n        let cacheCreation = max(tokenCacheCreation, 0)\n        if input == 0 && output == 0 && cacheRead == 0 && cacheCreation == 0 {\n            return nil\n        }\n        return SessionTokenBreakdown(\n            input: input,\n            output: output,\n            cacheRead: cacheRead,\n            cacheCreation: cacheCreation)\n    }\n\n    mutating func observe(_ row: SessionRow) {\n        if case let .eventMessage(payload) = row.kind,\n           payload.type.lowercased() == \"turn_boundary\"\n        {\n            return\n        }\n        lineCount += 1\n        seedLastUpdated(row.timestamp)\n\n        switch row.kind {\n        case let .sessionMeta(payload):\n            id = payload.id\n            startedAt = payload.timestamp\n            cwd = payload.cwd\n            originator = payload.originator\n            cliVersion = payload.cliVersion\n            if let instructionsText = payload.instructions, instructions == nil {\n                instructions = instructionsText\n            }\n        case let .turnContext(payload):\n            turnContextCount += 1\n            if let model = payload.model {\n                self.model = model\n            }\n            if let approval = payload.approvalPolicy {\n                approvalPolicy = approval\n            }\n            if let cwd = payload.cwd, self.cwd == nil {\n                self.cwd = cwd\n            }\n        case let .eventMessage(payload):\n            eventCount += 1\n            let type = payload.type\n            if type == \"user_message\" {\n                userMessageCount += 1\n            } else if type == \"agent_message\" {\n                assistantMessageCount += 1\n            } else if type == \"token_count\" {\n                handleTokenCountEvent(message: payload.message ?? payload.text, info: payload.info)\n            }\n        case let .responseItem(payload):\n            eventCount += 1\n            responseCounts[payload.type, default: 0] += 1\n            if payload.type == \"message\" {\n                assistantMessageCount += 1\n            }\n            // Only count invocation events themselves; ignore corresponding *_output entries\n            // to avoid double-counting.\n            if payload.type == \"function_call\" || payload.type == \"custom_tool_call\" || payload.type == \"tool_call\" {\n                toolInvocationCount += 1\n            }\n        case let .assistantMessage(payload):\n            // Accumulate tokens from all assistant messages according to Claude Code formula:\n            // total = input_tokens + output_tokens + cache_read_input_tokens + cache_creation_input_tokens\n            assistantMessageCount += 1\n            if let usage = payload.message?.usage {\n                totalTokens += usage.totalTokens\n                accumulateIncrementalTokens(\n                    input: usage.inputTokens,\n                    output: usage.outputTokens,\n                    cacheRead: usage.cacheReadInputTokens,\n                    cacheCreation: usage.cacheCreationInputTokens)\n            }\n        case .unknown:\n            lineCount += 0\n        }\n\n        if let classified = TimelineEventClassifier.classify(row: row) {\n            if classified.isToolLike, let callID = classified.callID, !callID.isEmpty {\n                if !seenToolCallIDs.insert(callID).inserted {\n                    return\n                }\n            }\n            messageTypeCounts[classified.kind, default: 0] += 1\n        }\n    }\n\n    @discardableResult\n    private mutating func handleTokenCountEvent(message: String?, info: JSONValue?) -> Bool {\n        var handled = false\n        if let snapshot = SessionTokenSnapshot.from(info: info) {\n            applyTokenSnapshot(snapshot)\n            handled = true\n        }\n        if let snapshot = SessionTokenSnapshot.from(message: message) {\n            applyTokenSnapshot(snapshot)\n            handled = true\n        }\n        return handled\n    }\n\n    private mutating func applyTokenSnapshot(_ snapshot: SessionTokenSnapshot) {\n        if let total = snapshot.total {\n            totalTokens = max(totalTokens, total)\n        }\n        seedTokenSnapshot(\n            input: snapshot.input,\n            output: snapshot.output,\n            cacheRead: snapshot.cacheRead,\n            cacheCreation: snapshot.cacheCreation)\n    }\n\n    mutating func setModelFallback(_ fallback: String) {\n        if model == nil || model?.isEmpty == true {\n            model = fallback\n        }\n    }\n\n    func build(for url: URL) -> SessionSummary? {\n        guard let id,\n              let startedAt,\n              let cliVersion,\n              let originator,\n              let cwd\n        else {\n            return nil\n        }\n\n        var s = SessionSummary(\n            id: id,\n            fileURL: url,\n            fileSizeBytes: fileSizeBytes,\n            startedAt: startedAt,\n            endedAt: lastUpdatedAt,\n            activeDuration: nil,\n            cliVersion: cliVersion,\n            cwd: cwd,\n            originator: originator,\n            instructions: instructions,\n            model: model,\n            approvalPolicy: approvalPolicy,\n            userMessageCount: userMessageCount,\n            assistantMessageCount: assistantMessageCount,\n            toolInvocationCount: toolInvocationCount,\n            responseCounts: responseCounts,\n            turnContextCount: turnContextCount,\n            messageTypeCounts: messageTypeCounts.isEmpty ? nil : messageTypeCounts.reduce(into: [:]) { $0[$1.key.rawValue] = $1.value },\n            totalTokens: totalTokens,\n            tokenBreakdown: currentTokenBreakdown(),\n            eventCount: eventCount,\n            lineCount: lineCount,\n            lastUpdatedAt: lastUpdatedAt,\n            source: source,\n            remotePath: nil\n        )\n        s.parseLevel = parseLevel\n        return s\n    }\n}\n\nextension SessionRow {\n    init(timestamp: Date, kind: SessionRow.Kind) {\n        self.timestamp = timestamp\n        self.kind = kind\n    }\n}\n\nstruct SessionTokenSnapshot {\n    var input: Int?\n    var output: Int?\n    var cacheRead: Int?\n    var cacheCreation: Int?\n    var total: Int?\n\n    init(input: Int? = nil, output: Int? = nil, cacheRead: Int? = nil, cacheCreation: Int? = nil, total: Int? = nil) {\n        self.input = input\n        self.output = output\n        self.cacheRead = cacheRead\n        self.cacheCreation = cacheCreation\n        self.total = total\n    }\n\n    var hasValues: Bool {\n        return input != nil || output != nil || cacheRead != nil || cacheCreation != nil || total != nil\n    }\n\n    var breakdown: SessionTokenBreakdown? {\n        let inputValue = input ?? 0\n        let outputValue = output ?? 0\n        let cacheReadValue = cacheRead ?? 0\n        let cacheCreationValue = cacheCreation ?? 0\n        if inputValue == 0 && outputValue == 0 && cacheReadValue == 0 && cacheCreationValue == 0 {\n            return nil\n        }\n        return SessionTokenBreakdown(\n            input: inputValue,\n            output: outputValue,\n            cacheRead: cacheReadValue,\n            cacheCreation: cacheCreationValue)\n    }\n\n    mutating func merge(_ other: SessionTokenSnapshot) {\n        if let value = other.input { input = max(input ?? 0, value) }\n        if let value = other.output { output = max(output ?? 0, value) }\n        if let value = other.cacheRead { cacheRead = max(cacheRead ?? 0, value) }\n        if let value = other.cacheCreation { cacheCreation = max(cacheCreation ?? 0, value) }\n        if let value = other.total { total = max(total ?? 0, value) }\n    }\n\n    static func from(info: JSONValue?) -> SessionTokenSnapshot? {\n        guard let info, let dict = info.objectValue else { return nil }\n        var snapshot = SessionTokenSnapshot()\n\n        // Use total_token_usage (cumulative) instead of last_token_usage (incremental)\n        // Codex/Claude log files have both, but we want the cumulative total\n        if let tokenUsage = dict[\"total_token_usage\"]?.objectValue {\n            snapshot.merge(dict: tokenUsage)\n        } else if let tokenUsage = dict[\"token_usage\"]?.objectValue {\n            // Fallback for other formats that only have token_usage\n            snapshot.merge(dict: tokenUsage)\n        } else {\n            // Fallback to top-level keys\n            snapshot.merge(dict: dict)\n        }\n\n        if snapshot.total == nil, let total = dict[\"total\"]?.intValue {\n            snapshot.total = total\n        }\n        return snapshot.hasValues ? snapshot : nil\n    }\n\n    static func from(message: String?) -> SessionTokenSnapshot? {\n        guard let text = message?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {\n            return nil\n        }\n        var snapshot = SessionTokenSnapshot()\n        let parts = text.split(separator: \",\")\n        for part in parts {\n            let pair = part.split(separator: \":\", maxSplits: 1)\n            guard pair.count == 2 else { continue }\n            let key = pair[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n            let valueString = pair[1].trimmingCharacters(in: .whitespacesAndNewlines)\n            guard let value = Int(valueString) else { continue }\n            snapshot.assign(key: key, value: value)\n        }\n        if !snapshot.hasValues {\n            let lower = text.lowercased()\n            if let range = lower.range(of: \"total:\") {\n                let substring = lower[range.upperBound...]\n                let digits = substring.prefix(while: { $0.isWhitespace || $0.isNumber })\n                if let value = Int(digits.trimmingCharacters(in: .whitespaces)) {\n                    snapshot.total = value\n                }\n            }\n        }\n        return snapshot.hasValues ? snapshot : nil\n    }\n\n    mutating func merge(dict: [String: JSONValue]) {\n        for (key, value) in dict {\n            guard let number = value.intValue else { continue }\n            assign(key: key, value: number)\n        }\n    }\n\n    mutating func assign(key: String, value: Int) {\n        let normalized = key\n            .replacingOccurrences(of: \"_\", with: \"\")\n            .replacingOccurrences(of: \" \", with: \"\")\n        let lower = normalized.lowercased()\n        if lower.contains(\"total\") {\n            total = value\n            return\n        }\n        if lower.contains(\"cache\") || lower.contains(\"cached\") {\n            if lower.contains(\"creation\") {\n                cacheCreation = value\n            } else {\n                cacheRead = value\n            }\n            return\n        }\n        // Handle input tokens (but not cached_input_tokens which was handled above)\n        if lower.contains(\"input\") && !lower.contains(\"cache\") && !lower.contains(\"cached\") {\n            input = value\n            return\n        }\n        // Handle output tokens - use max to avoid overwriting with reasoning_output_tokens\n        // since output_tokens usually includes reasoning_output_tokens already\n        if lower.contains(\"output\") || lower.contains(\"reasoning\") {\n            output = max(output ?? 0, value)\n            return\n        }\n    }\n}\n"
  },
  {
    "path": "models/SessionLaunchProvider.swift",
    "content": "import Foundation\n\nstruct SessionLaunchProvider: Identifiable, Hashable, Sendable {\n    let sessionSource: SessionSource\n\n    var id: String { sessionSource.launchIdentifier }\n}\n\nextension SessionSource {\n    var launchIdentifier: String {\n        switch self {\n        case .codexLocal:\n            return \"codex-local\"\n        case .claudeLocal:\n            return \"claude-local\"\n        case .geminiLocal:\n            return \"gemini-local\"\n        case .codexRemote(let host):\n            return \"codex-remote-\\(host)\"\n        case .claudeRemote(let host):\n            return \"claude-remote-\\(host)\"\n        case .geminiRemote(let host):\n            return \"gemini-remote-\\(host)\"\n        }\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+Commands.swift",
    "content": "import AppKit\nimport Foundation\n\n@MainActor\nextension SessionListViewModel {\n    func resume(session: SessionSummary) async -> Result<ProcessResult, Error> {\n        do {\n            let cwd = resolvedWorkingDirectory(for: session)\n            let codexHome = codexHomeOverride(for: session)\n            let result = try await actions.resume(\n                session: session,\n                executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                workingDirectory: cwd,\n                codexHomeOverride: codexHome)\n            return .success(result)\n        } catch {\n            return .failure(error)\n        }\n    }\n\n    private func preferredExecutableURL(for source: SessionSource) -> URL {\n        if let override = preferences.resolvedCommandOverrideURL(for: source.baseKind) {\n            return override\n        }\n        return URL(fileURLWithPath: \"/usr/bin/env\")\n    }\n\n    private func preferredExecutablePath(for kind: SessionSource.Kind) -> String {\n        preferences.preferredExecutablePath(for: kind)\n    }\n\n    private var commandGenerator: SessionCommandGenerator {\n        SessionCommandGenerator(actions: actions)\n    }\n\n    private func preferredExternalTerminalProfile() -> ExternalTerminalProfile? {\n        ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n            id: preferences.defaultResumeExternalAppId\n        )\n    }\n\n    var shouldCopyCommandsToClipboard: Bool {\n        preferences.defaultResumeCopyToClipboard\n    }\n\n    func copyResumeCommands(session: SessionSummary) {\n        let cwd = resolvedWorkingDirectory(for: session)\n        let codexHome = codexHomeOverride(for: session)\n        actions.copyResumeCommands(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            simplifiedForExternal: true,\n            workingDirectory: cwd,\n            codexHome: codexHome\n        )\n    }\n\n    private func warpResumeTitle(for session: SessionSummary) -> String? {\n        if let title = session.userTitle, let sanitized = warpSanitizedTitle(from: title) {\n            return sanitized\n        }\n        let defaultScope = warpScopeCandidate(for: session, project: projectForSession(session))\n        let defaultValue = WarpTitleBuilder.newSessionLabel(scope: defaultScope, task: taskTitle(for: session))\n        return resolveWarpTitleInput(defaultValue: defaultValue, forcePrompt: true)\n    }\n\n    private func projectForSession(_ session: SessionSummary) -> Project? {\n        guard let pid = projectIdForSession(session.id) else { return nil }\n        return projects.first(where: { $0.id == pid })\n    }\n\n    private func codexHomeOverride(for project: Project?) -> String? {\n        guard let project,\n              let dir = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines),\n              !dir.isEmpty\n        else { return nil }\n        guard ProjectExtensionsStore.requiresCodexHome(projectId: project.id) else { return nil }\n        let codexDir = URL(fileURLWithPath: dir, isDirectory: true)\n            .appendingPathComponent(\".codex\", isDirectory: true)\n        guard FileManager.default.fileExists(atPath: codexDir.path) else { return nil }\n        return codexDir.path\n    }\n\n    private func codexHomeOverride(for session: SessionSummary) -> String? {\n        guard session.source.baseKind == .codex else { return nil }\n        return codexHomeOverride(for: projectForSession(session))\n    }\n\n    @discardableResult\n    func copyResumeCommandsRespectingProject(\n        session: SessionSummary,\n        destinationApp: ExternalTerminalProfile? = nil\n    ) -> Bool {\n        let cwd = resolvedWorkingDirectory(for: session)\n        let codexHome = codexHomeOverride(for: session)\n        var warpHint: String? = nil\n        if destinationApp?.usesWarpCommands == true {\n            guard let hint = warpResumeTitle(for: session) else { return false }\n            warpHint = hint\n        }\n\n        if session.source != .codexLocal {\n            actions.copyResumeCommands(\n                session: session,\n                executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                simplifiedForExternal: true,\n                destinationApp: destinationApp,\n                titleHint: warpHint,\n                workingDirectory: cwd,\n                codexHome: codexHome\n            )\n            return true\n        }\n        if let pid = projectIdForSession(session.id),\n            let p = projects.first(where: { $0.id == pid }),\n            p.profile != nil || (p.profileId?.isEmpty == false)\n        {\n            actions.copyResumeUsingProjectProfileCommands(\n                session: session, project: p,\n                executableURL: preferredExecutableURL(for: .codexLocal),\n                options: preferences.resumeOptions,\n                destinationApp: destinationApp,\n                titleHint: warpHint,\n                codexHome: codexHome)\n        } else {\n            actions.copyResumeCommands(\n                session: session,\n                executableURL: preferredExecutableURL(for: .codexLocal),\n                options: preferences.resumeOptions,\n                simplifiedForExternal: true,\n                destinationApp: destinationApp,\n                titleHint: warpHint,\n                workingDirectory: cwd,\n                codexHome: codexHome)\n        }\n        return true\n    }\n\n    @discardableResult\n    func copyResumeCommandsIfEnabled(\n        session: SessionSummary,\n        destinationApp: ExternalTerminalProfile? = nil\n    ) -> Bool {\n        guard preferences.defaultResumeCopyToClipboard else { return true }\n        return copyResumeCommandsRespectingProject(session: session, destinationApp: destinationApp)\n    }\n\n    func openInTerminal(session: SessionSummary) -> Bool {\n        let cwd = resolvedWorkingDirectory(for: session)\n        let codexHome = codexHomeOverride(for: session)\n        return actions.openInTerminal(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            workingDirectory: cwd,\n            codexHome: codexHome)\n    }\n\n    func buildResumeCommands(session: SessionSummary) -> String {\n        let cwd = resolvedWorkingDirectory(for: session)\n        let codexHome = codexHomeOverride(for: session)\n        // For embedded terminal, skip cd command since terminal is already initialized\n        // to the correct working directory via worktreePath parameter\n        return commandGenerator.embeddedResume(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            workingDirectory: cwd,\n            codexHome: codexHome,\n            includeCd: false\n        )\n    }\n\n    func buildEmbeddedNewSessionCommands(\n        session: SessionSummary,\n        initialPrompt: String? = nil,\n        projectOverride: Project? = nil\n    ) -> String {\n        let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in\n            projects.first(where: { $0.id == pid })\n        }\n        let codexHome = codexHomeOverride(for: session)\n        return commandGenerator.embeddedNew(\n            session: session,\n            project: project,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            initialPrompt: initialPrompt,\n            codexHome: codexHome\n        )\n    }\n\n    func buildEmbeddedNewProjectCommands(project: Project) -> String {\n        commandGenerator.embeddedNewProject(\n            project: project,\n            executableURL: preferredExecutableURL(for: .codexLocal),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: project)\n        )\n    }\n\n    func buildExternalResumeCommands(session: SessionSummary) -> String {\n        let cwd = resolvedWorkingDirectory(for: session)\n        let codexHome = codexHomeOverride(for: session)\n        return actions.buildExternalResumeCommands(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            workingDirectory: cwd,\n            codexHome: codexHome\n        )\n    }\n\n    func buildResumeCLIInvocation(session: SessionSummary) -> String {\n        return commandGenerator.inlineResume(\n            session: session,\n            executablePath: preferredExecutablePath(for: session.source.baseKind),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    // MARK: - Embedded CLI Console helpers (dev)\n    func buildResumeCLIArgs(session: SessionSummary) -> [String] {\n        actions.buildResumeArguments(session: session, options: preferences.resumeOptions)\n    }\n\n    func buildNewSessionCLIArgs(session: SessionSummary) -> [String] {\n        actions.buildNewSessionArguments(session: session, options: preferences.resumeOptions)\n    }\n\n    func buildResumeCLIInvocationRespectingProject(session: SessionSummary) -> String {\n        let project = projectIdForSession(session.id).flatMap { pid in\n            projects.first(where: { $0.id == pid })\n        }\n        let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session)\n        return commandGenerator.inlineResume(\n            session: session,\n            project: project,\n            executablePath: preferredExecutablePath(for: session.source.baseKind),\n            options: preferences.resumeOptions,\n            codexHome: codexHome\n        )\n    }\n\n    func copyNewSessionCommands(session: SessionSummary) {\n        actions.copyNewSessionCommands(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    func buildNewSessionCLIInvocation(session: SessionSummary) -> String {\n        commandGenerator.inlineNew(\n            session: session,\n            executablePath: preferredExecutablePath(for: session.source.baseKind),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    func openNewSession(session: SessionSummary) -> Bool {\n        actions.openNewSession(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    func buildNewProjectCLIInvocation(project: Project) -> String {\n        commandGenerator.inlineNewProject(\n            project: project,\n            executablePath: preferredExecutablePath(for: .codex),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: project)\n        )\n    }\n\n    func buildClaudeProjectInvocation(project: Project) -> String {\n        commandGenerator.projectClaudeInvocation(\n            project: project,\n            executablePath: preferredExecutablePath(for: .claude),\n            options: preferences.resumeOptions,\n            fallbackModel: preferences.claudeFallbackModel\n        )\n    }\n\n    func buildGeminiProjectInvocation() -> String {\n        commandGenerator.projectGeminiInvocation(\n            executablePath: preferredExecutablePath(for: .gemini),\n            options: preferences.resumeOptions\n        )\n    }\n\n    @discardableResult\n    func copyNewProjectCommands(project: Project, destinationApp: ExternalTerminalProfile? = nil) -> Bool {\n        var warpHint: String? = nil\n        if destinationApp?.usesWarpCommands == true {\n            let base = warpTitleForProject(project)\n            guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false }\n            warpHint = resolved\n        }\n        actions.copyNewProjectCommands(\n            project: project,\n            executableURL: preferredExecutableURL(for: .codexLocal),\n            options: preferences.resumeOptions,\n            destinationApp: destinationApp,\n            titleHint: warpHint,\n            codexHome: codexHomeOverride(for: project)\n        )\n        return true\n    }\n\n    /// Unified Project \"New Session\" entry. Respects embedded/external preference\n    /// to reduce branching between Sidebar and Detail flows.\n    func newSession(project: Project) {\n        let embeddedPreferred = preferences.defaultResumeUseEmbeddedTerminal\n        NSLog(\n            \"📌 [SessionListVM] newSession(project:%@) embeddedPreferred=%@ useEmbeddedCLIConsole=%@\",\n            project.id,\n            embeddedPreferred ? \"YES\" : \"NO\",\n            preferences.useEmbeddedCLIConsole ? \"YES\" : \"NO\"\n        )\n        // Record intent so the new session can be auto-assigned to this project\n        recordIntentForProjectNew(project: project)\n\n        if preferences.defaultResumeUseEmbeddedTerminal {\n            // Embedded terminal path: signal ContentView to start an embedded\n            // shell anchored to this project and perform targeted refresh.\n            pendingEmbeddedProjectNew = project\n            setIncrementalHintForCodexToday()\n            // Also broadcast a notification for robustness across views\n            NotificationCenter.default.post(\n                name: .codMateStartEmbeddedNewProject,\n                object: nil,\n                userInfo: [\"projectId\": project.id]\n            )\n            Task { await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Starting embedded New…\") }\n            return\n        }\n\n        // Resolve preferred external terminal and open at the project directory\n        guard let profile = preferredExternalTerminalProfile() else { return }\n        let dir: String = {\n            let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n            return d.isEmpty ? NSHomeDirectory() : d\n        }()\n\n        // External terminal path: copy command and open preferred terminal.\n        guard copyNewProjectCommands(project: project, destinationApp: profile) else { return }\n\n        if !profile.isNone {\n            let cmd = profile.supportsCommandResolved\n                ? buildNewProjectCLIInvocation(project: project)\n                : nil\n\n            if profile.isTerminal {\n                _ = openAppleTerminal(at: dir)\n            } else {\n                openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n            }\n        }\n\n        // Friendly nudge so users know the command was placed on clipboard\n        if preferences.commandCopyNotificationsEnabled {\n            Task {\n                await SystemNotifier.shared.notify(\n                    title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n            }\n        }\n\n        // Event-driven incremental refresh hint + proactive targeted refresh for today\n        setIncrementalHintForCodexToday()\n        Task { await self.refreshIncrementalForNewCodexToday() }\n    }\n\n    /// Build CLI invocation, respecting project profile if applicable.\n    /// - Parameters:\n    ///   - session: Session to launch.\n    ///   - initialPrompt: Optional initial prompt text to pass to CLI.\n    /// - Returns: Complete CLI command string.\n    func buildNewSessionCLIInvocationRespectingProject(\n        session: SessionSummary,\n        initialPrompt: String? = nil,\n        projectOverride: Project? = nil\n    ) -> String {\n        let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in\n            projects.first(where: { $0.id == pid })\n        }\n        let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session)\n        return commandGenerator.inlineNew(\n            session: session,\n            project: project,\n            executablePath: preferredExecutablePath(for: session.source.baseKind),\n            options: preferences.resumeOptions,\n            initialPrompt: initialPrompt,\n            codexHome: codexHome\n        )\n    }\n\n    @discardableResult\n    func copyNewSessionCommandsRespectingProject(\n        session: SessionSummary,\n        destinationApp: ExternalTerminalProfile? = nil,\n        warpTitleOverride: String? = nil,\n        projectOverride: Project? = nil\n    ) -> Bool {\n        let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in\n            projects.first(where: { $0.id == pid })\n        }\n        var warpHint: String? = nil\n        if destinationApp?.usesWarpCommands == true {\n            if let override = warpTitleOverride {\n                warpHint = warpSanitizedTitle(from: override) ?? override\n            } else {\n                let base = warpNewSessionTitleHint(for: session, project: project)\n                guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false }\n                warpHint = resolved\n            }\n        }\n\n        if session.source == .codexLocal,\n            let project,\n            project.profile != nil || (project.profileId?.isEmpty == false)\n        {\n            actions.copyNewSessionUsingProjectProfileCommands(\n                session: session, project: project, executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                destinationApp: destinationApp,\n                titleHint: warpHint,\n                codexHome: codexHomeOverride(for: project)\n            )\n        } else {\n            actions.copyNewSessionCommands(\n                session: session,\n                executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                destinationApp: destinationApp,\n                titleHint: warpHint,\n                codexHome: codexHomeOverride(for: session)\n            )\n        }\n        return true\n    }\n\n    @discardableResult\n    func copyNewSessionCommandsIfEnabled(\n        session: SessionSummary,\n        destinationApp: ExternalTerminalProfile? = nil,\n        initialPrompt: String? = nil,\n        warpTitleOverride: String? = nil,\n        projectOverride: Project? = nil\n    ) -> Bool {\n        guard preferences.defaultResumeCopyToClipboard else { return true }\n        if let initialPrompt {\n            return copyNewSessionCommandsRespectingProject(\n                session: session,\n                destinationApp: destinationApp,\n                initialPrompt: initialPrompt,\n                warpTitleOverride: warpTitleOverride,\n                projectOverride: projectOverride\n            )\n        }\n        return copyNewSessionCommandsRespectingProject(\n            session: session,\n            destinationApp: destinationApp,\n            warpTitleOverride: warpTitleOverride,\n            projectOverride: projectOverride\n        )\n    }\n\n    @discardableResult\n    func copyNewSessionCommandsRespectingProject(\n        session: SessionSummary,\n        destinationApp: ExternalTerminalProfile? = nil,\n        initialPrompt: String,\n        warpTitleOverride: String? = nil,\n        projectOverride: Project? = nil\n    ) -> Bool {\n        let project = projectOverride ?? projectIdForSession(session.id).flatMap { pid in\n            projects.first(where: { $0.id == pid })\n        }\n        var warpHint: String? = nil\n        if destinationApp?.usesWarpCommands == true {\n            if let override = warpTitleOverride {\n                warpHint = warpSanitizedTitle(from: override) ?? override\n            } else {\n                let base = warpNewSessionTitleHint(for: session, project: project)\n                guard let resolved = resolveWarpTitleInput(defaultValue: base) else { return false }\n                warpHint = resolved\n            }\n        }\n\n        if session.source == .codexLocal,\n            let project,\n            project.profile != nil || (project.profileId?.isEmpty == false)\n        {\n            actions.copyNewSessionUsingProjectProfileCommands(\n                session: session, project: project, executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                destinationApp: destinationApp,\n                initialPrompt: initialPrompt,\n                titleHint: warpHint,\n                codexHome: codexHomeOverride(for: project)\n            )\n        } else {\n            let codexHome = project.map { codexHomeOverride(for: $0) } ?? codexHomeOverride(for: session)\n            let cmd = commandGenerator.inlineNew(\n                session: session,\n                project: project,\n                executablePath: preferredExecutablePath(for: session.source.baseKind),\n                options: preferences.resumeOptions,\n                initialPrompt: initialPrompt,\n                codexHome: codexHome\n            )\n            let pb = NSPasteboard.general\n            pb.clearContents()\n            if destinationApp?.usesWarpCommands == true, let title = warpHint {\n                let lines = [\"#\\(title)\", cmd]\n                pb.setString(lines.joined(separator: \"\\n\") + \"\\n\", forType: .string)\n            } else {\n                pb.setString(cmd + \"\\n\", forType: .string)\n            }\n        }\n        return true\n    }\n\n    @discardableResult\n    func copyNewProjectCommandsIfEnabled(\n        project: Project,\n        destinationApp: ExternalTerminalProfile? = nil\n    ) -> Bool {\n        guard preferences.defaultResumeCopyToClipboard else { return true }\n        return copyNewProjectCommands(project: project, destinationApp: destinationApp)\n    }\n\n    private func warpSanitizedTitle(from raw: String?) -> String? {\n        guard var s = raw else { return nil }\n        s = s.replacingOccurrences(of: \"\\r\", with: \" \")\n        s = s.replacingOccurrences(of: \"\\n\", with: \" \")\n        s = s.replacingOccurrences(of: \"\\t\", with: \" \")\n        s = s.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !s.isEmpty else { return nil }\n        if s.count > 80 { s = String(s.prefix(80)) }\n        let collapsed = s.split(whereSeparator: { $0.isWhitespace }).joined(separator: \"-\")\n        return collapsed.isEmpty ? nil : collapsed\n    }\n\n    private func warpScopeCandidate(for session: SessionSummary, project: Project?) -> String? {\n        if let name = project?.name.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty {\n            return name\n        }\n        if let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines),\n            !title.isEmpty\n        {\n            return title\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let name = URL(fileURLWithPath: cwd).lastPathComponent\n        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? session.displayName : trimmed\n    }\n\n    private func taskTitle(for session: SessionSummary) -> String? {\n        guard let tid = session.taskId else { return nil }\n        return workspaceVM?.tasks.first(where: { $0.id == tid })?.effectiveTitle\n    }\n\n    private func warpNewSessionTitleHint(for session: SessionSummary, project: Project?) -> String {\n        let scope = warpScopeCandidate(for: session, project: project)\n        let task = taskTitle(for: session)\n        var extras: [String] = []\n        if session.isRemote, let host = session.remoteHost {\n            extras.append(host)\n        }\n        return WarpTitleBuilder.newSessionLabel(scope: scope, task: task, extras: extras)\n    }\n\n    private func warpTitleForProject(_ project: Project) -> String {\n        WarpTitleBuilder.newSessionLabel(scope: project.name, task: nil)\n    }\n\n    private func resolveWarpTitleInput(defaultValue: String, forcePrompt: Bool = false) -> String? {\n        if preferences.promptForWarpTitle || forcePrompt {\n            guard let userInput = WarpTitlePrompt.requestCustomTitle(defaultValue: defaultValue) else {\n                return nil\n            }\n            let trimmed = userInput.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed.isEmpty {\n                return defaultValue\n            }\n            return warpSanitizedTitle(from: trimmed) ?? defaultValue\n        }\n        return defaultValue\n    }\n    \n    // MARK: - Notification Helpers\n    \n    /// Notify user that command was copied to clipboard, if notifications are enabled.\n    private func notifyCommandCopiedIfEnabled(message: String = \"Command copied. Paste it in the opened terminal.\") {\n        guard shouldCopyCommandsToClipboard, preferences.commandCopyNotificationsEnabled else { return }\n        Task {\n            await SystemNotifier.shared.notify(title: \"CodMate\", body: message)\n        }\n    }\n\n\n    func openNewSessionRespectingProject(session: SessionSummary) {\n        if session.source == .codexLocal,\n            let pid = projectIdForSession(session.id),\n            let p = projects.first(where: { $0.id == pid }),\n            p.profile != nil || (p.profileId?.isEmpty == false)\n        {\n            _ = actions.openNewSessionUsingProjectProfile(\n                session: session, project: p, executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                codexHome: codexHomeOverride(for: p)\n            )\n        } else {\n            _ = actions.openNewSession(\n                session: session,\n                executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                codexHome: codexHomeOverride(for: session)\n            )\n        }\n    }\n\n    // MARK: - Launch New Session From Project (without anchor)\n    \n    /// Launch a new session from a project without requiring an anchor session.\n    /// This is used when right-clicking on a project with no sessions.\n    func launchNewSessionFromProject(\n        project: Project,\n        using source: SessionSource,\n        profile: ExternalTerminalProfile\n    ) {\n        recordIntentForProjectNew(project: project)\n        let dir: String = {\n            let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n            return d.isEmpty ? NSHomeDirectory() : d\n        }()\n\n        if profile.id == \"codmate.embedded\" {\n            #if APPSTORE\n            newSession(project: project)\n            #else\n            NotificationCenter.default.post(\n                name: .codMateStartEmbeddedNewProject,\n                object: nil,\n                userInfo: [\"projectId\": project.id]\n            )\n            #endif\n            return\n        }\n\n        // Build command based on source\n        let cmd: String\n        switch source.baseKind {\n        case .codex:\n            cmd = buildNewProjectCLIInvocation(project: project)\n        case .claude:\n            cmd = buildClaudeProjectInvocation(project: project)\n        case .gemini:\n            cmd = buildGeminiProjectInvocation()\n        }\n\n        guard copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile)\n        else { return }\n\n        if profile.usesWarpCommands {\n            openPreferredTerminalViaScheme(profile: profile, directory: dir)\n            notifyCommandCopiedIfEnabled()\n            return\n        }\n\n        if profile.isTerminal {\n            // Create a dummy session for terminal opening\n            let dummySession = SessionSummary(\n                id: UUID().uuidString,\n                fileURL: URL(fileURLWithPath: \"/dev/null\"),\n                fileSizeBytes: 0,\n                startedAt: Date(),\n                endedAt: nil,\n                activeDuration: nil,\n                cliVersion: \"\",\n                cwd: dir,\n                originator: \"system\",\n                instructions: nil,\n                model: nil,\n                approvalPolicy: nil,\n                userMessageCount: 0,\n                assistantMessageCount: 0,\n                toolInvocationCount: 0,\n                responseCounts: [:],\n                turnContextCount: 0,\n                totalTokens: 0,\n                eventCount: 0,\n                lineCount: 0,\n                lastUpdatedAt: Date(),\n                source: source,\n                remotePath: nil\n            )\n            if !openNewSession(session: dummySession) {\n                _ = openAppleTerminal(at: dir)\n                notifyCommandCopiedIfEnabled()\n            }\n            return\n        }\n\n        if profile.isNone {\n            notifyCommandCopiedIfEnabled(message: \"Command copied.\")\n            return\n        }\n\n        let inlineCmd = profile.supportsCommandResolved ? cmd : nil\n        openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inlineCmd)\n        if !profile.supportsCommandResolved {\n            notifyCommandCopiedIfEnabled()\n        }\n    }\n\n    // MARK: - Unified Launch New Session (extracted from views)\n    \n    /// Unified method to launch a new session with a given profile.\n    /// This consolidates the duplicate logic from multiple view files.\n    func launchNewSessionWithProfile(\n        session: SessionSummary,\n        using source: SessionSource,\n        profile: ExternalTerminalProfile,\n        workingDirectory: String? = nil,\n        initialPrompt: String? = nil,\n        warpTitle: String? = nil,\n        projectOverride: Project? = nil,\n        embeddedHandler: ((SessionSummary, SessionSource) -> Void)? = nil\n    ) {\n        let target = source == session.source ? session : session.overridingSource(source)\n        recordIntentForDetailNew(anchor: target)\n        let dir = workingDirectory ?? resolvedWorkingDirectory(for: target)\n\n        // Handle embedded terminal\n        if profile.id == \"codmate.embedded\" {\n            if let embeddedHandler {\n                embeddedHandler(target, source)\n            } else {\n                EmbeddedSessionNotification.postEmbeddedNewSession(sessionId: target.id, source: source)\n            }\n            return\n        }\n\n        guard copyNewSessionCommandsIfEnabled(\n            session: target,\n            destinationApp: profile,\n            initialPrompt: initialPrompt,\n            warpTitleOverride: warpTitle,\n            projectOverride: projectOverride\n        ) else { return }\n\n        // Handle None profile (copy only)\n        if profile.isNone {\n            notifyCommandCopiedIfEnabled()\n            return\n        }\n\n        // Handle Warp commands\n        if profile.usesWarpCommands {\n            openPreferredTerminalViaScheme(profile: profile, directory: dir)\n            notifyCommandCopiedIfEnabled()\n            return\n        }\n\n        // Handle Terminal.app\n        if profile.isTerminal {\n            #if APPSTORE\n            _ = copyNewSessionCommandsIfEnabled(\n                session: target,\n                destinationApp: profile,\n                initialPrompt: initialPrompt,\n                warpTitleOverride: warpTitle,\n                projectOverride: projectOverride\n            )\n            _ = openAppleTerminal(at: dir)\n            #else\n            // Use openNewSessionRespectingProject to handle initialPrompt if supported\n            if let prompt = initialPrompt {\n                openNewSessionRespectingProject(session: target, initialPrompt: prompt)\n            } else if !openNewSession(session: target) {\n                _ = copyNewSessionCommandsIfEnabled(session: target, destinationApp: profile, projectOverride: projectOverride)\n                _ = openAppleTerminal(at: dir)\n                notifyCommandCopiedIfEnabled()\n            }\n            #endif\n            return\n        }\n\n        // Handle other terminals with command resolution\n        if !profile.supportsCommandResolved {\n            _ = copyNewSessionCommandsIfEnabled(\n                session: target,\n                destinationApp: profile,\n                initialPrompt: initialPrompt,\n                warpTitleOverride: warpTitle,\n                projectOverride: projectOverride\n            )\n        }\n        let cmd = profile.supportsCommandResolved\n            ? buildNewSessionCLIInvocationRespectingProject(\n                session: target,\n                initialPrompt: initialPrompt,\n                projectOverride: projectOverride\n            )\n            : nil\n        openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n        if !profile.supportsCommandResolved {\n            notifyCommandCopiedIfEnabled()\n        }\n    }\n\n    func openNewSessionRespectingProject(session: SessionSummary, initialPrompt: String) {\n        if session.source == .codexLocal,\n            let pid = projectIdForSession(session.id),\n            let p = projects.first(where: { $0.id == pid }),\n            p.profile != nil || (p.profileId?.isEmpty == false)\n        {\n            _ = actions.openNewSessionUsingProjectProfile(\n                session: session, project: p, executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                initialPrompt: initialPrompt,\n                codexHome: codexHomeOverride(for: p)\n            )\n        } else {\n            _ = actions.openNewSession(\n                session: session,\n                executableURL: preferredExecutableURL(for: session.source),\n                options: preferences.resumeOptions,\n                codexHome: codexHomeOverride(for: session)\n            )\n        }\n    }\n\n    func projectIdForSession(_ id: String) -> String? {\n        if let summary = sessionSummary(for: id) {\n            return projectId(for: summary)\n        }\n        for source in ProjectSessionSource.allCases {\n            if let pid = projectId(for: id, source: source) {\n                return pid\n            }\n        }\n        return nil\n    }\n\n    func projectForId(_ id: String) async -> Project? {\n        await projectsStore.getProject(id: id)\n    }\n\n    func allowedSources(for session: SessionSummary) -> [ProjectSessionSource] {\n        let sources: [ProjectSessionSource]\n        if let pid = projectIdForSession(session.id),\n            let p = projects.first(where: { $0.id == pid })\n        {\n            let allowed = p.sources.isEmpty ? ProjectSessionSource.allSet : p.sources\n            sources = Array(allowed).sorted { $0.displayName < $1.displayName }\n        } else {\n            sources = ProjectSessionSource.allCases\n        }\n        return sources.filter { preferences.isCLIEnabled($0.baseKind) }\n    }\n\n    func copyRealResumeCommand(session: SessionSummary) {\n        actions.copyRealResumeInvocation(\n            session: session,\n            executableURL: preferredExecutableURL(for: session.source),\n            options: preferences.resumeOptions,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    func openWarpLaunch(session: SessionSummary) {\n        let cwd = resolvedWorkingDirectory(for: session)\n        _ = actions.openWarpLaunchConfig(\n            session: session,\n            options: preferences.resumeOptions,\n            executableURL: preferredExecutableURL(for: session.source),\n            workingDirectory: cwd,\n            codexHome: codexHomeOverride(for: session)\n        )\n    }\n\n    func openPreferredTerminal(profile: ExternalTerminalProfile) {\n        actions.openTerminalApp(profile)\n    }\n\n    func openPreferredTerminalViaScheme(\n        profile: ExternalTerminalProfile,\n        directory: String,\n        command: String? = nil\n    ) {\n        actions.openTerminalViaScheme(profile, directory: directory, command: command)\n    }\n\n    func openAppleTerminal(at directory: String) -> Bool {\n        actions.openAppleTerminal(at: directory)\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+Editor.swift",
    "content": "import Foundation\nimport AppKit\n\nextension SessionListViewModel {\n    /// Open a project directory in the specified editor\n    /// - Parameters:\n    ///   - project: The project to open\n    ///   - editor: The editor app to use (VSCode, Cursor, Zed, Antigravity)\n    /// - Returns: True if successfully opened, false otherwise\n    @discardableResult\n    func openProjectInEditor(_ project: Project, using editor: EditorApp) -> Bool {\n        guard let directory = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines),\n              !directory.isEmpty else {\n            errorMessage = \"Project directory is not set\"\n            return false\n        }\n\n        let dirURL = URL(fileURLWithPath: directory)\n\n        // Verify directory exists\n        var isDirectory: ObjCBool = false\n        guard FileManager.default.fileExists(atPath: directory, isDirectory: &isDirectory),\n              isDirectory.boolValue else {\n            errorMessage = \"Directory does not exist: \\(directory)\"\n            return false\n        }\n\n        // Strategy 1: Try CLI command first (most reliable, supports opening specific directories)\n        if let executablePath = findExecutableInPath(editor.cliCommand) {\n            let process = Process()\n            process.executableURL = URL(fileURLWithPath: executablePath)\n            process.arguments = [directory]\n            process.standardOutput = Pipe()\n            process.standardError = Pipe()\n\n            do {\n                try process.run()\n                return true\n            } catch {\n                // Fall through to Strategy 2\n            }\n        }\n\n        // Strategy 2: Open via bundle identifier\n        if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) {\n            let config = NSWorkspace.OpenConfiguration()\n            config.activates = true\n\n            NSWorkspace.shared.open(\n                [dirURL],\n                withApplicationAt: appURL,\n                configuration: config\n            ) { _, error in\n                if let error = error {\n                    DispatchQueue.main.async {\n                        self.errorMessage = \"Failed to open \\(editor.title): \\(error.localizedDescription)\"\n                    }\n                }\n            }\n            return true\n        }\n\n        // Editor not found\n        errorMessage = \"\\(editor.title) is not installed. Please install it or try a different editor.\"\n        return false\n    }\n\n    /// Reveal a project directory in Finder\n    /// - Parameter project: The project to reveal\n    func revealProjectDirectory(_ project: Project) {\n        guard let directory = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines),\n              !directory.isEmpty else {\n            errorMessage = \"Project directory is not set\"\n            return\n        }\n\n        let dirURL = URL(fileURLWithPath: directory)\n\n        // Verify directory exists\n        var isDirectory: ObjCBool = false\n        guard FileManager.default.fileExists(atPath: directory, isDirectory: &isDirectory),\n              isDirectory.boolValue else {\n            errorMessage = \"Directory does not exist: \\(directory)\"\n            return\n        }\n\n        // Reveal in Finder (will activate Finder and select the folder)\n        NSWorkspace.shared.activateFileViewerSelecting([dirURL])\n    }\n\n    /// Find an executable in the system PATH\n    private func findExecutableInPath(_ name: String) -> String? {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n        process.arguments = [name]\n\n        let pipe = Pipe()\n        process.standardOutput = pipe\n        process.standardError = Pipe()\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n\n            guard process.terminationStatus == 0 else { return nil }\n\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            let path = String(data: data, encoding: .utf8)?\n                .trimmingCharacters(in: .whitespacesAndNewlines)\n\n            return path?.isEmpty == false ? path : nil\n        } catch {\n            return nil\n        }\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+Intents.swift",
    "content": "import Foundation\n\n@MainActor\nextension SessionListViewModel {\n    func recordIntentForDetailNew(anchor: SessionSummary) {\n        guard let pid = projectIdForSession(anchor.id) else { return }\n        let hints = PendingAssignIntent.Hints(\n            model: anchor.model,\n            sandbox: preferences.resumeOptions.flagSandboxRaw,\n            approval: preferences.resumeOptions.flagApprovalRaw\n        )\n        recordIntent(projectId: pid, expectedCwd: anchor.cwd, hints: hints)\n    }\n\n    func recordIntentForProjectNew(project: Project) {\n        let expected =\n            (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n                $0.isEmpty ? nil : $0\n            } ?? NSHomeDirectory()\n        let hints = PendingAssignIntent.Hints(\n            model: project.profile?.model,\n            sandbox: project.profile?.sandbox?.rawValue ?? preferences.resumeOptions.flagSandboxRaw,\n            approval: project.profile?.approval?.rawValue\n                ?? preferences.resumeOptions.flagApprovalRaw\n        )\n        recordIntent(projectId: project.id, expectedCwd: expected, hints: hints)\n    }\n}\n\nextension SessionListViewModel {\n    func handleAutoAssignIfMatches(_ s: SessionSummary) {\n        guard !pendingAssignIntents.isEmpty else { return }\n        let canonical = Self.canonicalPath(s.cwd)\n        let candidates = pendingAssignIntents.filter { intent in\n            guard canonical == intent.expectedCwd else { return false }\n            let windowStart = intent.t0.addingTimeInterval(-2)\n            let windowEnd = intent.t0.addingTimeInterval(60)\n            return s.startedAt >= windowStart && s.startedAt <= windowEnd\n        }\n        guard !candidates.isEmpty else { return }\n        struct Scored {\n            let intent: PendingAssignIntent\n            let score: Int\n            let timeAbs: TimeInterval\n        }\n        var scored: [Scored] = []\n        for it in candidates {\n            var score = 0\n            if let m = it.hints.model, let sm = s.model, !m.isEmpty, m == sm { score += 1 }\n            if let a = it.hints.approval, let sa = s.approvalPolicy, !a.isEmpty, a == sa {\n                score += 1\n            }\n            let timeAbs = abs(s.startedAt.timeIntervalSince(it.t0))\n            scored.append(Scored(intent: it, score: score, timeAbs: timeAbs))\n        }\n        guard\n            let best = scored.max(by: { lhs, rhs in\n                if lhs.score != rhs.score { return lhs.score < rhs.score }\n                return lhs.timeAbs > rhs.timeAbs\n            })\n        else { return }\n        let topScore = best.score\n        let topTime = best.timeAbs\n        let dupCount = scored.filter { $0.score == topScore && abs($0.timeAbs - topTime) < 0.001 }\n            .count\n        if dupCount > 1 {\n            Task {\n                await SystemNotifier.shared.notify(\n                    title: \"CodMate\", body: \"Assign to \\(best.intent.projectId)?\")\n            }\n            return\n        }\n        Task {\n            let assignment = SessionAssignment(id: s.id, source: s.source.projectSource)\n            await projectsStore.assign(sessions: [assignment], to: best.intent.projectId)\n            let counts = await projectsStore.counts()\n            let memberships = await projectsStore.membershipsSnapshot()\n            await MainActor.run {\n                self.projectCounts = counts\n                self.setProjectMemberships(memberships)\n                self.recomputeProjectCounts()\n                self.scheduleApplyFilters()\n            }\n            await SystemNotifier.shared.notify(\n                title: \"CodMate\", body: \"Assigned to \\(best.intent.projectId)\")\n        }\n        pendingAssignIntents.removeAll { $0.id == best.intent.id }\n    }\n\n    func pruneExpiredIntents() {\n        let now = Date()\n        pendingAssignIntents.removeAll { now.timeIntervalSince($0.t0) > 60 }\n        // Reschedule cleanup if intents remain\n        if !pendingAssignIntents.isEmpty {\n            scheduleIntentsCleanupIfNeeded()\n        }\n    }\n\n    func recordIntent(\n        projectId: String, expectedCwd: String, hints: PendingAssignIntent.Hints\n    ) {\n        if !preferences.autoAssignNewToSameProject { return }\n        let canonical = Self.canonicalPath(expectedCwd)\n        pendingAssignIntents.append(\n            PendingAssignIntent(\n                projectId: projectId,\n                expectedCwd: canonical,\n                t0: Date(),\n                hints: hints\n            ))\n        pruneExpiredIntents()\n        // Schedule cleanup for new intent\n        scheduleIntentsCleanupIfNeeded()\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+Notes.swift",
    "content": "import Foundation\nimport AppKit\nimport OSLog\n\n@MainActor\nextension SessionListViewModel {\n\n    private static let log = Logger(subsystem: \"ai.umate.codmate\", category: \"SessionSummaryGen\")\n\n    // MARK: - Title and Comment Generation\n\n    /// Generates title and comment for a session using LLM\n    /// - Parameters:\n    ///   - session: The session to generate for\n    ///   - force: If true, skip the confirmation dialog when existing content is present\n    func generateTitleAndComment(for session: SessionSummary, force: Bool = false) async {\n        Self.log.info(\"Starting generation for session \\(session.id, privacy: .public)\")\n        let statusToken = StatusBarLogStore.shared.beginTask(\n            \"Generating title & comment...\",\n            level: .info,\n            source: \"Session\"\n        )\n        var finalStatus: (message: String, level: StatusBarLogLevel)?\n        defer {\n            if let finalStatus {\n                StatusBarLogStore.shared.endTask(\n                    statusToken,\n                    message: finalStatus.message,\n                    level: finalStatus.level,\n                    source: \"Session\"\n                )\n            } else {\n                StatusBarLogStore.shared.endTask(statusToken)\n            }\n        }\n\n        // Check if there's existing content and we should confirm\n        if !force {\n            // Only show confirmation if there's actual non-empty content\n            let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n            let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n            let hasExisting = !title.isEmpty || !comment.isEmpty\n\n            if hasExisting {\n                Self.log.info(\"Session has existing content, showing confirmation dialog\")\n                let shouldProceed = await showOverwriteConfirmation()\n                if !shouldProceed {\n                    Self.log.info(\"User cancelled generation\")\n                    finalStatus = (\"Generation cancelled\", .warning)\n                    return\n                }\n                Self.log.info(\"User confirmed, proceeding with generation\")\n            }\n        }\n\n        // Set generating state\n        isGeneratingTitleComment = true\n        generatingSessionId = session.id\n        defer {\n            isGeneratingTitleComment = false\n            generatingSessionId = nil\n        }\n\n        do {\n            // Load turns using existing timeline infrastructure\n            Self.log.info(\"Loading conversation turns from \\(session.fileURL.path, privacy: .public)\")\n            Self.log.info(\"Session source: \\(String(describing: session.source), privacy: .public)\")\n\n            let turns = await self.timeline(for: session)\n            Self.log.info(\"Loaded \\(turns.count) turns\")\n\n            if turns.isEmpty {\n                Self.log.warning(\"No conversation turns found\")\n                await showGenerationError(\"No conversation data found in session.\")\n                finalStatus = (\"No conversation data found\", .warning)\n                return\n            }\n\n            // Build material using intelligent truncation and summarization\n            // Run in background thread to avoid blocking UI\n            Self.log.info(\"Building conversation material\")\n\n            let material = await Task.detached {\n                SessionSummaryMaterialBuilder.build(turns: turns)\n            }.value\n\n            Self.log.info(\"Material size: \\(material.utf8.count) bytes\")\n\n            // Build prompt\n            let prompt = Self.titleCommentPrompt(material: material)\n            Self.log.info(\"Prompt size: \\(prompt.utf8.count) bytes\")\n\n            // Call LLM\n            Self.log.info(\"Calling LLM API\")\n            let llm = LLMHTTPService()\n            var options = LLMHTTPService.Options()\n            options.preferred = .auto\n\n            // Reuse commit message configuration for now\n            if let providerId = UserDefaults.standard.string(forKey: \"git.review.commitProviderId\"), !providerId.isEmpty {\n                options.providerId = providerId\n                Self.log.info(\"Using provider: \\(providerId, privacy: .public)\")\n            }\n            if let modelId = UserDefaults.standard.string(forKey: \"git.review.commitModelId\"), !modelId.isEmpty {\n                options.model = modelId\n                Self.log.info(\"Using model: \\(modelId, privacy: .public)\")\n            }\n\n            options.timeout = 45\n            options.maxTokens = 500\n            options.systemPrompt = \"Return only the JSON object. No labels, explanations, or extra commentary.\"\n\n            let res = try await llm.generateText(prompt: prompt, options: options)\n            Self.log.info(\"LLM responded in \\(res.elapsedMs)ms from provider \\(res.providerId, privacy: .public)\")\n\n            let raw = res.text.trimmingCharacters(in: .whitespacesAndNewlines)\n            Self.log.info(\"Raw response: \\(raw, privacy: .public)\")\n\n            // Parse JSON response\n            guard let result = Self.parseTitleCommentResponse(raw) else {\n                Self.log.error(\"Failed to parse JSON response\")\n                await showGenerationError(\"Failed to parse response from LLM. Response: \\(raw)\")\n                finalStatus = (\"Failed to parse LLM response\", .error)\n                return\n            }\n\n            Self.log.info(\"Parsed title: \\(result.title, privacy: .public)\")\n            Self.log.info(\"Parsed comment: \\(result.comment, privacy: .public)\")\n\n            // Update edit fields\n            await MainActor.run {\n                // If we're already editing this session, just update the fields\n                if editingSession?.id == session.id {\n                    Self.log.info(\"Updating existing edit dialog\")\n                    if !result.title.isEmpty {\n                        editTitle = result.title\n                    }\n                    if !result.comment.isEmpty {\n                        editComment = result.comment\n                    }\n                } else {\n                    // Otherwise, open the edit dialog with the generated content\n                    Self.log.info(\"Opening new edit dialog\")\n                    editingSession = session\n                    editTitle = result.title\n                    editComment = result.comment\n                }\n            }\n\n            Self.log.info(\"Generation completed successfully\")\n            if preferences.titleCommentNotificationsEnabled {\n                await SystemNotifier.shared.notify(\n                    title: \"Session Summary\",\n                    body: \"Generated title and comment in \\(res.elapsedMs)ms\",\n                    threadId: \"session-summary\"\n                )\n            }\n            finalStatus = (\"Title & comment ready\", .success)\n\n        } catch {\n            Self.log.error(\"Generation error: \\(error.localizedDescription, privacy: .public)\")\n            await showGenerationError(\"Generation failed: \\(error.localizedDescription)\")\n            finalStatus = (\"Generation failed: \\(error.localizedDescription)\", .error)\n        }\n    }\n\n    private func showGenerationError(_ message: String) async {\n        Self.log.error(\"Showing error: \\(message, privacy: .public)\")\n        if preferences.titleCommentNotificationsEnabled {\n            await SystemNotifier.shared.notify(\n                title: \"Session Summary Error\",\n                body: message,\n                threadId: \"session-summary\"\n            )\n        }\n    }\n\n    private func showOverwriteConfirmation() async -> Bool {\n        // Use NSAlert for confirmation\n        return await withCheckedContinuation { continuation in\n            DispatchQueue.main.async {\n                let alert = NSAlert()\n                alert.messageText = \"Overwrite Existing Content?\"\n                alert.informativeText = \"This session already has a title or comment. Do you want to generate new ones?\"\n                alert.addButton(withTitle: \"Generate\")\n                alert.addButton(withTitle: \"Cancel\")\n                alert.alertStyle = .warning\n\n                let response = alert.runModal()\n                continuation.resume(returning: response == .alertFirstButtonReturn)\n            }\n        }\n    }\n\n    // MARK: - Prompt Building\n\n    private static func titleCommentPrompt(material: String) -> String {\n        let basePrompt: String\n        if let payload = Self.payloadTitleCommentPrompt {\n            basePrompt = payload\n        } else {\n            basePrompt = \"\"\"\n            Generate a concise title and descriptive comment for this conversation.\n            Return a JSON object with \"title\" and \"comment\" fields.\n            Title should be 3-8 words. Comment should be 1-3 sentences.\n            \"\"\"\n        }\n        return [basePrompt, \"\", material].joined(separator: \"\\n\")\n    }\n\n    private static let payloadTitleCommentPrompt: String? = {\n        let bundle = Bundle.main\n        guard let url = bundle.url(forResource: \"title-and-comment\", withExtension: \"md\", subdirectory: \"payload/prompts\") else {\n            return nil\n        }\n        guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n        let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? nil : trimmed\n    }()\n\n    // MARK: - Response Parsing\n\n    private static func parseTitleCommentResponse(_ raw: String) -> (title: String, comment: String)? {\n        var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n\n        // Remove code fences if present\n        if cleaned.hasPrefix(\"```\") {\n            // Remove opening fence (```json or just ```)\n            if let firstNewline = cleaned.firstIndex(of: \"\\n\") {\n                cleaned = String(cleaned[cleaned.index(after: firstNewline)...])\n            }\n            // Remove closing fence\n            if let lastFence = cleaned.range(of: \"```\", options: .backwards) {\n                cleaned = String(cleaned[..<lastFence.lowerBound])\n            }\n            cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)\n        }\n\n        // Try to parse JSON\n        guard let data = cleaned.data(using: .utf8) else { return nil }\n\n        do {\n            if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],\n               let title = json[\"title\"] as? String,\n               let comment = json[\"comment\"] as? String {\n                return (title: title.trimmingCharacters(in: .whitespacesAndNewlines),\n                        comment: comment.trimmingCharacters(in: .whitespacesAndNewlines))\n            }\n        } catch {\n            return nil\n        }\n\n        return nil\n    }\n\n    // MARK: - Existing Methods\n    func timelineVisibleKindsOverride(for sessionId: String) -> Set<MessageVisibilityKind>? {\n        let raw = notesSnapshot[sessionId]?.timelineVisibleKinds\n        guard var set = Set<MessageVisibilityKind>.fromRawValues(raw) else { return nil }\n        set.remove(.environmentContext)\n        if set.contains(.tool) { set.insert(.codeEdit) }\n        return set\n    }\n\n    func updateTimelineVisibleKindsOverride(\n        for sessionId: String,\n        kinds: Set<MessageVisibilityKind>?\n    ) async {\n        let raw = kinds?.rawValues\n        await notesStore.updateTimelineVisibleKinds(id: sessionId, kinds: raw)\n        if let updatedNote = await notesStore.note(for: sessionId) {\n            notesSnapshot[sessionId] = updatedNote\n        }\n    }\n\n    func clearTimelineVisibleKindsOverride(for sessionId: String) async {\n        await updateTimelineVisibleKindsOverride(for: sessionId, kinds: nil)\n    }\n\n    func beginEditing(session: SessionSummary) async {\n        editingSession = session\n        if let note = await notesStore.note(for: session.id) {\n            editTitle = note.title ?? \"\"\n            editComment = note.comment ?? \"\"\n        } else {\n            editTitle = session.userTitle ?? \"\"\n            editComment = session.userComment ?? \"\"\n        }\n    }\n\n    func saveEdits() async {\n        guard let session = editingSession else { return }\n        let titleValue = editTitle.isEmpty ? nil : editTitle\n        let commentValue = editComment.isEmpty ? nil : editComment\n        await notesStore.upsert(id: session.id, title: titleValue, comment: commentValue)\n\n        // Reload the complete note from store to ensure cache consistency\n        // (preserves projectId, profileId and other fields managed by notesStore)\n        if let updatedNote = await notesStore.note(for: session.id) {\n            notesSnapshot[session.id] = updatedNote\n        }\n\n        await indexer.updateUserMetadata(sessionId: session.id, title: titleValue, comment: commentValue)\n\n        // Update the session in place to preserve sorting and trigger didSet observer\n        allSessions = allSessions.map { s in\n            guard s.id == session.id else { return s }\n            var updated = s\n            updated.userTitle = titleValue\n            updated.userComment = commentValue\n            return updated\n        }\n        await autoAssignSessionAfterEditIfNeeded(session)\n        scheduleApplyFilters()\n        cancelEdits()\n    }\n\n    func cancelEdits() {\n        editingSession = nil\n        editTitle = \"\"\n        editComment = \"\"\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+Projects.swift",
    "content": "import Foundation\nimport OSLog\n\n@MainActor\nextension SessionListViewModel {\n    private static let projectLogger = Logger(subsystem: \"io.umate.codmate\", category: \"SessionListVM.ProjectCounts\")\n    static let otherProjectId = \"__other__\"\n    func loadProjects() async {\n        var list = await projectsStore.listProjects()\n        if list.isEmpty {\n            let cfg = await configService.listProjects()\n            if !cfg.isEmpty {\n                for p in cfg { await projectsStore.upsertProject(p) }\n                list = await projectsStore.listProjects()\n            }\n        }\n        let counts = await projectsStore.counts()\n        let memberships = await projectsStore.membershipsSnapshot()\n        await MainActor.run {\n            self.projects = list\n            if self.preferences.isCLIEnabled(.gemini) {\n                self.rebuildGeminiProjectHashLookup()\n            }\n            self.projectStructureVersion &+= 1\n            self.projectCounts = counts\n            self.setProjectMemberships(memberships)\n            self.recomputeProjectCounts()\n            self.invalidateProjectVisibleCountsCache()\n            self.scheduleApplyFilters()\n        }\n        if preferences.isCLIEnabled(.gemini) {\n            await geminiProvider.invalidateProjectMappings()\n        }\n    }\n\n    func setSelectedProject(_ id: String?) {\n        if let id {\n            selectedProjectIDs = Set([id])\n\n            // Special behavior for the synthetic Others bucket:\n            // when there is no active date filter yet, clicking\n            // Others focuses on \"today\" without changing the\n            // Created/Last Updated picker. Independently, we fire\n            // a targeted incremental refresh for today across\n            // Claude and Gemini so newly created/updated sessions\n            // appear under Others quickly.\n            if id == Self.otherProjectId {\n                if selectedDay == nil, selectedDays.isEmpty {\n                    setSelectedDay(Date())\n                }\n                Task { [weak self] in\n                    guard let self else { return }\n                    async let codex: Void = self.refreshIncrementalForNewCodexToday()\n                    async let claude: Void = self.refreshIncrementalForClaudeToday()\n                    async let gemini: Void = self.refreshIncrementalForGeminiToday()\n                    _ = await (codex, claude, gemini)\n                }\n            }\n        } else {\n            selectedProjectIDs.removeAll()\n        }\n    }\n\n    func setSelectedProjects(_ ids: Set<String>) {\n        selectedProjectIDs = ids\n    }\n\n    func toggleProjectSelection(_ id: String) {\n        if selectedProjectIDs.contains(id) {\n            selectedProjectIDs.remove(id)\n        } else {\n            selectedProjectIDs.insert(id)\n        }\n    }\n\n    func assignSessions(to projectId: String?, ids: [String]) async {\n        let assignments = ids.compactMap { sessionAssignment(forIdentifier: $0) }\n        guard !assignments.isEmpty else { return }\n        await projectsStore.assign(sessions: assignments, to: projectId)\n        let counts = await projectsStore.counts()\n        let memberships = await projectsStore.membershipsSnapshot()\n        await MainActor.run {\n            self.projectCounts = counts\n            self.setProjectMemberships(memberships)\n            self.recomputeProjectCounts()\n            self.scheduleApplyFilters()\n        }\n    }\n\n    func autoAssignSessionAfterEditIfNeeded(_ session: SessionSummary) async {\n        guard projectIdForSession(session.id) == nil else { return }\n        guard let bestProjectId = bestMatchingProjectId(for: session) else { return }\n        await assignSessions(to: bestProjectId, ids: [session.id])\n    }\n\n    func bestMatchingProjectId(for session: SessionSummary) -> String? {\n        let projectDirs: [(id: String, path: String)] = projects.compactMap { project in\n            guard let raw = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines),\n                  !raw.isEmpty\n            else { return nil }\n            let normalized = URL(fileURLWithPath: raw).standardizedFileURL.path\n            let slash = normalized.hasSuffix(\"/\") ? normalized : normalized + \"/\"\n            return (project.id, slash)\n        }\n        guard !projectDirs.isEmpty else { return nil }\n\n        let rawPath = session.cwd.trimmingCharacters(in: .whitespacesAndNewlines)\n        let cwd = rawPath.isEmpty\n          ? session.fileURL.deletingLastPathComponent().path\n          : rawPath\n        let normalized = URL(fileURLWithPath: cwd).standardizedFileURL.path\n        let sessionPath = normalized.hasSuffix(\"/\") ? normalized : normalized + \"/\"\n\n        let matchingProjects = projectDirs.filter { candidate in\n            sessionPath.hasPrefix(candidate.path)\n        }\n        return matchingProjects.max(by: { lhs, rhs in\n            lhs.path.count < rhs.path.count\n        })?.id\n    }\n\n    func projectCountsFromStore() -> [String: Int] { projectCounts }\n\n    func visibleProjectCountsForDateScope() -> [String: Int] {\n        let key = ProjectVisibleKey(\n            dimension: dateDimension,\n            selectedDay: selectedDay,\n            selectedDays: selectedDays,\n            sessionCount: allSessions.count,\n            membershipVersion: projectMembershipsVersion\n        )\n        if let cached = cachedProjectVisibleCounts, cached.key == key {\n            return cached.value\n        }\n        var visible: [String: Int] = [:]\n        let allowed = projects.reduce(into: [String: Set<ProjectSessionSource>]()) {\n            $0[$1.id] = $1.sources\n        }\n        let descriptors = Self.makeDayDescriptors(selectedDays: selectedDays, singleDay: selectedDay)\n        let filterByDay = !descriptors.isEmpty\n\n        var other = 0\n        for session in allSessions {\n            if filterByDay && !matchesDayFilters(session, descriptors: descriptors) {\n                continue\n            }\n            if let pid = projectId(for: session) {\n                let allowedSources = allowed[pid] ?? ProjectSessionSource.allSet\n                if !allowedSources.contains(session.source.projectSource) { continue }\n                visible[pid, default: 0] += 1\n            } else {\n                other += 1\n            }\n        }\n        if other > 0 { visible[Self.otherProjectId] = other }\n        cachedProjectVisibleCounts = (key, visible)\n        return visible\n    }\n\n    func projectCountsDisplay() -> [String: (visible: Int, total: Int)] {\n        var directVisible = visibleProjectCountsForDateScope()\n        let directTotal = projectCounts\n\n        // Cold-start smoothing: when sessions尚未加载、visible为空但总数已知，先用总数填充，避免出现 “N/0” 闪烁\n        if directVisible.isEmpty, isLoading {\n            for (k, v) in directTotal {\n                directVisible[k] = v\n            }\n            Self.projectLogger.log(\"projectCountsDisplay smoothing with totals only count=\\(directTotal.values.reduce(0, +), privacy: .public)\")\n        }\n\n        // Build cache key\n        let visibleKey = ProjectVisibleKey(\n            dimension: dateDimension,\n            selectedDay: selectedDay,\n            selectedDays: selectedDays,\n            sessionCount: allSessions.count,\n            membershipVersion: projectMembershipsVersion\n        )\n        let totalCountsHash = directTotal.values.reduce(0) { $0 ^ $1 }\n        let cacheKey = ProjectAggregatedKey(\n            visibleKey: visibleKey,\n            totalCountsHash: totalCountsHash,\n            structureVersion: projectStructureVersion\n        )\n\n        // Check cache\n        if let cached = cachedProjectAggregated, cached.key == cacheKey {\n            return cached.value\n        }\n\n        // Cache miss - compute aggregated counts\n        var children: [String: [String]] = [:]\n        for p in projects {\n            if let parent = p.parentId { children[parent, default: []].append(p.id) }\n        }\n        func aggregate(for id: String, using map: inout [String: (Int, Int)]) -> (Int, Int) {\n            if let cached = map[id] { return cached }\n            var v = directVisible[id] ?? 0\n            var t = directTotal[id] ?? 0\n            for c in (children[id] ?? []) {\n                let (cv, ct) = aggregate(for: c, using: &map)\n                v += cv\n                t += ct\n            }\n            map[id] = (v, t)\n            return (v, t)\n        }\n        var memo: [String: (Int, Int)] = [:]\n        var out: [String: (visible: Int, total: Int)] = [:]\n        for p in projects {\n            let (v, t) = aggregate(for: p.id, using: &memo)\n            out[p.id] = (v, t)\n        }\n        // Add synthetic Other bucket\n        let otherVisible = directVisible[Self.otherProjectId] ?? 0\n        let otherTotal = directTotal[Self.otherProjectId] ?? otherVisible\n        if otherVisible > 0 || otherTotal > 0 {\n            out[Self.otherProjectId] = (otherVisible, otherTotal)\n        }\n\n        // Cache the result\n        cachedProjectAggregated = (cacheKey, out)\n        return out\n    }\n\n    func visibleAllCountForDateScope() -> Int {\n        let key = SessionListViewModel.VisibleCountKey(\n            dimension: dateDimension,\n            selectedDay: selectedDay,\n            selectedDays: selectedDays,\n            sessionCount: allSessions.count\n        )\n        if let cached = cachedVisibleCount, cached.key == key {\n            return cached.value\n        }\n\n        // Cold-start: if sessions尚未加载但缓存覆盖度可用，直接返回缓存总数，避免 0 闪烁。\n        if allSessions.isEmpty {\n            if let coverage = cacheCoverage {\n                cachedVisibleCount = (key, coverage.sessionCount)\n                Self.projectLogger.log(\"visibleAllCount use coverage sessionCount=\\(coverage.sessionCount, privacy: .public)\")\n                return coverage.sessionCount\n            }\n            if let meta = indexMeta {\n                cachedVisibleCount = (key, meta.sessionCount)\n                Self.projectLogger.log(\"visibleAllCount use meta sessionCount=\\(meta.sessionCount, privacy: .public)\")\n                return meta.sessionCount\n            }\n            Self.projectLogger.log(\"visibleAllCount no cache available, default 0\")\n        }\n\n        let descriptors = Self.makeDayDescriptors(selectedDays: selectedDays, singleDay: selectedDay)\n        let value: Int\n        if descriptors.isEmpty {\n            value = allSessions.count\n        } else {\n            value = allSessions.filter { matchesDayFilters($0, descriptors: descriptors) }.count\n        }\n        cachedVisibleCount = (key, value)\n        Self.projectLogger.log(\"visibleAllCount computed from sessions count=\\(value, privacy: .public) descriptors=\\(descriptors.count, privacy: .public)\")\n        return value\n    }\n\n    // Calendar helper: days within the given month that have at least one session\n    // belonging to any of the currently selected projects (including descendants), respecting\n    // each project's allowed sources. Returns nil when no project is selected.\n    func calendarEnabledDaysForSelectedProject(monthStart: Date, dimension: DateDimension) -> Set<Int>? {\n        guard !selectedProjectIDs.isEmpty else { return nil }\n        let monthKey = monthKey(for: monthStart)\n\n        // Build allowed project set: include descendants of each selected project\n        var allowedProjects = Set<String>()\n        for pid in selectedProjectIDs {\n            allowedProjects.insert(pid)\n            allowedProjects.formUnion(collectDescendants(of: pid, in: projects))\n        }\n\n        // Resolve allowed sources per project\n        let allowedSourcesByProject = projects.reduce(into: [String: Set<ProjectSessionSource>]()) {\n            $0[$1.id] = $1.sources\n        }\n\n        var days: Set<Int> = []\n        for session in allSessions {\n            if let assigned = projectId(for: session) {\n                guard allowedProjects.contains(assigned) else { continue }\n                let allowed = allowedSourcesByProject[assigned] ?? ProjectSessionSource.allSet\n                if !allowed.contains(session.source.projectSource) { continue }\n            } else {\n                // Include unassigned only when Other is selected\n                guard allowedProjects.contains(Self.otherProjectId) else { continue }\n            }\n            let bucket = dayIndex(for: session)\n            switch dimension {\n            case .created:\n                guard bucket.createdMonthKey == monthKey else { continue }\n                days.insert(bucket.createdDay)\n            case .updated:\n                let coverageKey = SessionMonthCoverageKey(sessionID: session.id, monthKey: monthKey)\n                if let covered = updatedMonthCoverage[coverageKey], !covered.isEmpty {\n                    days.formUnion(covered)\n                } else if bucket.updatedMonthKey == monthKey {\n                    days.insert(bucket.updatedDay)\n                }\n            }\n        }\n        return days\n    }\n\n    func allSessionsInSameProject(as anchor: SessionSummary) -> [SessionSummary] {\n        if let pid = projectId(for: anchor) {\n            let allowed = projects.first(where: { $0.id == pid })?.sources ?? ProjectSessionSource.allSet\n            return allSessions.filter {\n                projectId(for: $0) == pid && allowed.contains($0.source.projectSource)\n            }\n        }\n        return allSessions\n    }\n\n    func createOrUpdateProject(_ project: Project) async {\n        await projectsStore.upsertProject(project)\n        await loadProjects()\n    }\n\n    func deleteProject(id: String) async {\n        await projectsStore.deleteProject(id: id)\n        await loadProjects()\n        if selectedProjectIDs.contains(id) {\n            selectedProjectIDs.remove(id)\n        }\n        scheduleApplyFilters()\n    }\n\n    func deleteProjectCascade(id: String) async {\n        let list = await projectsStore.listProjects()\n        let ids = collectDescendants(of: id, in: list) + [id]\n        for pid in ids { await projectsStore.deleteProject(id: pid) }\n        await loadProjects()\n        if !selectedProjectIDs.isDisjoint(with: ids) {\n            selectedProjectIDs.subtract(ids)\n        }\n        scheduleApplyFilters()\n    }\n\n    func deleteProjectMoveChildrenUp(id: String) async {\n        let list = await projectsStore.listProjects()\n        for p in list where p.parentId == id {\n            var moved = p\n            moved.parentId = nil\n            await projectsStore.upsertProject(moved)\n        }\n        await projectsStore.deleteProject(id: id)\n        await loadProjects()\n        if selectedProjectIDs.contains(id) {\n            selectedProjectIDs.remove(id)\n        }\n        scheduleApplyFilters()\n    }\n\n    func changeProjectParent(projectId: String, newParentId: String?) async {\n        // Don't allow changing the Other synthetic project\n        guard projectId != Self.otherProjectId else { return }\n        // Don't allow setting Other as a parent\n        guard newParentId != Self.otherProjectId else { return }\n\n        let list = await projectsStore.listProjects()\n        guard let project = list.first(where: { $0.id == projectId }) else { return }\n\n        // No-op if already has the same parent\n        if project.parentId == newParentId { return }\n\n        // Prevent circular dependency: can't make a project its own parent or descendant\n        if let newParent = newParentId {\n            if newParent == projectId { return }\n            let descendants = collectDescendants(of: projectId, in: list)\n            if descendants.contains(newParent) { return }\n        }\n\n        var updated = project\n        updated.parentId = newParentId\n        await projectsStore.upsertProject(updated)\n        await loadProjects()\n    }\n\n    func collectDescendants(of id: String, in list: [Project]) -> [String] {\n        var result: [String] = []\n        func dfs(_ pid: String) {\n            for p in list where p.parentId == pid {\n                result.append(p.id)\n                dfs(p.id)\n            }\n        }\n        dfs(id)\n        return result\n    }\n\n    func importMembershipsFromNotesIfNeeded(notes: [String: SessionNote]) async {\n        let existing = await projectsStore.membershipsSnapshot()\n        if !existing.isEmpty { return }\n        var buckets: [String: [SessionAssignment]] = [:]\n        for (sid, n) in notes {\n            guard let pid = n.projectId else { continue }\n            guard let assignment = sessionAssignment(forIdentifier: sid) else { continue }\n            buckets[pid, default: []].append(assignment)\n        }\n        guard !buckets.isEmpty else { return }\n        for (pid, entries) in buckets { await projectsStore.assign(sessions: entries, to: pid) }\n        let counts = await projectsStore.counts()\n        let memberships = await projectsStore.membershipsSnapshot()\n        await MainActor.run {\n            self.projectCounts = counts\n            self.setProjectMemberships(memberships)\n            self.recomputeProjectCounts()\n        }\n    }\n\n    @MainActor\n    func recomputeProjectCounts() {\n        if selectedDay != nil || !selectedDays.isEmpty {\n            return\n        }\n        // Optimize: use visibleProjectCountsForDateScope if it's for current filter state\n        // to avoid re-traversing all sessions\n        let currentKey = ProjectVisibleKey(\n            dimension: dateDimension,\n            selectedDay: selectedDay,\n            selectedDays: selectedDays,\n            sessionCount: allSessions.count,\n            membershipVersion: projectMembershipsVersion\n        )\n\n        // If we have cached visible counts for current state, reuse them as total counts\n        // (when no date filter is active)\n        if selectedDay == nil && selectedDays.isEmpty,\n           let cached = cachedProjectVisibleCounts, cached.key == currentKey {\n            projectCounts = cached.value\n            return\n        }\n\n        // Otherwise compute from scratch\n        var counts: [String: Int] = [:]\n        var other = 0\n        let allowed = projects.reduce(into: [String: Set<ProjectSessionSource>]()) {\n            $0[$1.id] = $1.sources\n        }\n        for session in allSessions {\n            if let pid = projectId(for: session) {\n                let allowedSources = allowed[pid] ?? ProjectSessionSource.allSet\n                if allowedSources.contains(session.source.projectSource) {\n                    counts[pid, default: 0] += 1\n                }\n            } else {\n                other += 1\n            }\n        }\n        if other > 0 { counts[Self.otherProjectId] = other }\n        projectCounts = counts\n    }\n\n    func requestProjectExpansion(for projectId: String) {\n        let chain = projectAncestorChain(projectId: projectId)\n        guard !chain.isEmpty else { return }\n        NotificationCenter.default.post(\n            name: .codMateExpandProjectTree,\n            object: nil,\n            userInfo: [\"ids\": chain]\n        )\n    }\n\n    private func projectAncestorChain(projectId: String) -> [String] {\n        guard !projects.isEmpty else { return [] }\n        var map: [String: Project] = [:]\n        for p in projects { map[p.id] = p }\n        var chain: [String] = []\n        var current: String? = projectId\n        while let id = current, let project = map[id] {\n            chain.insert(project.id, at: 0)\n            current = project.parentId\n        }\n        return chain\n    }\n}\n"
  },
  {
    "path": "models/SessionListViewModel+SearchSupport.swift",
    "content": "import Foundation\n\n@MainActor\nextension SessionListViewModel {\n  func sessionsSnapshot() -> [SessionSummary] { allSessions }\n\n  func sessionSummary(withId id: String) -> SessionSummary? {\n    allSessions.first { $0.id == id }\n  }\n\n  func sessionSummary(forFileURL url: URL) -> SessionSummary? {\n    allSessions.first { $0.fileURL == url }\n  }\n}\n"
  },
  {
    "path": "models/SessionListViewModel.swift",
    "content": "import AppKit\nimport Combine\nimport CryptoKit\nimport Foundation\nimport OSLog\n\n#if canImport(Darwin)\n  import Darwin\n#endif\n\n@MainActor\nfinal class SessionListViewModel: ObservableObject {\n  @Published var sections: [SessionDaySection] = []\n  @Published var searchText: String = \"\" {\n    didSet { scheduleFulltextSearchIfNeeded() }\n  }\n  @Published var sortOrder: SessionSortOrder = .mostRecent {\n    didSet { scheduleFiltersUpdate() }\n  }\n  @Published var isLoading = false\n  @Published var isEnriching = false\n  @Published var enrichmentProgress: Int = 0\n  @Published var enrichmentTotal: Int = 0\n  @Published var errorMessage: String?\n\n  // Title/Comment quick search for the middle list only\n  @Published var quickSearchText: String = \"\" {\n    didSet { scheduleFiltersUpdate() }\n  }\n\n  // New filter state: supports combined filters\n  @Published var selectedPath: String? = nil {\n    didSet {\n      guard !suppressFilterNotifications, oldValue != selectedPath else { return }\n      // Path filtering works on already-loaded sessions (filters by cwd field),\n      // so we don't need to refresh files from disk - just reapply filters\n      scheduleFiltersUpdate()\n    }\n  }\n  @Published var selectedDay: Date? = nil {\n    didSet {\n      guard !suppressFilterNotifications, oldValue != selectedDay else { return }\n      invalidateVisibleCountCache()\n      scheduleSelectionDrivenUpdate()\n      windowStateStore.saveCalendarSelection(\n        selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    }\n  }\n  @Published var dateDimension: DateDimension = .updated {\n    didSet {\n      guard !suppressFilterNotifications, oldValue != dateDimension else { return }\n      invalidateVisibleCountCache()\n      invalidateCalendarCaches()\n      enrichmentSnapshots.removeAll()\n      if dateDimension == .updated {\n        for day in selectedDays {\n          requestCoverageIfNeeded(for: day)\n        }\n        if let day = selectedDay {\n          requestCoverageIfNeeded(for: day)\n        }\n      }\n      scheduleFiltersUpdate()\n      scheduleFilterRefresh(force: true)\n    }\n  }\n  // Multiple day selection support (normalized to startOfDay)\n  @Published var selectedDays: Set<Date> = [] {\n    didSet {\n      guard !suppressFilterNotifications else { return }\n      invalidateVisibleCountCache()\n      scheduleSelectionDrivenUpdate()\n      windowStateStore.saveCalendarSelection(\n        selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    }\n  }\n  @Published var sidebarMonthStart: Date = SessionListViewModel.normalizeMonthStart(Date()) {\n    didSet {\n      guard !suppressFilterNotifications, oldValue != sidebarMonthStart else { return }\n      windowStateStore.saveCalendarSelection(\n        selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    }\n  }\n  // Track current list selection for targeted refreshes\n  @Published var selectedSessionIDs: Set<SessionSummary.ID> = []\n  private var cacheUnavailableLastError: Date?\n  private let cacheUnavailableCooldown: TimeInterval = 5.0\n\n  private func markCacheUnavailableNow() {\n    cacheUnavailableLastError = Date()\n  }\n\n  private func clearCacheUnavailable() {\n    cacheUnavailableLastError = nil\n  }\n\n  private func shouldSkipForCacheUnavailable() -> Bool {\n    guard let last = cacheUnavailableLastError else { return false }\n    return Date().timeIntervalSince(last) < cacheUnavailableCooldown\n  }\n\n  let preferences: SessionPreferencesStore\n  private var sessionsRoot: URL { preferences.sessionsRoot }\n\n  internal let indexer: SessionIndexer\n  let actions: SessionActions\n  var allSessions: [SessionSummary] = [] {\n    didSet {\n      sessionsVersion &+= 1\n      invalidateVisibleCountCache()\n      invalidateCalendarCaches()\n      pruneDayCache()\n      pruneCoverageCache()\n      for session in allSessions {\n        _ = dayIndex(for: session)\n      }\n      // Incremental path tree update based on session cwd diffs\n      let newCounts = cwdCounts(for: allSessions)\n      let oldCounts = lastPathCounts\n      lastPathCounts = newCounts\n      pathTreeRefreshTask?.cancel()\n      let delta = diffCounts(old: oldCounts, new: newCounts)\n      if !delta.isEmpty {\n        Task { [weak self] in\n          guard let self else { return }\n          if let updated = await self.pathTreeStore.applyDelta(delta) {\n            await MainActor.run { self.pathTreeRootPublished = updated }\n          } else {\n            // Fallback to full snapshot rebuild when prefix changes or structure requires it\n            let rebuilt = await self.pathTreeStore.applySnapshot(counts: newCounts)\n            await MainActor.run { self.pathTreeRootPublished = rebuilt }\n          }\n        }\n      }\n      sessionLookup = Dictionary(uniqueKeysWithValues: allSessions.map { ($0.id, $0) })\n\n      // Auto-assign unassigned sessions to \"Others\" task\n      autoAssignSessionsToOthersTask()\n    }\n  }\n  private var sessionLookup: [String: SessionSummary] = [:]\n  private var sessionsVersion: UInt64 = 0\n  private var fulltextMatches: Set<String> = []  // SessionSummary.id set\n  private var fulltextTask: Task<Void, Never>?\n  private var enrichmentTask: Task<Void, Never>?\n  var notesStore: SessionNotesStore\n  var notesSnapshot: [String: SessionNote] = [:]\n  private var canonicalCwdCache: [String: String] = [:]\n  private let ripgrepStore = SessionRipgrepStore()\n  private var coverageLoadTasks: [String: Task<Void, Never>] = [:]\n  private var pendingCoverageMonths: Set<String> = []\n  private var coverageDebounceTasks: [String: Task<Void, Never>] = [:]  // Per-key debounce\n  private var selectedSessionsRefreshTask: Task<Void, Never>?\n  struct SessionDayIndex: Equatable {\n    let created: Date\n    let updated: Date\n    let createdMonthKey: String\n    let updatedMonthKey: String\n    let createdDay: Int\n    let updatedDay: Int\n  }\n  struct SessionMonthCoverageKey: Hashable, Sendable {\n    let sessionID: String\n    let monthKey: String\n  }\n  struct DaySelectionDescriptor: Hashable, Sendable {\n    let date: Date\n    let monthKey: String\n    let day: Int\n  }\n  private var sessionDayCache: [String: SessionDayIndex] = [:]\n  var updatedMonthCoverage: [SessionMonthCoverageKey: Set<Int>] = [:]\n  private var directoryMonitor: DirectoryMonitor?\n  private var claudeDirectoryMonitor: DirectoryMonitor?\n  private var claudeProjectMonitor: DirectoryMonitor?\n  private var geminiDirectoryMonitor: DirectoryMonitor?\n  private var directoryRefreshTask: Task<Void, Never>?\n  private var enrichmentSnapshots: [String: Set<String>] = [:]\n  private var suppressFilterNotifications = false\n  private var scheduledFilterRefresh: Task<Void, Never>?\n  private var filterTask: Task<Void, Never>?\n  private var filterDebounceTask: Task<Void, Never>?\n  private var filterGeneration: UInt64 = 0\n  private var pendingApplyFilters = false\n  private var lastFilterSnapshotHash: Int?\n  /// Debounce refresh triggers to avoid repeated full enumerations\n  private var refreshDebounceTask: Task<Void, Never>?\n  private var lastRefreshAt: Date?\n  private var lastRefreshScope: SessionLoadScope?\n  private let refreshCooldown: TimeInterval = 0.5\n  private var pendingRefreshForce: Bool = false\n  /// Scope-based refresh debouncing: track pending refresh by scope key to enable merging\n  private var scopedRefreshTasks: [String: Task<Void, Never>] = [:]\n  private var pendingScopeRefreshForce: [String: Bool] = [:]\n  /// Track actively executing refreshes by scope to prevent concurrent duplicates\n  private var activeScopeRefreshes: [String: UUID] = [:]\n  /// File event aggregation: collect file change events within a time window\n  private var pendingFileEvents: Set<String> = []  // file paths that changed\n  private var fileEventAggregationTask: Task<Void, Never>?\n  private var lastFileEventAt: Date = .distantPast\n  struct VisibleCountKey: Equatable {\n    var dimension: DateDimension\n    var selectedDay: Date?\n    var selectedDays: Set<Date>\n    var sessionCount: Int\n  }\n  var cachedVisibleCount: (key: VisibleCountKey, value: Int)?\n  struct ProjectVisibleKey: Equatable {\n    var dimension: DateDimension\n    var selectedDay: Date?\n    var selectedDays: Set<Date>\n    var sessionCount: Int\n    var membershipVersion: UInt64\n  }\n  var cachedProjectVisibleCounts: (key: ProjectVisibleKey, value: [String: Int])?\n  private var geminiProjectPathByHash: [String: String] = [:]\n  private var codexUsageTask: Task<Void, Never>?\n  private var claudeUsageTask: Task<Void, Never>?\n  private var geminiUsageTask: Task<Void, Never>?\n  private var pathTreeRefreshTask: Task<Void, Never>?\n  private var calendarRefreshTasks: [String: Task<Void, Never>] = [:]\n  private var cancellables = Set<AnyCancellable>()\n  private let pathTreeStore = PathTreeStore()\n  private var timelineCache: [String: TimelineCacheEntry] = [:]\n\n  private struct TimelineCacheEntry {\n    let signature: TimelineCacheSignature\n    let turns: [ConversationTurn]\n  }\n\n  private struct TimelineCacheSignature: Equatable {\n    let modifiedAt: Date?\n    let fileSize: UInt64?\n  }\n  private var lastPathCounts: [String: Int] = [:]\n  private let sidebarStatsDebounceNanoseconds: UInt64 = 150_000_000\n  private let filterDebounceNanoseconds: UInt64 = 15_000_000\n  private var cachedCalendar = Calendar.current\n  private var pendingViewUpdate = false\n  static let monthFormatter: DateFormatter = {\n    let df = DateFormatter()\n    df.dateFormat = \"yyyy-MM\"\n    return df\n  }()\n  private var currentMonthKey: String?\n  private var currentMonthDimension: DateDimension = .updated\n  // Quick pulse (cheap file mtime scan) state\n  private var quickPulseTask: Task<Void, Never>?\n  private var lastQuickPulseAt: Date = .distantPast\n  private var fileMTimeCache: [String: Date] = [:]  // session.id -> mtime\n  private var lastDisplayedDigest: Int = 0\n  @Published var editingSession: SessionSummary? = nil\n  @Published var editTitle: String = \"\"\n  @Published var editComment: String = \"\"\n  @Published var isGeneratingTitleComment: Bool = false\n  @Published var generatingSessionId: String? = nil\n  @Published var globalSessionCount: Int = 0\n  @Published private(set) var pathTreeRootPublished: PathTreeNode?\n  private var monthCountsCache: [String: [Int: Int]] = [:]  // key: \"dim|yyyy-MM\" (not @Published to avoid updates during view reads)\n  @Published private(set) var codexUsageStatus: CodexUsageStatus?\n  @Published private(set) var usageSnapshots: [UsageProviderKind: UsageProviderSnapshot] = [:]\n  private var lastUsageRefreshByProvider: [UsageProviderKind: Date] = [:]\n  private var claudeUsageAutoRefreshEnabled = false\n  private var didAutoRefreshUsage = false\n  // Live activity indicators\n  @Published private(set) var activeUpdatingIDs: Set<String> = []\n  @Published private(set) var awaitingFollowupIDs: Set<String> = []\n  // Index meta for diagnostics/UI state (full cache completion marker)\n  @Published private(set) var indexMeta: SessionIndexMeta?\n  @Published private(set) var cacheCoverage: SessionIndexCoverage?\n  private let diagLogger = Logger(subsystem: \"io.umate.codmate\", category: \"SessionListVM\")\n  private func ts() -> Double { Date().timeIntervalSince1970 }\n\n  // Persist Review (Git Changes) panel UI state per session so toggling\n  // between Conversation, Terminal and Review preserves context.\n  @Published var reviewPanelStates: [String: ReviewPanelState] = [:]\n  // Project-level Git Review panel state per project id\n  @Published var projectReviewPanelStates: [String: ReviewPanelState] = [:]\n\n  // Project workspace mode (toolbar segmented)\n  @Published var projectWorkspaceMode: ProjectWorkspaceMode = .overview\n\n  let windowStateStore = WindowStateStore()\n\n  // Project workspace view model for managing tasks\n  private(set) var workspaceVM: ProjectWorkspaceViewModel?\n\n  // Auto-assign: pending intents created when user clicks New\n  struct PendingAssignIntent: Identifiable, Sendable, Hashable {\n    let id = UUID()\n    let projectId: String\n    let expectedCwd: String  // canonical path\n    let t0: Date\n    struct Hints: Sendable, Hashable {\n      var model: String?\n      var sandbox: String?\n      var approval: String?\n    }\n    let hints: Hints\n  }\n  var pendingAssignIntents: [PendingAssignIntent] = []\n  var intentsCleanupTask: Task<Void, Never>?\n\n  // Targeted incremental refresh hint, set when user triggers New\n  struct PendingIncrementalRefreshHint {\n    enum Kind {\n      case codexDay(Date)\n      case geminiDay(Date)\n      case claudeProject(String)\n    }\n    let kind: Kind\n    let expiresAt: Date\n  }\n  private var pendingIncrementalHint: PendingIncrementalRefreshHint? = nil\n\n  // Projects\n  let configService = CodexConfigService()\n  var projectsStore: ProjectsStore\n  var tasksStore: TasksStore\n  let claudeProvider: ClaudeSessionProvider\n  let geminiProvider: GeminiSessionProvider\n  private let claudeUsageClient = ClaudeUsageAPIClient()\n  private let geminiUsageClient = GeminiUsageAPIClient()\n  private let codexAppServerProbe = CodexAppServerProbeService()\n  private let providersRegistry = ProvidersRegistryService()\n  let remoteProvider: RemoteSessionProvider\n  let sqliteStore: SessionIndexSQLiteStore\n  @Published var projects: [Project] = []\n  var projectCounts: [String: Int] = [:]\n  var projectMemberships: [String: String] = [:]\n  var projectMembershipsVersion: UInt64 = 0\n  var projectStructureVersion: UInt64 = 0  // Incremented when projects/parentIds change\n  @Published var expandedProjectIDs: Set<String> = [] {\n    didSet {\n      if oldValue != expandedProjectIDs {\n        windowStateStore.saveProjectExpansions(expandedProjectIDs)\n      }\n    }\n  }\n\n  struct ProjectAggregatedKey: Equatable {\n    var visibleKey: ProjectVisibleKey\n    var totalCountsHash: Int\n    var structureVersion: UInt64\n  }\n  var cachedProjectAggregated:\n    (key: ProjectAggregatedKey, value: [String: (visible: Int, total: Int)])?\n  @Published var selectedProjectIDs: Set<String> = [] {\n    didSet {\n      guard !suppressFilterNotifications, oldValue != selectedProjectIDs else { return }\n      if !selectedProjectIDs.isEmpty {\n        // Defer selectedPath modification to avoid \"Publishing changes from within view updates\"\n        Task { @MainActor [weak self] in\n          self?.selectedPath = nil\n        }\n      }\n      invalidateProjectVisibleCountsCache()\n      scheduleSelectionDrivenUpdate()\n      windowStateStore.saveProjectSelection(selectedProjectIDs)\n    }\n  }\n  // Sidebar → Project-level New request when using embedded terminal\n  @Published var pendingEmbeddedProjectNew: Project? = nil\n  @Published var remoteSyncStates: [String: RemoteSyncState] = [:]\n\n  private func pruneDayCache() {\n    guard !sessionDayCache.isEmpty else { return }\n    let ids = Set(allSessions.map(\\.id))\n    sessionDayCache = sessionDayCache.filter { ids.contains($0.key) }\n  }\n\n  private func pruneCoverageCache() {\n    guard !updatedMonthCoverage.isEmpty else { return }\n    let ids = Set(allSessions.map(\\.id))\n    updatedMonthCoverage = updatedMonthCoverage.filter { ids.contains($0.key.sessionID) }\n  }\n\n  private func invalidateVisibleCountCache() {\n    cachedVisibleCount = nil\n    invalidateProjectVisibleCountsCache()\n  }\n\n  func invalidateProjectVisibleCountsCache() {\n    cachedProjectVisibleCounts = nil\n    cachedProjectAggregated = nil\n  }\n\n  private func scheduleViewUpdate() {\n    if pendingViewUpdate { return }\n    pendingViewUpdate = true\n    DispatchQueue.main.async { [weak self] in\n      guard let self else { return }\n      self.objectWillChange.send()\n      self.pendingViewUpdate = false\n    }\n  }\n\n  func scheduleApplyFilters() {\n    DispatchQueue.main.async { [weak self] in\n      guard let self else { return }\n      // Coalesce rapid triggers: if a filter task is in flight, mark pending and return.\n      if self.filterTask != nil {\n        self.pendingApplyFilters = true\n        return\n      }\n      self.applyFilters()\n    }\n  }\n\n  func setProjectMemberships(_ memberships: [String: String]) {\n    var normalized: [String: String] = [:]\n    for (key, value) in memberships {\n      if key.contains(\"|\") {\n        normalized[key] = value\n      } else {\n        let legacyKey = membershipKey(for: key, source: .codex)\n        normalized[legacyKey] = value\n      }\n    }\n    projectMemberships = normalized\n    projectMembershipsVersion &+= 1\n    invalidateProjectVisibleCountsCache()\n  }\n\n  func monthKey(for date: Date) -> String {\n    Self.monthFormatter.string(from: date)\n  }\n\n  private static func formattedMonthKey(year: Int, month: Int) -> String {\n    return String(format: \"%04d-%02d\", year, month)\n  }\n\n  static func makeDayDescriptors(selectedDays: Set<Date>, singleDay: Date?)\n    -> [DaySelectionDescriptor]\n  {\n    let calendar = Calendar.current\n    let targets: [Date]\n    if !selectedDays.isEmpty {\n      targets = Array(selectedDays)\n    } else if let single = singleDay {\n      targets = [single]\n    } else {\n      targets = []\n    }\n    return targets.map { date in\n      let comps = calendar.dateComponents([.year, .month, .day], from: date)\n      let monthKey = formattedMonthKey(year: comps.year ?? 0, month: comps.month ?? 0)\n      return DaySelectionDescriptor(date: date, monthKey: monthKey, day: comps.day ?? 0)\n    }\n  }\n\n  func dayIndex(for session: SessionSummary) -> SessionDayIndex {\n    let index = buildDayIndex(for: session)\n    if let cached = sessionDayCache[session.id], cached == index {\n      return cached\n    }\n    sessionDayCache[session.id] = index\n    return index\n  }\n\n  private func buildDayIndex(for session: SessionSummary) -> SessionDayIndex {\n    let created = cachedCalendar.startOfDay(for: session.startedAt)\n    let updatedSource = session.lastUpdatedAt ?? session.startedAt\n    let updated = cachedCalendar.startOfDay(for: updatedSource)\n    let createdKey = monthKey(for: created)\n    let updatedKey = monthKey(for: updated)\n    let createdDay = cachedCalendar.component(.day, from: created)\n    let updatedDay = cachedCalendar.component(.day, from: updated)\n    return SessionDayIndex(\n      created: created,\n      updated: updated,\n      createdMonthKey: createdKey,\n      updatedMonthKey: updatedKey,\n      createdDay: createdDay,\n      updatedDay: updatedDay)\n  }\n\n  func dayStart(for session: SessionSummary, dimension: DateDimension) -> Date {\n    let index = dayIndex(for: session)\n    switch dimension {\n    case .created: return index.created\n    case .updated: return index.updated\n    }\n  }\n\n  func matchesDayFilters(_ session: SessionSummary, descriptors: [DaySelectionDescriptor]) -> Bool {\n    guard !descriptors.isEmpty else { return true }\n    let bucket = dayIndex(for: session)\n    return Self.matchesDayDescriptors(\n      summary: session,\n      bucket: bucket,\n      descriptors: descriptors,\n      dimension: dateDimension,\n      coverage: updatedMonthCoverage,\n      calendar: cachedCalendar\n    )\n  }\n\n  static func normalizeMonthStart(_ date: Date) -> Date {\n    let cal = Calendar.current\n    let comps = cal.dateComponents([.year, .month], from: date)\n    return cal.date(from: comps) ?? cal.startOfDay(for: date)\n  }\n\n  func setSidebarMonthStart(_ date: Date) {\n    let normalized = Self.normalizeMonthStart(date)\n    if normalized == sidebarMonthStart { return }\n    sidebarMonthStart = normalized\n\n    // Cancel unrelated coverage load tasks to reduce CPU usage when switching months\n    let currentKey = cacheKey(normalized, dateDimension)\n    for (key, task) in coverageLoadTasks where key != currentKey {\n      task.cancel()\n    }\n    coverageLoadTasks.removeAll(keepingCapacity: true)\n\n    _ = calendarCounts(for: normalized, dimension: dateDimension)\n\n    // In Created mode, changing the viewed month requires reloading data\n    // since we only load the current month's sessions for efficiency\n    if dateDimension == .created {\n      scheduleFilterRefresh(force: true)\n    }\n  }\n\n  var sidebarStateSnapshot: SidebarState {\n    SidebarState(\n      totalSessionCount: totalSessionCount,\n      isLoading: isLoading,\n      visibleAllCount: visibleAllCountForDateScope(),\n      selectedProjectIDs: selectedProjectIDs,\n      selectedDay: selectedDay,\n      selectedDays: selectedDays,\n      dateDimension: dateDimension,\n      monthStart: sidebarMonthStart,\n      calendarCounts: calendarCounts(for: sidebarMonthStart, dimension: dateDimension),\n      enabledProjectDays: calendarEnabledDaysForSelectedProject(\n        monthStart: sidebarMonthStart,\n        dimension: dateDimension\n      )\n    )\n  }\n\n  init(\n    preferences: SessionPreferencesStore,\n    sqliteStore: SessionIndexSQLiteStore = SessionIndexSQLiteStore(),\n    indexer: SessionIndexer? = nil,\n    actions: SessionActions = SessionActions()\n  ) {\n    self.preferences = preferences\n    self.sqliteStore = sqliteStore\n    self.indexer = indexer ?? SessionIndexer(sqliteStore: sqliteStore)\n    self.actions = actions\n    self.notesStore = SessionNotesStore(notesRoot: preferences.notesRoot)\n    // Initialize ProjectsStore using configurable projectsRoot (defaults to ~/.codmate/projects)\n    let pr = preferences.projectsRoot\n    let p = ProjectsStore.Paths(\n      root: pr,\n      metadataDir: pr.appendingPathComponent(\"metadata\", isDirectory: true),\n      membershipsURL: pr.appendingPathComponent(\"memberships.json\", isDirectory: false)\n    )\n    self.projectsStore = ProjectsStore(paths: p)\n    // Initialize TasksStore (defaults to ~/.codmate/tasks)\n    self.tasksStore = TasksStore()\n    self.claudeProvider = ClaudeSessionProvider(cacheStore: sqliteStore)\n    self.geminiProvider = GeminiSessionProvider(\n      projectsStore: self.projectsStore, cacheStore: sqliteStore)\n    self.remoteProvider = RemoteSessionProvider(indexer: SessionIndexer(sqliteStore: sqliteStore))\n\n    suppressFilterNotifications = true\n\n    // Restore window state from previous session\n    let calendar = windowStateStore.restoreCalendarSelection()\n    if let restoredDay = calendar.selectedDay {\n      // Use restored calendar state\n      self.selectedDay = restoredDay\n      self.selectedDays = calendar.selectedDays.isEmpty ? [restoredDay] : calendar.selectedDays\n      // Restore monthStart if available, otherwise derive from selectedDay\n      if let restoredMonthStart = calendar.monthStart {\n        self.sidebarMonthStart = restoredMonthStart\n      } else {\n        self.sidebarMonthStart = Self.normalizeMonthStart(restoredDay)\n      }\n    } else if !calendar.selectedDays.isEmpty {\n      // Restore selectedDays even if selectedDay is nil\n      self.selectedDays = calendar.selectedDays\n      self.selectedDay = calendar.selectedDays.count == 1 ? calendar.selectedDays.first : nil\n      if let restoredMonthStart = calendar.monthStart {\n        self.sidebarMonthStart = restoredMonthStart\n      } else if let firstDay = calendar.selectedDays.first {\n        self.sidebarMonthStart = Self.normalizeMonthStart(firstDay)\n      } else {\n        self.sidebarMonthStart = Self.normalizeMonthStart(Date())\n      }\n    } else if let restoredMonthStart = calendar.monthStart {\n      // Only monthStart was saved, restore it but select today\n      let today = Date()\n      let cal = Calendar.current\n      let start = cal.startOfDay(for: today)\n      self.selectedDay = start\n      self.selectedDays = [start]\n      self.sidebarMonthStart = restoredMonthStart\n    } else {\n      // No saved state, default to today\n      let today = Date()\n      let cal = Calendar.current\n      let start = cal.startOfDay(for: today)\n      self.selectedDay = start\n      self.selectedDays = [start]\n      self.sidebarMonthStart = Self.normalizeMonthStart(today)\n    }\n\n    // Restore project selection\n    self.selectedProjectIDs = windowStateStore.restoreProjectSelection()\n    self.expandedProjectIDs = windowStateStore.restoreProjectExpansions()\n\n    suppressFilterNotifications = false\n\n    // Initialize workspace view model after self is fully initialized\n    self.workspaceVM = ProjectWorkspaceViewModel(sessionListViewModel: self)\n\n    // Prime cached index state early so sidebar counts/overview can render without a 0 flash.\n    Task { @MainActor [weak self] in\n      guard let self else { return }\n      let meta = await self.indexer.currentMeta()\n      let coverage = await self.indexer.currentCoverage()\n      if let coverage {\n        self.cacheCoverage = coverage\n        self.globalSessionCount = coverage.sessionCount\n        self.diagLogger.log(\n          \"prime index coverage count=\\(coverage.sessionCount, privacy: .public) sources=\\(coverage.sources, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n        )\n      } else if let meta {\n        self.indexMeta = meta\n        self.globalSessionCount = meta.sessionCount\n        self.diagLogger.log(\n          \"prime index meta count=\\(meta.sessionCount, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n        )\n      }\n    }\n\n    configureDirectoryMonitor()\n    configureClaudeDirectoryMonitor()\n    configureGeminiDirectoryMonitor()\n    Task { await loadProjects() }\n    Task { await self.performInitialRemoteSyncIfNeeded() }\n    // Observe agent completion notifications to surface in list\n    NotificationCenter.default.addObserver(\n      forName: .codMateAgentCompleted,\n      object: nil,\n      queue: .main\n    ) { [weak self] note in\n      guard let id = note.userInfo?[\"sessionID\"] as? String else { return }\n      Task { @MainActor in\n        self?.awaitingFollowupIDs.insert(id)\n      }\n    }\n    // React to Active Provider changes to keep usage capsule in sync immediately\n    NotificationCenter.default.addObserver(\n      forName: .codMateActiveProviderChanged,\n      object: nil,\n      queue: .main\n    ) { [weak self] note in\n      guard let self else { return }\n      let consumer = note.userInfo?[\"consumer\"] as? String\n      let providerId = note.userInfo?[\"providerId\"] as? String\n      Task { @MainActor in\n        if consumer == ProvidersRegistryService.Consumer.codex.rawValue {\n          guard self.preferences.isCLIEnabled(.codex) else { return }\n          if providerId == nil || providerId?.isEmpty == true {\n            self.refreshCodexUsageStatus()\n          } else {\n            self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex))\n          }\n        } else if consumer == ProvidersRegistryService.Consumer.claudeCode.rawValue {\n          guard self.preferences.isCLIEnabled(.claude) else { return }\n          if providerId == nil || providerId?.isEmpty == true {\n            self.claudeUsageAutoRefreshEnabled = false\n            self.setInitialClaudePlaceholder()\n          } else {\n            self.claudeUsageAutoRefreshEnabled = false\n            self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude))\n          }\n        }\n      }\n    }\n    startActivityPruneTicker()\n    startIntentsCleanupTicker()\n    // Observe remote host enablement changes to trigger sync\n\n    preferences.$enabledRemoteHosts\n      .removeDuplicates()\n      .dropFirst()\n      .sink { [weak self] _ in\n        guard let self else { return }\n        Task { await self.syncRemoteHosts(force: true, refreshAfter: true) }\n      }\n      .store(in: &cancellables)\n\n    // Observe global CLI enablement changes\n    var previousEnabledKinds = enabledCLIKindSet()\n    Publishers.CombineLatest3(\n      preferences.$cliCodexEnabled,\n      preferences.$cliClaudeEnabled,\n      preferences.$cliGeminiEnabled\n    )\n    .dropFirst()\n    .debounce(for: .milliseconds(200), scheduler: DispatchQueue.main)\n    .sink { [weak self] codex, claude, gemini in\n      guard let self else { return }\n      let current = Self.cliEnabledKindSet(codex: codex, claude: claude, gemini: gemini)\n      guard current != previousEnabledKinds else { return }\n      previousEnabledKinds = current\n      self.cancelHeavyWork()\n      self.activeScopeRefreshes.removeAll()\n      Task { await self.refreshSessionsForProviderChange(force: true) }\n      self.configureDirectoryMonitor()\n      self.configureClaudeDirectoryMonitor()\n      self.configureGeminiDirectoryMonitor()\n      self.trimUsageSnapshotsForDisabledCLIs()\n    }\n    .store(in: &cancellables)\n\n    // Observe session path configs changes (ignore rules, enabled state)\n    // When enabled state changes, trigger full refresh to rebuild providers\n    // When only ignore rules change, trigger refresh but only from cache (no filesystem scan)\n    // Cache is preserved - sessions will reappear if ignore rules are removed later\n    var previousConfigs: [SessionPathConfig] = preferences.sessionPathConfigs\n    preferences.$sessionPathConfigs\n      .dropFirst()\n      .debounce(for: .milliseconds(300), scheduler: DispatchQueue.main)\n      .sink { [weak self] newConfigs in\n        guard let self else { return }\n        // Check if enabled state changed (requires provider rebuild)\n        let previousEnabled = Set(\n          previousConfigs.filter { $0.enabled }.map { \"\\($0.kind.rawValue):\\($0.id)\" })\n        let currentEnabled = Set(\n          newConfigs.filter { $0.enabled }.map { \"\\($0.kind.rawValue):\\($0.id)\" })\n        previousConfigs = newConfigs\n\n        if previousEnabled != currentEnabled {\n          // Enabled state changed - need full refresh to rebuild providers\n          // Important: toggling providers can overlap with manual refresh (Cmd+R). Cancel pending work\n          // and clear in-flight markers to avoid getting stuck in a \"refresh already running\" state.\n          self.cancelHeavyWork()\n          self.activeScopeRefreshes.removeAll()\n          // Force a filesystem-backed refresh with .all scope to fully restore sessions for re-enabled providers.\n          Task { await self.refreshSessionsForProviderChange(force: true) }\n        } else {\n          // Only ignore rules changed - refresh from cache only (no filesystem scan)\n          // This applies new ignore rules to cached sessions without rescanning\n          Task { await self.refreshSessionsFromCacheOnly() }\n        }\n      }\n      .store(in: &cancellables)\n\n    preferences.$timelineVisibleKinds\n      .removeDuplicates()\n      .dropFirst()\n      .sink { [weak self] _ in\n        self?.scheduleFiltersUpdate()\n      }\n      .store(in: &cancellables)\n    // Pre-seed usage snapshots based on current Active Provider selection to avoid initial flicker\n    Task { [weak self] in\n      guard let self else { return }\n      let codexOrigin = await self.providerOrigin(for: .codex)\n      let claudeOrigin = await self.providerOrigin(for: .claude)\n      let geminiOrigin = await self.providerOrigin(for: .gemini)\n      await MainActor.run {\n        if self.preferences.isCLIEnabled(.codex) {\n          if codexOrigin == .thirdParty {\n            self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex))\n          } else {\n            // Immediately refresh Codex usage (like Gemini) for better initial UX\n            self.refreshCodexUsageStatus(silent: false)\n          }\n        }\n        if self.preferences.isCLIEnabled(.claude) {\n          if claudeOrigin == .thirdParty {\n            self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude))\n          } else {\n            self.claudeUsageAutoRefreshEnabled = false\n            self.setInitialClaudePlaceholder()\n          }\n        }\n        if self.preferences.isCLIEnabled(.gemini) {\n          if geminiOrigin == .thirdParty {\n            self.setUsageSnapshot(.gemini, Self.thirdPartyUsageSnapshot(for: .gemini))\n          } else {\n            self.refreshGeminiUsageStatus(silent: false)\n          }\n        }\n      }\n      await MainActor.run {\n        self.autoRefreshUsageIfNeeded(\n          codexOrigin: codexOrigin,\n          claudeOrigin: claudeOrigin,\n          geminiOrigin: geminiOrigin\n        )\n      }\n    }\n  }\n\n  // Immediate apply from UI (e.g., pressing Return in search field)\n  func immediateApplyQuickSearch(_ text: String) { quickSearchText = text }\n\n  private var activeRefreshToken = UUID()\n  private var refreshPulseTask: Task<Void, Never>?\n  private var refreshStatusToken: String?\n\n  private func beginRefreshStatus(force: Bool) {\n    refreshPulseTask?.cancel()\n    refreshStatusToken = StatusBarLogStore.shared.beginTask(\n      force ? \"Refreshing sessions (forced)...\" : \"Refreshing sessions...\",\n      level: .info,\n      source: \"Sessions\"\n    )\n    refreshPulseTask = Task.detached { [weak self] in\n      guard let self else { return }\n      var tick = 0\n      while !Task.isCancelled {\n        try? await Task.sleep(nanoseconds: 1_000_000_000)\n        tick += 1\n        let currentTick = tick\n        await MainActor.run {\n          let progressText: String\n          if self.enrichmentTotal > 0 {\n            progressText = \"Enriching \\(self.enrichmentProgress)/\\(self.enrichmentTotal)\"\n          } else {\n            progressText = \"Scanning sessions\"\n          }\n          StatusBarLogStore.shared.post(\n            \"\\(progressText) - \\(currentTick)s\",\n            level: .info,\n            source: \"Sessions\"\n          )\n        }\n      }\n    }\n  }\n\n  private func endRefreshStatus(elapsed: TimeInterval, isCurrent: Bool) {\n    refreshPulseTask?.cancel()\n    refreshPulseTask = nil\n    guard let token = refreshStatusToken else { return }\n    refreshStatusToken = nil\n    if isCurrent {\n      let count = allSessions.count\n      StatusBarLogStore.shared.endTask(\n        token,\n        message: \"Refresh complete in \\(String(format: \"%.1f\", elapsed))s - \\(count) sessions\",\n        level: .success,\n        source: \"Sessions\"\n      )\n    } else {\n      StatusBarLogStore.shared.endTask(token)\n    }\n  }\n\n  func refreshSessions(force: Bool = false) async {\n    scheduledFilterRefresh?.cancel()\n    scheduledFilterRefresh = nil\n    let token = UUID()\n    activeRefreshToken = token\n    if shouldSkipForCacheUnavailable() {\n      diagLogger.log(\n        \"refreshSessions skipped due to cache unavailable (cooldown) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      StatusBarLogStore.shared.post(\n        \"Refresh skipped (cache unavailable)\", level: .warning, source: \"Sessions\")\n      await MainActor.run { self.isLoading = false }\n      return\n    }\n    let scope = currentScope()\n    let scopeKeyValue = scopeKey(scope)\n\n    if shouldSkipRefresh(scope: scope, force: force) {\n      diagLogger.log(\n        \"refreshSessions skipped (executing or recent) scope=\\(scopeKeyValue, privacy: .public) force=\\(force, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      StatusBarLogStore.shared.post(\n        \"Refresh skipped (already running)\", level: .warning, source: \"Sessions\")\n      await MainActor.run { self.isLoading = false }\n      return\n    }\n\n    isLoading = true\n    beginRefreshStatus(force: force)\n    activeScopeRefreshes[scopeKeyValue] = token\n    if force {\n      invalidateEnrichmentCache(for: selectedDay)\n    }\n    let refreshBegan = Date()\n    defer {\n      let elapsed = Date().timeIntervalSince(refreshBegan)\n      // Always clear the in-flight marker for this scope if it's ours.\n      // Important: a refresh can be superseded (e.g. settings toggle -> Cmd+R),\n      // and if we only clear on \"current token\" we can leave a stale marker behind,\n      // causing future refreshes to be skipped indefinitely.\n      if activeScopeRefreshes[scopeKeyValue] == token {\n        activeScopeRefreshes.removeValue(forKey: scopeKeyValue)\n      }\n      if token == activeRefreshToken {\n        isLoading = false\n        lastRefreshAt = Date()\n        lastRefreshScope = currentScope()\n        endRefreshStatus(elapsed: elapsed, isCurrent: true)\n        diagLogger.log(\n          \"refreshSessions done in \\(elapsed, format: .fixed(precision: 3))s sessions=\\(self.allSessions.count, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n        )\n      }\n    }\n\n    // Ensure we have access to the sessions directory in sandbox mode\n    await ensureSessionsAccess()\n\n    let enabledRemoteHosts = preferences.enabledRemoteHosts\n    diagLogger.log(\n      \"refreshSessions start force=\\(force, privacy: .public) scope=\\(String(describing: scope), privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3)) hosts=\\(enabledRemoteHosts.count, privacy: .public)\"\n    )\n\n    let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts))\n    let projectDirectories = singleSelectedProjectDirectory()\n    let scopedProjectIds = dateDimension == .created ? singleSelectedProject() : nil\n    let scopedProjectDirectories = dateDimension == .created ? projectDirectories : nil\n    let scopedDateRange = dateDimension == .created ? currentDateRange() : nil\n    // Get ignored paths for Codex (merge all enabled Codex configs)\n    let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled }\n    let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths }\n\n    let cacheContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: scopedProjectDirectories,\n      dateDimension: dateDimension,\n      dateRange: scopedDateRange,\n      projectIds: scopedProjectIds,\n      forceFilesystemScan: false,\n      cachePolicy: .cacheOnly,\n      ignoredPaths: codexIgnoredPaths\n    )\n    let refreshContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: scopedProjectDirectories,\n      dateDimension: dateDimension,\n      dateRange: scopedDateRange,\n      projectIds: scopedProjectIds,\n      forceFilesystemScan: force,\n      cachePolicy: .refresh,\n      ignoredPaths: codexIgnoredPaths\n    )\n\n    let cachedResults = await loadProviders(providers, context: cacheContext)\n    let cachedSessions = dedupProviderSessions(cachedResults)\n    let notes = await notesStore.all()\n    notesSnapshot = notes\n\n    if token == activeRefreshToken, !cachedSessions.isEmpty {\n      var cachedForApply = cachedSessions\n      apply(notes: notes, to: &cachedForApply)\n      registerActivityHeartbeat(previous: allSessions, current: cachedForApply)\n      smartMergeAllSessions(newSessions: cachedForApply)\n      scheduleFiltersUpdate()\n    }\n\n    let refreshedResults = await loadProviders(providers, context: refreshContext)\n    var sessions = dedupProviderSessions(cachedSessions + refreshedResults)\n\n    guard token == activeRefreshToken else { return }\n    let previousIDs = Set(allSessions.map { $0.id })\n    // Refresh projects/memberships snapshot and import legacy mappings if needed\n    Task { @MainActor in\n      await self.loadProjects()\n      await self.importMembershipsFromNotesIfNeeded(notes: notes)\n    }\n    apply(notes: notes, to: &sessions)\n    // Auto-assign on newly appeared sessions matched with pending intents\n    let newlyAppeared = sessions.filter { !previousIDs.contains($0.id) }\n    if !newlyAppeared.isEmpty {\n      for s in newlyAppeared { self.handleAutoAssignIfMatches(s) }\n    }\n    registerActivityHeartbeat(previous: allSessions, current: sessions)\n    // Smart merge: only update if data actually changed to avoid unnecessary UI re-renders\n    smartMergeAllSessions(newSessions: sessions)\n    persistProjectAssignmentsToCache(sessions)\n    recomputeProjectCounts()\n    rebuildCanonicalCwdCache()\n    await computeCalendarCaches()\n    scheduleFiltersUpdate()\n    // TEMPORARILY DISABLED FOR PERFORMANCE TESTING\n    // Background enrichment causes continuous UI updates during scrolling\n    // startBackgroundEnrichment()\n    currentMonthDimension = dateDimension\n    currentMonthKey = monthKey(for: selectedDay, dimension: dateDimension)\n    Task { await self.refreshGlobalCount() }\n    // Refresh path tree to ensure newly created files appear via refresh\n    let enabledRemoteHostsForCounts = enabledRemoteHosts\n    let sessionsRootForCounts = sessionsRoot\n    Task {\n      var counts: [String: Int] = [:]\n      if self.preferences.isCLIEnabled(.codex) {\n        counts = await indexer.collectCWDCounts(root: sessionsRootForCounts)\n      }\n      if self.preferences.isCLIEnabled(.claude) {\n        let claudeCounts = await claudeProvider.collectCWDCounts()\n        for (key, value) in claudeCounts {\n          counts[key, default: 0] += value\n        }\n      }\n      if self.preferences.isCLIEnabled(.gemini) {\n        let geminiCounts = await geminiProvider.collectCWDCounts()\n        for (key, value) in geminiCounts {\n          counts[key, default: 0] += value\n        }\n      }\n      if !enabledRemoteHostsForCounts.isEmpty {\n        let remoteCodex = await remoteProvider.collectCWDAggregates(\n          kind: .codex, enabledHosts: enabledRemoteHostsForCounts)\n        for (key, value) in remoteCodex {\n          counts[key, default: 0] += value\n        }\n        let remoteClaude = await remoteProvider.collectCWDAggregates(\n          kind: .claude, enabledHosts: enabledRemoteHostsForCounts)\n        if self.preferences.isCLIEnabled(.claude) {\n          for (key, value) in remoteClaude {\n            counts[key, default: 0] += value\n          }\n        }\n      }\n      let tree = counts.buildPathTreeFromCounts()\n      await MainActor.run { self.pathTreeRootPublished = tree }\n    }\n    Task { [weak self] in\n      guard let self else { return }\n      self.indexMeta = await self.indexer.currentMeta()\n      self.cacheCoverage = await self.indexer.currentCoverage()\n      self.diagLogger.log(\n        \"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))\"\n      )\n    }\n    if preferences.isCLIEnabled(.codex) {\n      refreshCodexUsageStatus()\n    }\n    if preferences.isCLIEnabled(.claude), claudeUsageAutoRefreshEnabled {\n      refreshClaudeUsageStatus(silent: false)\n    }\n    if preferences.isCLIEnabled(.gemini) {\n      refreshGeminiUsageStatus(silent: false)\n    }\n    schedulePathTreeRefresh()\n\n    // Ensure currently selected sessions are fully up-to-date with high-quality parsing.\n    // This fixes the issue where global refresh (fast parse) keeps selected item in 'metadata' state\n    // when user explicitly requests a refresh (Cmd+R).\n    if !selectedSessionIDs.isEmpty {\n      Task { await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: force) }\n    }\n  }\n\n  /// Refresh sessions when provider enabled state changes\n  /// Loads all sessions (scope: .all) to ensure complete restoration when re-enabling a provider\n  private func refreshSessionsForProviderChange(force: Bool = false) async {\n    scheduledFilterRefresh?.cancel()\n    scheduledFilterRefresh = nil\n    let token = UUID()\n    activeRefreshToken = token\n\n    // Use .all scope to load all sessions when provider state changes\n    let scope: SessionLoadScope = .all\n    let scopeKeyValue = scopeKey(scope)\n\n    isLoading = true\n    beginRefreshStatus(force: force)\n    activeScopeRefreshes[scopeKeyValue] = token\n    if force {\n      invalidateEnrichmentCache(for: selectedDay)\n    }\n    let refreshBegan = Date()\n    defer {\n      let elapsed = Date().timeIntervalSince(refreshBegan)\n      // Always clear the in-flight marker for this scope if it's ours.\n      if activeScopeRefreshes[scopeKeyValue] == token {\n        activeScopeRefreshes.removeValue(forKey: scopeKeyValue)\n      }\n      if token == activeRefreshToken {\n        isLoading = false\n        lastRefreshAt = Date()\n        lastRefreshScope = scope\n        endRefreshStatus(elapsed: elapsed, isCurrent: true)\n        diagLogger.log(\n          \"refreshSessionsForProviderChange done in \\(elapsed, format: .fixed(precision: 3))s sessions=\\(self.allSessions.count, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n        )\n      }\n    }\n\n    await ensureSessionsAccess()\n\n    let enabledRemoteHosts = preferences.enabledRemoteHosts\n    diagLogger.log(\n      \"refreshSessionsForProviderChange start force=\\(force, privacy: .public) scope=all ts=\\(self.ts(), format: .fixed(precision: 3))\"\n    )\n\n    let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts))\n    // Load all sessions, not scoped to current selection\n    let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled }\n    let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths }\n\n    let cacheContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: nil,  // Load all\n      dateDimension: dateDimension,\n      dateRange: nil,  // Load all\n      projectIds: nil,  // Load all\n      forceFilesystemScan: false,\n      cachePolicy: .cacheOnly,\n      ignoredPaths: codexIgnoredPaths\n    )\n    let refreshContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: nil,  // Load all\n      dateDimension: dateDimension,\n      dateRange: nil,  // Load all\n      projectIds: nil,  // Load all\n      forceFilesystemScan: force,\n      cachePolicy: .refresh,\n      ignoredPaths: codexIgnoredPaths\n    )\n\n    let cachedResults = await loadProviders(providers, context: cacheContext)\n    let cachedSessions = dedupProviderSessions(cachedResults)\n    let notes = await notesStore.all()\n    notesSnapshot = notes\n\n    if token == activeRefreshToken, !cachedSessions.isEmpty {\n      var cachedForApply = cachedSessions\n      apply(notes: notes, to: &cachedForApply)\n      registerActivityHeartbeat(previous: allSessions, current: cachedForApply)\n      smartMergeAllSessions(newSessions: cachedForApply)\n      scheduleFiltersUpdate()\n    }\n\n    let refreshedResults = await loadProviders(providers, context: refreshContext)\n    var sessions = dedupProviderSessions(cachedSessions + refreshedResults)\n\n    guard token == activeRefreshToken else { return }\n    let previousIDs = Set(allSessions.map { $0.id })\n    Task { @MainActor in\n      await self.loadProjects()\n      await self.importMembershipsFromNotesIfNeeded(notes: notes)\n    }\n    apply(notes: notes, to: &sessions)\n    let newlyAppeared = sessions.filter { !previousIDs.contains($0.id) }\n    if !newlyAppeared.isEmpty {\n      for s in newlyAppeared { self.handleAutoAssignIfMatches(s) }\n    }\n    registerActivityHeartbeat(previous: allSessions, current: sessions)\n    smartMergeAllSessions(newSessions: sessions)\n    persistProjectAssignmentsToCache(sessions)\n    recomputeProjectCounts()\n    rebuildCanonicalCwdCache()\n    await computeCalendarCaches()\n    scheduleFiltersUpdate()\n    currentMonthDimension = dateDimension\n    currentMonthKey = monthKey(for: selectedDay, dimension: dateDimension)\n    Task { await self.refreshGlobalCount() }\n    let enabledRemoteHostsForCounts = enabledRemoteHosts\n    let sessionsRootForCounts = sessionsRoot\n    Task {\n      var counts: [String: Int] = [:]\n      if self.preferences.isCLIEnabled(.codex) {\n        counts = await indexer.collectCWDCounts(root: sessionsRootForCounts)\n      }\n      if self.preferences.isCLIEnabled(.claude) {\n        let claudeCounts = await claudeProvider.collectCWDCounts()\n        for (key, value) in claudeCounts {\n          counts[key, default: 0] += value\n        }\n      }\n      if self.preferences.isCLIEnabled(.gemini) {\n        let geminiCounts = await geminiProvider.collectCWDCounts()\n        for (key, value) in geminiCounts {\n          counts[key, default: 0] += value\n        }\n      }\n      if !enabledRemoteHostsForCounts.isEmpty {\n        if self.preferences.isCLIEnabled(.codex) {\n          let remoteCodex = await remoteProvider.collectCWDAggregates(\n            kind: .codex, enabledHosts: enabledRemoteHostsForCounts)\n          for (key, value) in remoteCodex {\n            counts[key, default: 0] += value\n          }\n        }\n        if self.preferences.isCLIEnabled(.claude) {\n          let remoteClaude = await remoteProvider.collectCWDAggregates(\n            kind: .claude, enabledHosts: enabledRemoteHostsForCounts)\n          for (key, value) in remoteClaude {\n            counts[key, default: 0] += value\n          }\n        }\n      }\n      let tree = counts.buildPathTreeFromCounts()\n      await MainActor.run { self.pathTreeRootPublished = tree }\n    }\n    Task { [weak self] in\n      guard let self else { return }\n      self.indexMeta = await self.indexer.currentMeta()\n      self.cacheCoverage = await self.indexer.currentCoverage()\n      self.diagLogger.log(\n        \"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))\"\n      )\n    }\n    if preferences.isCLIEnabled(.codex) {\n      refreshCodexUsageStatus()\n    }\n    if preferences.isCLIEnabled(.claude), claudeUsageAutoRefreshEnabled {\n      refreshClaudeUsageStatus(silent: false)\n    }\n    if preferences.isCLIEnabled(.gemini) {\n      refreshGeminiUsageStatus(silent: false)\n    }\n    schedulePathTreeRefresh()\n\n    if !selectedSessionIDs.isEmpty {\n      Task { await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: force) }\n    }\n  }\n\n  /// Hydrate sessions from cache on launch (lightweight, no filesystem scan or expensive recomputations)\n  /// Used at app startup to quickly populate UI from cached data without triggering full refresh\n  func hydrateFromCacheOnLaunch() async {\n    let scope = currentScope()\n    let enabledRemoteHosts = preferences.enabledRemoteHosts\n    let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts))\n    let projectDirectories = singleSelectedProjectDirectory()\n    let scopedProjectIds = dateDimension == .created ? singleSelectedProject() : nil\n    let scopedProjectDirectories = dateDimension == .created ? projectDirectories : nil\n    let scopedDateRange = dateDimension == .created ? currentDateRange() : nil\n    // Get ignored paths for Codex (merge all enabled Codex configs)\n    let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled }\n    let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths }\n\n    let cacheContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: scopedProjectDirectories,\n      dateDimension: dateDimension,\n      dateRange: scopedDateRange,\n      projectIds: scopedProjectIds,\n      forceFilesystemScan: false,\n      cachePolicy: .cacheOnly,\n      ignoredPaths: codexIgnoredPaths\n    )\n\n    let cachedResults = await loadProviders(providers, context: cacheContext)\n    let sessions = dedupProviderSessions(cachedResults)\n    let notes = await notesStore.all()\n    notesSnapshot = notes\n\n    var sessionsForApply = sessions\n    apply(notes: notes, to: &sessionsForApply)\n    registerActivityHeartbeat(previous: allSessions, current: sessionsForApply)\n    smartMergeAllSessions(newSessions: sessionsForApply)\n    scheduleFiltersUpdate()\n    \n    // Trigger usage refresh on launch (like Gemini) to ensure usage data is available immediately\n    // This ensures the usage capsule icon and menu show data without requiring user interaction\n    await MainActor.run {\n      if preferences.isCLIEnabled(.codex) {\n        refreshCodexUsageStatus(silent: false)\n      }\n      if preferences.isCLIEnabled(.gemini) {\n        refreshGeminiUsageStatus(silent: false)\n      }\n    }\n    \n    diagLogger.log(\n      \"hydrateFromCacheOnLaunch done sessions=\\(self.allSessions.count, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n    )\n  }\n\n  /// Refresh sessions from cache only (no filesystem scan)\n  /// Used when ignore rules change - applies new rules to cached sessions without rescanning\n  /// Cache is preserved - sessions will reappear if ignore rules are removed later\n  /// When re-enabling a provider, loads all sessions (scope: .all) to ensure complete restoration\n  private func refreshSessionsFromCacheOnly() async {\n    // When ignore rules change or provider is re-enabled, load ALL sessions from cache\n    // to ensure previously filtered sessions are restored\n    let scope: SessionLoadScope = .all\n    let enabledRemoteHosts = preferences.enabledRemoteHosts\n    let providers = buildProviders(enabledRemoteHosts: Set(enabledRemoteHosts))\n\n    // Get ignored paths (merge all enabled configs)\n    let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled }\n    let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths }\n\n    let cacheContext = SessionProviderContext(\n      scope: scope,\n      sessionsRoot: preferences.sessionsRoot,\n      enabledRemoteHosts: Set(enabledRemoteHosts),\n      projectDirectories: nil,  // Load all, not scoped to current selection\n      dateDimension: dateDimension,\n      dateRange: nil,  // Load all, not scoped to current date range\n      projectIds: nil,  // Load all, not scoped to current project\n      forceFilesystemScan: false,\n      cachePolicy: .cacheOnly,\n      ignoredPaths: codexIgnoredPaths\n    )\n\n    let cachedResults = await loadProviders(providers, context: cacheContext)\n    let sessions = dedupProviderSessions(cachedResults)\n    let notes = await notesStore.all()\n    notesSnapshot = notes\n\n    var sessionsForApply = sessions\n    apply(notes: notes, to: &sessionsForApply)\n    registerActivityHeartbeat(previous: allSessions, current: sessionsForApply)\n    smartMergeAllSessions(newSessions: sessionsForApply)\n    scheduleFiltersUpdate()\n  }\n\n  // MARK: - Selected Sessions Incremental Refresh\n\n  /// Refresh only the selected sessions, avoiding full scope scan.\n  /// Returns true if any sessions were refreshed.\n  func refreshSelectedSessions(sessionIds: Set<String>, force: Bool = false) async -> Bool {\n    guard !sessionIds.isEmpty else { return false }\n    if shouldSkipForCacheUnavailable() {\n      diagLogger.log(\n        \"refreshSelectedSessions skipped due to cache unavailable (cooldown) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      return false\n    }\n\n    diagLogger.log(\n      \"refreshSelectedSessions: start sessionIds=\\(sessionIds.count, privacy: .public) force=\\(force, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n    )\n    let refreshBegan = Date()\n\n    // Pull cached file metadata (mtime/size) to avoid re-parsing unchanged files (Codex only)\n    let cachedRecords = await indexer.fetchRecords(sessionIds: sessionIds)\n    let cachedById = Dictionary(uniqueKeysWithValues: cachedRecords.map { ($0.summary.id, $0) })\n\n    // 1. Find the selected sessions in current allSessions\n    let selectedSessions = allSessions.filter { sessionIds.contains($0.id) }\n    guard !selectedSessions.isEmpty else {\n      diagLogger.log(\"refreshSelectedSessions: no sessions found in allSessions for given IDs\")\n      return false\n    }\n\n    // Split by source so we can use the correct parser\n    let codexSessions = selectedSessions.filter { $0.source.baseKind == .codex }\n    let claudeSessions = selectedSessions.filter { $0.source.baseKind == .claude }\n\n    var refreshedSummaries: [SessionSummary] = []\n\n    // 2. Codex: mtime/size check + reindex via SessionIndexer\n    if !codexSessions.isEmpty {\n      var needsRefresh: [(id: String, url: URL)] = []\n      for session in codexSessions {\n        let record = cachedById[session.id]\n        let fileURL = record.flatMap { URL(fileURLWithPath: $0.filePath) } ?? session.fileURL\n        guard\n          let values = try? fileURL.resourceValues(\n            forKeys: [.contentModificationDateKey, .fileSizeKey, .isRegularFileKey]),\n          values.isRegularFile == true\n        else {\n          // Missing file or unreadable: refresh to reconcile state\n          needsRefresh.append((session.id, fileURL))\n          continue\n        }\n\n        if force {\n          needsRefresh.append((session.id, fileURL))\n          continue\n        }\n\n        var hasComparableMetric = false\n        var changed = false\n\n        if let cachedMtime = record?.fileModificationTime,\n          let mtime = values.contentModificationDate\n        {\n          hasComparableMetric = true\n          if mtime > cachedMtime.addingTimeInterval(0.001) {\n            changed = true\n          }\n        }\n\n        if let cachedSize = record?.fileSize, let fsize = values.fileSize.map({ UInt64($0) }) {\n          hasComparableMetric = true\n          if cachedSize != fsize {\n            changed = true\n          }\n        }\n\n        // If we had no cached metrics, err on the side of refreshing\n        if !hasComparableMetric || changed {\n          needsRefresh.append((session.id, fileURL))\n        }\n      }\n\n      if !needsRefresh.isEmpty {\n        diagLogger.log(\n          \"refreshSelectedSessions (codex): refreshing \\(needsRefresh.count, privacy: .public) files\"\n        )\n        let urlsToRefresh = needsRefresh.map { $0.url }\n        do {\n          let codexSummaries = try await indexer.reindexFiles(urlsToRefresh)\n          refreshedSummaries.append(contentsOf: codexSummaries)\n        } catch {\n          diagLogger.error(\n            \"refreshSelectedSessions: codex reindex failed: \\(error.localizedDescription, privacy: .public)\"\n          )\n        }\n      }\n    }\n\n    // 3. Claude/Gemini: always parse with provider-specific parsers when forced or when selection includes them.\n    if !claudeSessions.isEmpty {\n      let claudeParser = ClaudeSessionParser()\n\n      func parseSummary(for session: SessionSummary) -> SessionSummary? {\n        let url = session.fileURL\n        let values = try? url.resourceValues(forKeys: [.fileSizeKey])\n        let fileSize = values?.fileSize.flatMap { UInt64($0) }\n        switch session.source.baseKind {\n        case .claude:\n          return claudeParser.parse(at: url, fileSize: fileSize)?.summary\n        default:\n          return nil\n        }\n      }\n\n      for session in claudeSessions {\n        if let summary = parseSummary(for: session) {\n          var merged = summary\n          // Preserve user metadata (title/comment/task)\n          merged.userTitle = session.userTitle\n          merged.userComment = session.userComment\n          merged.taskId = session.taskId\n          refreshedSummaries.append(merged)\n        }\n      }\n    }\n\n    guard !refreshedSummaries.isEmpty else {\n      diagLogger.log(\"refreshSelectedSessions: no changes detected, skipping refresh\")\n      return false\n    }\n\n    // 4. Update allSessions with refreshed data\n    var didChange = false\n    await MainActor.run {\n      var updatedSessions = allSessions\n      for refreshed in refreshedSummaries {\n        if let index = updatedSessions.firstIndex(where: { $0.id == refreshed.id }) {\n          var merged = refreshed\n          merged.userTitle = updatedSessions[index].userTitle\n          merged.userComment = updatedSessions[index].userComment\n          merged.taskId = updatedSessions[index].taskId\n          if updatedSessions[index] != merged {\n            updatedSessions[index] = merged\n            didChange = true\n          }\n        }\n      }\n      if didChange {\n        allSessions = updatedSessions\n      }\n    }\n\n    // 5. Re-apply filters to update UI if anything changed\n    if didChange {\n      scheduleFiltersUpdate()\n    }\n\n    let elapsed = Date().timeIntervalSince(refreshBegan)\n    diagLogger.log(\n      \"refreshSelectedSessions: completed in \\(elapsed, format: .fixed(precision: 3))s, refreshed=\\(refreshedSummaries.count, privacy: .public)\"\n    )\n\n    return didChange\n  }\n\n  /// Schedule a debounced refresh for selected sessions.\n  /// Call this method when selection changes to trigger incremental refresh.\n  func scheduleSelectedSessionsRefresh(sessionIds: Set<String>) {\n    guard !sessionIds.isEmpty else { return }\n\n    // Cancel any pending refresh\n    selectedSessionsRefreshTask?.cancel()\n\n    // Schedule new refresh with 100ms debounce\n    selectedSessionsRefreshTask = Task { [weak self] in\n      try? await Task.sleep(nanoseconds: 100_000_000)  // 100ms\n      guard let self, !Task.isCancelled else { return }\n      _ = await self.refreshSelectedSessions(sessionIds: sessionIds, force: false)\n    }\n  }\n\n  private func buildProviders(enabledRemoteHosts: Set<String>) -> [any SessionProvider] {\n    var providers: [any SessionProvider] = []\n\n    // Check if each kind is enabled in session path configs and globally enabled\n    let codexEnabled =\n      preferences.isCLIEnabled(.codex)\n      && preferences.sessionPathConfigs.contains { $0.kind == .codex && $0.enabled }\n    let claudeEnabled =\n      preferences.isCLIEnabled(.claude)\n      && preferences.sessionPathConfigs.contains { $0.kind == .claude && $0.enabled }\n    let geminiEnabled =\n      preferences.isCLIEnabled(.gemini)\n      && preferences.sessionPathConfigs.contains { $0.kind == .gemini && $0.enabled }\n\n    diagLogger.log(\n      \"buildProviders: codex=\\(codexEnabled, privacy: .public) claude=\\(claudeEnabled, privacy: .public) gemini=\\(geminiEnabled, privacy: .public) remoteHosts=\\(enabledRemoteHosts.count, privacy: .public)\"\n    )\n\n    if codexEnabled {\n      providers.append(indexer)\n    }\n    if claudeEnabled {\n      providers.append(claudeProvider)\n    }\n    if geminiEnabled {\n      providers.append(geminiProvider)\n    }\n\n    if !enabledRemoteHosts.isEmpty {\n      if codexEnabled {\n        providers.append(\n          RemoteSessionProviderAdapter(\n            kind: .codex,\n            remoteKind: .codex,\n            provider: remoteProvider,\n            label: \"CodexRemote\"\n          )\n        )\n      }\n      if claudeEnabled {\n        providers.append(\n          RemoteSessionProviderAdapter(\n            kind: .claude,\n            remoteKind: .claude,\n            provider: remoteProvider,\n            label: \"ClaudeRemote\"\n          )\n        )\n      }\n    }\n    return providers\n  }\n\n  private func loadProviders(\n    _ providers: [any SessionProvider],\n    context: SessionProviderContext\n  ) async -> [SessionSummary] {\n    let logger = diagLogger\n    let isCacheUnavailableError: (Error) -> Bool = { error in\n      error is SessionIndexSQLiteStoreError\n        || error is ClaudeSessionProvider.SessionProviderCacheError\n        || error is GeminiSessionProvider.SessionProviderCacheError\n    }\n    return await withTaskGroup(\n      of: ([SessionSummary], SessionIndexCoverage?, SessionSource.Kind).self\n    ) { group in\n      for provider in providers {\n        group.addTask { [self] in\n          // Get ignored paths for this provider's kind (merge all configs of same kind, must access on MainActor)\n          // Filter out disabled subpaths\n          let ignoredPaths = await MainActor.run {\n            let configs = preferences.sessionPathConfigs.filter {\n              $0.kind == provider.kind && $0.enabled\n            }\n            return configs.flatMap { config in\n              config.ignoredSubpaths.filter { !config.disabledSubpaths.contains($0) }\n            }\n          }\n\n          // Create context with provider-specific ignored paths\n          let providerContext = SessionProviderContext(\n            scope: context.scope,\n            sessionsRoot: context.sessionsRoot,\n            enabledRemoteHosts: context.enabledRemoteHosts,\n            projectDirectories: context.projectDirectories,\n            dateDimension: context.dateDimension,\n            dateRange: context.dateRange,\n            projectIds: context.projectIds,\n            forceFilesystemScan: context.forceFilesystemScan,\n            cachePolicy: context.cachePolicy,\n            ignoredPaths: ignoredPaths\n          )\n\n          do {\n            let result = try await provider.load(context: providerContext)\n            let label = result.summaries.first?.source.baseKind.rawValue ?? provider.kind.rawValue\n            logger.log(\n              \"provider load success kind=\\(label, privacy: .public) count=\\(result.summaries.count, privacy: .public) cacheHit=\\(result.cacheHit, privacy: .public)\"\n            )\n            if !result.summaries.isEmpty {\n              await MainActor.run { self.clearCacheUnavailable() }\n            }\n            return (result.summaries, result.coverage, provider.kind)\n          } catch {\n            if isCacheUnavailableError(error) {\n              await MainActor.run { self.markCacheUnavailableNow() }\n            }\n            logger.error(\n              \"provider load failed kind=\\(provider.kind.rawValue, privacy: .public) error=\\(error.localizedDescription, privacy: .public)\"\n            )\n            return ([], nil, provider.kind)\n          }\n        }\n      }\n      var all: [SessionSummary] = []\n      var latestCoverage: SessionIndexCoverage?\n      for await output in group {\n        all.append(contentsOf: output.0)\n        if output.2 == .codex, let cov = output.1 {\n          latestCoverage = cov\n        }\n      }\n      if let cov = latestCoverage {\n        await MainActor.run { self.cacheCoverage = cov }\n      }\n      return all\n    }\n  }\n\n  private func dedupProviderSessions(_ sessions: [SessionSummary]) -> [SessionSummary] {\n    guard !sessions.isEmpty else { return [] }\n    var best: [String: SessionSummary] = [:]\n    for session in sessions {\n      if let existing = best[session.id] {\n        best[session.id] = preferSession(lhs: existing, rhs: session)\n      } else {\n        best[session.id] = session\n      }\n    }\n    return Array(best.values)\n  }\n\n  private func preferSession(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary {\n    // 1. Prefer higher parse level (Enriched > Full > Metadata)\n    if let lLevel = lhs.parseLevel, let rLevel = rhs.parseLevel {\n      if lLevel != rLevel {\n        return lLevel > rLevel ? lhs : rhs\n      }\n    }\n    // If one has explicit high quality level and other is unknown (nil), prefer explicit high quality\n    if let lLevel = lhs.parseLevel, lLevel > .metadata, rhs.parseLevel == nil {\n      return lhs\n    }\n    if let rLevel = rhs.parseLevel, rLevel > .metadata, lhs.parseLevel == nil {\n      return rhs\n    }\n\n    // CRITICAL FIX: Prefer sessions with higher counts (from full parse) over lower counts (from fast parse)\n    // When same file (matching size), always prefer the one with more complete data\n    let lt = lhs.lastUpdatedAt ?? lhs.startedAt\n    let rt = rhs.lastUpdatedAt ?? rhs.startedAt\n    let ls = lhs.fileSizeBytes ?? 0\n    let rs = rhs.fileSizeBytes ?? 0\n\n    // If file sizes match (same file), prefer the one with more complete data regardless of timestamp\n    // This handles the case where fast parse and full parse have slightly different timestamps\n    if ls > 0 && ls == rs {\n      let lhsTotal = lhs.userMessageCount + lhs.assistantMessageCount + lhs.toolInvocationCount\n      let rhsTotal = rhs.userMessageCount + rhs.assistantMessageCount + rhs.toolInvocationCount\n      if lhsTotal != rhsTotal {\n        return lhsTotal > rhsTotal ? lhs : rhs  // Prefer richer data (full parse)\n      }\n      // If counts are equal, also check lineCount as another indicator of completeness\n      if lhs.lineCount != rhs.lineCount {\n        return lhs.lineCount > rhs.lineCount ? lhs : rhs\n      }\n    }\n\n    // Original fallback logic\n    if lt != rt { return lt > rt ? lhs : rhs }\n    if ls != rs { return ls > rs ? lhs : rhs }\n    return lhs.id < rhs.id ? lhs : rhs\n  }\n\n  /// Aggregated overview metrics from cached index (all sources).\n  func fetchOverviewAggregate() async -> OverviewAggregate? {\n    await indexer.fetchOverviewAggregate()\n  }\n\n  /// Aggregated overview metrics with scoped filters when supported by SQLite cache.\n  func fetchOverviewAggregate(scope: OverviewAggregateScope?) async -> OverviewAggregate? {\n    await indexer.fetchOverviewAggregate(scope: scope)\n  }\n\n  private func registerActivityHeartbeat(previous: [SessionSummary], current: [SessionSummary]) {\n    // Map previous lastUpdated for quick lookup\n    var prevMap: [String: Date] = [:]\n    for s in previous { if let t = s.lastUpdatedAt { prevMap[s.id] = t } }\n    let now = Date()\n    var heartbeatChanged = false\n    for s in current {\n      guard let newT = s.lastUpdatedAt else { continue }\n      if let oldT = prevMap[s.id], newT > oldT {\n        activityHeartbeat[s.id] = now\n        heartbeatChanged = true\n      }\n    }\n    recomputeActiveUpdatingIDs()\n    // Reschedule prune task if heartbeats changed\n    if heartbeatChanged {\n      scheduleActivityPruneIfNeeded()\n    }\n  }\n\n  private var activityHeartbeat: [String: Date] = [:]\n  private var activityPruneTask: Task<Void, Never>?\n  \n  /// Schedule one-shot prune task based on earliest expiry time\n  private func scheduleActivityPruneIfNeeded() {\n    activityPruneTask?.cancel()\n    activityPruneTask = nil\n    \n    guard !activityHeartbeat.isEmpty else { return }\n    \n    let now = Date()\n    \n    // Find earliest time when any ID would expire (3s after its heartbeat)\n    let earliestExpiry = activityHeartbeat.values\n      .map { $0.addingTimeInterval(3.0) }\n      .filter { $0 > now }\n      .min()\n    \n    guard let nextExpiry = earliestExpiry else {\n      // All heartbeats are already expired, prune immediately\n      recomputeActiveUpdatingIDs()\n      return\n    }\n    \n    let delay = nextExpiry.timeIntervalSince(now)\n    guard delay > 0 else {\n      recomputeActiveUpdatingIDs()\n      return\n    }\n    \n    activityPruneTask = Task { [weak self] in\n      try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n      guard !Task.isCancelled else { return }\n      await MainActor.run {\n        self?.recomputeActiveUpdatingIDs()\n        // Reschedule if there are still active heartbeats\n        self?.scheduleActivityPruneIfNeeded()\n      }\n    }\n  }\n  \n  private func startActivityPruneTicker() {\n    // Legacy method name kept for compatibility - now uses one-shot scheduling\n    scheduleActivityPruneIfNeeded()\n  }\n\n  /// Schedule one-shot intent cleanup task based on earliest intent expiry\n  func scheduleIntentsCleanupIfNeeded() {\n    intentsCleanupTask?.cancel()\n    intentsCleanupTask = nil\n    \n    guard !pendingAssignIntents.isEmpty else { return }\n    \n    let now = Date()\n    // Intents expire 60s after creation\n    let earliestExpiry = pendingAssignIntents\n      .map { $0.t0.addingTimeInterval(60) }\n      .filter { $0 > now }\n      .min()\n    \n    guard let nextExpiry = earliestExpiry else {\n      // All intents are already expired, prune immediately\n      pruneExpiredIntents()\n      return\n    }\n    \n    let delay = nextExpiry.timeIntervalSince(now)\n    guard delay > 0 else {\n      pruneExpiredIntents()\n      return\n    }\n    \n    intentsCleanupTask = Task { [weak self] in\n      try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n      guard !Task.isCancelled else { return }\n      await MainActor.run {\n        self?.pruneExpiredIntents()\n        // Reschedule if there are still pending intents\n        self?.scheduleIntentsCleanupIfNeeded()\n      }\n    }\n  }\n  \n  private func startIntentsCleanupTicker() {\n    // Legacy method name kept for compatibility - now uses one-shot scheduling\n    scheduleIntentsCleanupIfNeeded()\n  }\n\n  private func recomputeActiveUpdatingIDs() {\n    let cutoff = Date().addingTimeInterval(-3.0)\n    let newIDs = Set(activityHeartbeat.filter { $0.value > cutoff }.keys)\n    guard newIDs != activeUpdatingIDs else { return }\n    activeUpdatingIDs = newIDs\n    // Reschedule prune task if heartbeats changed\n    scheduleActivityPruneIfNeeded()\n  }\n\n  func isActivelyUpdating(_ id: String) -> Bool { activeUpdatingIDs.contains(id) }\n  func isAwaitingFollowup(_ id: String) -> Bool { awaitingFollowupIDs.contains(id) }\n\n  func clearAwaitingFollowup(_ id: String) {\n    awaitingFollowupIDs.remove(id)\n  }\n\n  private func persistProjectAssignmentsToCache(_ sessions: [SessionSummary]) {\n    guard !sessions.isEmpty else { return }\n    let mapping = Dictionary(uniqueKeysWithValues: sessions.map { ($0.id, projectId(for: $0)) })\n    let resolver: @Sendable (SessionSummary) -> String? = { session in\n      mapping[session.id] ?? nil\n    }\n    Task { [weak self] in\n      guard let self else { return }\n      await self.indexer.updateProjects(for: sessions, resolver: resolver)\n    }\n  }\n\n  // Cancel ongoing background tasks (fulltext, enrichment, scheduled refreshes, quick pulses).\n  // Useful when a heavy modal/sheet is presented and the UI should stay responsive.\n  func cancelHeavyWork() {\n    fulltextTask?.cancel()\n    fulltextTask = nil\n    enrichmentTask?.cancel()\n    enrichmentTask = nil\n    filterDebounceTask?.cancel()\n    filterDebounceTask = nil\n    scheduledFilterRefresh?.cancel()\n    scheduledFilterRefresh = nil\n    // Cancel all scoped refresh tasks\n    for (_, task) in scopedRefreshTasks {\n      task.cancel()\n    }\n    scopedRefreshTasks.removeAll()\n    pendingScopeRefreshForce.removeAll()\n    directoryRefreshTask?.cancel()\n    directoryRefreshTask = nil\n    fileEventAggregationTask?.cancel()\n    fileEventAggregationTask = nil\n    pendingFileEvents.removeAll()\n    quickPulseTask?.cancel()\n    quickPulseTask = nil\n    codexUsageTask?.cancel()\n    codexUsageTask = nil\n    geminiUsageTask?.cancel()\n    geminiUsageTask = nil\n    pathTreeRefreshTask?.cancel()\n    pathTreeRefreshTask = nil\n    for task in calendarRefreshTasks.values { task.cancel() }\n    calendarRefreshTasks.removeAll()\n    isEnriching = false\n    isLoading = false\n  }\n\n  func reveal(session: SessionSummary) {\n    actions.revealInFinder(session: session)\n  }\n\n  func delete(summaries: [SessionSummary]) async {\n    let count = summaries.count\n    AppLogger.shared.info(\"Deleting \\(count) session\\(count == 1 ? \"\" : \"s\")\", source: \"Sessions\")\n    do {\n      try actions.delete(summaries: summaries)\n      await indexer.deleteSessions(ids: summaries.map(\\.id))\n      AppLogger.shared.success(\n        \"Deleted \\(count) session\\(count == 1 ? \"\" : \"s\")\", source: \"Sessions\")\n      await refreshSessions(force: true)\n    } catch {\n      AppLogger.shared.error(\"Delete failed: \\(error.localizedDescription)\", source: \"Sessions\")\n      errorMessage = error.localizedDescription\n    }\n  }\n\n  func updateSessionsRoot(to newURL: URL) async {\n    guard newURL != preferences.sessionsRoot else { return }\n    // Save security-scoped bookmark if sandboxed\n    SecurityScopedBookmarks.shared.save(url: newURL, for: .sessionsRoot)\n    preferences.sessionsRoot = newURL\n    await notesStore.updateRoot(to: preferences.notesRoot)\n    await indexer.invalidateAll()\n    enrichmentSnapshots.removeAll()\n    configureDirectoryMonitor()\n    await refreshSessions(force: true)\n  }\n\n  func updateNotesRoot(to newURL: URL) async {\n    guard newURL != preferences.notesRoot else { return }\n    SecurityScopedBookmarks.shared.save(url: newURL, for: .notesRoot)\n    preferences.notesRoot = newURL\n    await notesStore.updateRoot(to: newURL)\n    // Reload notes snapshot and re-apply to current sessions\n    let notes = await notesStore.all()\n    notesSnapshot = notes\n    var sessions = allSessions\n    apply(notes: notes, to: &sessions)\n    allSessions = sessions\n    // Avoid publishing during view updates\n    scheduleApplyFilters()\n  }\n\n  func updateProjectsRoot(to newURL: URL) async {\n    guard newURL != preferences.projectsRoot else { return }\n    SecurityScopedBookmarks.shared.save(url: newURL, for: .projectsRoot)\n    preferences.projectsRoot = newURL\n    let p = ProjectsStore.Paths(\n      root: newURL,\n      metadataDir: newURL.appendingPathComponent(\"metadata\", isDirectory: true),\n      membershipsURL: newURL.appendingPathComponent(\"memberships.json\", isDirectory: false)\n    )\n    self.projectsStore = ProjectsStore(paths: p)\n    await geminiProvider.updateProjectsStore(self.projectsStore)\n    await loadProjects()\n    // Avoid publishing changes during view update; schedule on next runloop tick\n    Task { @MainActor [weak self] in\n      guard let self else { return }\n      self.recomputeProjectCounts()\n      self.scheduleApplyFilters()\n    }\n  }\n\n  // Removed: executable path updates – CLI resolution uses PATH\n\n  var totalSessionCount: Int {\n    globalSessionCount\n  }\n\n  // Expose data for navigation helpers\n  func calendarCounts(for monthStart: Date, dimension: DateDimension) -> [Int: Int] {\n    let key = cacheKey(monthStart, dimension)\n    if let cached = monthCountsCache[key] { return cached }\n    let monthKey = Self.monthFormatter.string(from: monthStart)\n    let coverage = dimension == .updated ? monthCoverageMap(for: monthKey) : [:]\n    let counts = Self.computeMonthCounts(\n      sessions: allSessions,\n      monthKey: monthKey,\n      dimension: dimension,\n      dayIndex: sessionDayCache,\n      coverage: coverage)\n    // Update cache synchronously to avoid race conditions\n    monthCountsCache[key] = counts\n    currentMonthKey = key\n    currentMonthDimension = dimension\n    if dimension == .updated {\n      // Use current selected path for accurate cache key\n      triggerCoverageLoad(for: monthStart, dimension: dimension, projectPath: selectedPath)\n    }\n    return counts\n  }\n\n  private func countsForLoadedMonth(dimension: DateDimension) -> [Int: Int] {\n    guard let key = currentMonthKey else { return [:] }\n    let components = key.split(separator: \"|\", maxSplits: 1, omittingEmptySubsequences: false)\n    guard components.count == 2 else { return [:] }\n    let monthKey = String(components[1])\n    return Self.computeMonthCounts(\n      sessions: allSessions,\n      monthKey: monthKey,\n      dimension: dimension,\n      dayIndex: sessionDayCache)\n  }\n\n  func ensureCalendarCounts(for monthStart: Date, dimension: DateDimension) {\n    let key = cacheKey(monthStart, dimension)\n    if monthCountsCache[key] != nil { return }\n    if currentMonthDimension == dimension,\n      let currentKey = currentMonthKey,\n      currentKey == key\n    {\n      let counts = countsForLoadedMonth(dimension: dimension)\n      DispatchQueue.main.async { [weak self] in\n        self?.monthCountsCache[key] = counts\n      }\n      return\n    }\n    let enabledHosts = preferences.enabledRemoteHosts\n    let sessionsRoot = preferences.sessionsRoot\n    Task { [weak self, monthStart, dimension, enabledHosts, sessionsRoot] in\n      guard let self else { return }\n      let started = Date()\n      self.diagLogger.log(\n        \"calendarCounts start month=\\(key, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      var merged = await self.indexer.computeCalendarCounts(\n        root: sessionsRoot, monthStart: monthStart, dimension: dimension)\n      if !enabledHosts.isEmpty {\n        let remoteCodex = await self.remoteProvider.codexSessions(\n          scope: .month(monthStart), enabledHosts: enabledHosts)\n        let remoteClaude = await self.remoteProvider.claudeSessions(\n          scope: .month(monthStart), enabledHosts: enabledHosts)\n        let remoteSessions = remoteCodex + remoteClaude\n        if !remoteSessions.isEmpty {\n          let calendar = Calendar.current\n          for session in remoteSessions {\n            let referenceDate: Date\n            switch dimension {\n            case .created:\n              referenceDate = session.startedAt\n            case .updated:\n              referenceDate = session.lastUpdatedAt ?? session.startedAt\n            }\n            guard calendar.isDate(referenceDate, equalTo: monthStart, toGranularity: .month)\n            else { continue }\n            let day = calendar.component(.day, from: referenceDate)\n            merged[day, default: 0] += 1\n          }\n        }\n      }\n      await MainActor.run {\n        self.monthCountsCache[self.cacheKey(monthStart, dimension)] = merged\n      }\n      let elapsed = Date().timeIntervalSince(started)\n      self.diagLogger.log(\n        \"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))\"\n      )\n    }\n  }\n\n  func cacheKey(_ monthStart: Date, _ dimension: DateDimension) -> String {\n    return dimension.rawValue + \"|\" + Self.monthFormatter.string(from: monthStart)\n  }\n\n  private func coverageCacheKey(\n    _ monthStart: Date, _ dimension: DateDimension, projectPath: String? = nil\n  ) -> String {\n    var key = dimension.rawValue + \"|\" + Self.monthFormatter.string(from: monthStart)\n    if let path = projectPath {\n      key += \"|\" + path\n    }\n    return key\n  }\n\n  var pathTreeRoot: PathTreeNode? { pathTreeRootPublished }\n\n  func ensurePathTree() {\n    if pathTreeRootPublished != nil { return }\n    schedulePathTreeRefresh()\n  }\n\n  private func schedulePathTreeRefresh() {\n    pathTreeRefreshTask?.cancel()\n    pathTreeRefreshTask = Task { [weak self] in\n      guard let self else { return }\n      let started = Date()\n      diagLogger.log(\"pathTreeRefresh start ts=\\(ts(), format: .fixed(precision: 3))\")\n      defer { self.pathTreeRefreshTask = nil }\n      var counts = self.cwdCounts(for: self.allSessions)\n      self.lastPathCounts = counts\n      let enabledHosts = preferences.enabledRemoteHosts\n      if !enabledHosts.isEmpty {\n        let remoteCodex = await remoteProvider.collectCWDAggregates(\n          kind: .codex, enabledHosts: enabledHosts)\n        for (key, value) in remoteCodex {\n          counts[key, default: 0] += value\n        }\n        let remoteClaude = await remoteProvider.collectCWDAggregates(\n          kind: .claude, enabledHosts: enabledHosts)\n        for (key, value) in remoteClaude {\n          counts[key, default: 0] += value\n        }\n      }\n      let tree = await self.pathTreeStore.applySnapshot(counts: counts)\n      await MainActor.run { self.pathTreeRootPublished = tree }\n      let elapsed = Date().timeIntervalSince(started)\n      diagLogger.log(\n        \"pathTreeRefresh done in \\(elapsed, format: .fixed(precision: 3))s counts=\\(counts.count, privacy: .public) ts=\\(ts(), format: .fixed(precision: 3))\"\n      )\n    }\n  }\n\n  private func cwdCounts(for sessions: [SessionSummary]) -> [String: Int] {\n    var counts: [String: Int] = [:]\n    counts.reserveCapacity(sessions.count)\n    for s in sessions { counts[s.cwd, default: 0] += 1 }\n    return counts\n  }\n\n  private func diffCounts(old: [String: Int], new: [String: Int]) -> [String: Int] {\n    var delta: [String: Int] = [:]\n    let keys = Set(old.keys).union(new.keys)\n    for k in keys {\n      let d = (new[k] ?? 0) - (old[k] ?? 0)\n      if d != 0 { delta[k] = d }\n    }\n    return delta\n  }\n\n  private func scheduleCalendarCountsRefresh(\n    monthStart: Date,\n    dimension: DateDimension,\n    skipDebounce: Bool\n  ) {\n    // Legacy path removed; kept for compatibility if future disk scans are reintroduced.\n    // For now, we compute counts synchronously from in-memory sessions.\n    let key = cacheKey(monthStart, dimension)\n    calendarRefreshTasks[key]?.cancel()\n    if !skipDebounce {\n      let delay = sidebarStatsDebounceNanoseconds\n      calendarRefreshTasks[key] = Task { [weak self] in\n        defer { self?.calendarRefreshTasks.removeValue(forKey: key) }\n        try? await Task.sleep(nanoseconds: delay)\n      }\n    }\n  }\n\n  private func triggerCoverageLoad(\n    for monthStart: Date,\n    dimension: DateDimension,\n    projectPath: String? = nil,\n    forceRefresh: Bool = false\n  ) {\n    guard dimension == .updated else { return }\n    let key = coverageCacheKey(monthStart, dimension, projectPath: projectPath)\n\n    // Force refresh: invalidate cache for this scope\n    if forceRefresh {\n      let monthKey = Self.monthFormatter.string(from: monthStart)\n      Task {\n        await ripgrepStore.invalidateCoverage(monthKey: monthKey, projectPath: projectPath)\n      }\n      pendingCoverageMonths.remove(key)\n    }\n\n    // Cancel only this specific key's debounce task (not all of them!)\n    coverageDebounceTasks[key]?.cancel()\n\n    // Debounce: delay execution to avoid triggering too many scans during rapid month switching\n    // Each key has its own debounce task, so switching between different months won't cancel each other\n    coverageDebounceTasks[key] = Task { @MainActor in\n      defer { coverageDebounceTasks.removeValue(forKey: key) }\n      try? await Task.sleep(nanoseconds: 150_000_000)  // 150ms debounce\n      guard !Task.isCancelled else { return }\n\n      if coverageLoadTasks[key] != nil {\n        pendingCoverageMonths.insert(key)\n        return\n      }\n      // Precise query scope: filter by month AND project path\n      let targets = sessionsIntersecting(monthStart: monthStart, projectPath: projectPath)\n      guard !targets.isEmpty else { return }\n\n      coverageLoadTasks[key] = Task.detached(priority: .background) { [weak self] in\n        guard let self else { return }\n        let data = await self.ripgrepStore.dayCoverage(for: monthStart, sessions: targets)\n        guard !Task.isCancelled else { return }\n        await MainActor.run {\n          self.coverageLoadTasks[key]?.cancel()\n          self.coverageLoadTasks.removeValue(forKey: key)\n          if data.isEmpty {\n            if !targets.isEmpty {\n              self.pendingCoverageMonths.insert(key)\n              self.rebuildMonthCounts(for: monthStart, dimension: dimension, skipUIUpdate: true)\n            }\n          } else {\n            self.applyCoverage(monthStart: monthStart, coverage: data)\n          }\n          if self.pendingCoverageMonths.remove(key) != nil {\n            self.triggerCoverageLoad(\n              for: monthStart, dimension: dimension, projectPath: projectPath)\n          }\n        }\n      }\n    }\n  }\n\n  private func requestCoverageIfNeeded(for day: Date) {\n    guard dateDimension == .updated else { return }\n    let monthStart = Self.normalizeMonthStart(day)\n    // Use current selected path for accurate cache key\n    triggerCoverageLoad(for: monthStart, dimension: .updated, projectPath: selectedPath)\n  }\n\n  private func sessionsIntersecting(monthStart: Date, projectPath: String? = nil)\n    -> [SessionSummary]\n  {\n    let calendar = Calendar.current\n    guard let monthEnd = calendar.date(byAdding: DateComponents(month: 1), to: monthStart) else {\n      return []\n    }\n    return allSessions.filter { summary in\n      // Date range filter\n      let start = summary.startedAt\n      let end = summary.lastUpdatedAt ?? summary.startedAt\n      guard end >= monthStart && start < monthEnd else { return false }\n\n      // Project path filter (if specified)\n      if let projectPath = projectPath {\n        return summary.fileURL.path.hasPrefix(projectPath)\n      }\n\n      return true\n    }\n  }\n\n  @MainActor\n  private func applyCoverage(monthStart: Date, coverage: [String: Set<Int>]) {\n    guard !coverage.isEmpty else {\n      rebuildMonthCounts(for: monthStart, dimension: .updated, skipUIUpdate: true)\n      return\n    }\n    let monthKey = monthKey(for: monthStart)\n    var changed = false\n    let validIDs = Set(allSessions.map(\\.id))\n    for (sessionID, days) in coverage {\n      guard validIDs.contains(sessionID) else { continue }\n      let key = SessionMonthCoverageKey(sessionID: sessionID, monthKey: monthKey)\n      if updatedMonthCoverage[key] != days {\n        updatedMonthCoverage[key] = days\n        changed = true\n      }\n    }\n    if changed {\n      invalidateVisibleCountCache()\n    }\n    rebuildMonthCounts(for: monthStart, dimension: .updated, skipUIUpdate: !changed)\n    if changed {\n      scheduleApplyFilters()\n    }\n  }\n\n  private func monthCoverageMap(for monthKey: String) -> [String: Set<Int>] {\n    var map: [String: Set<Int>] = [:]\n    for (key, days) in updatedMonthCoverage where key.monthKey == monthKey {\n      map[key.sessionID] = days\n    }\n    return map\n  }\n\n  private func rebuildMonthCounts(\n    for monthStart: Date, dimension: DateDimension, skipUIUpdate: Bool = false\n  ) {\n    let key = cacheKey(monthStart, dimension)\n    let monthKey = monthKey(for: monthStart)\n    let coverage = dimension == .updated ? monthCoverageMap(for: monthKey) : [:]\n    let counts = Self.computeMonthCounts(\n      sessions: allSessions,\n      monthKey: monthKey,\n      dimension: dimension,\n      dayIndex: sessionDayCache,\n      coverage: coverage)\n    monthCountsCache[key] = counts\n    currentMonthKey = key\n    currentMonthDimension = dimension\n    if !skipUIUpdate {\n      scheduleViewUpdate()\n    }\n  }\n\n  // MARK: - Filter state management\n\n  func setSelectedPath(_ path: String?) {\n    if selectedPath == path { return }\n    selectedPath = path\n  }\n\n  func setSelectedDay(_ day: Date?) {\n    let normalized = day.map { Calendar.current.startOfDay(for: $0) }\n    if selectedDay == normalized { return }\n    suppressFilterNotifications = true\n    selectedDay = normalized\n    if let d = normalized { selectedDays = [d] } else { selectedDays.removeAll() }\n\n    // In Created mode, when selecting a day, ensure the calendar sidebar shows that month\n    // so we only need to load one month's data\n    if dateDimension == .created, let d = normalized {\n      let newMonthStart = Self.normalizeMonthStart(d)\n      if newMonthStart != sidebarMonthStart {\n        sidebarMonthStart = newMonthStart\n      }\n    }\n\n    if let d = normalized {\n      requestCoverageIfNeeded(for: d)\n    }\n\n    suppressFilterNotifications = false\n    // Manually save calendar state since didSet was suppressed\n    windowStateStore.saveCalendarSelection(\n      selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    // Update UI using next-runloop to avoid publishing during view updates\n    scheduleApplyFilters()\n    // After coordinated update of selectedDay/selectedDays, trigger a refresh only in Created mode.\n    if dateDimension == .created {\n      scheduleFilterRefresh(force: true)\n    }\n  }\n\n  // Toggle selection for a specific day (Cmd-click behavior)\n  func toggleSelectedDay(_ day: Date) {\n    let d = Calendar.current.startOfDay(for: day)\n    suppressFilterNotifications = true\n    if selectedDays.contains(d) {\n      selectedDays.remove(d)\n    } else {\n      selectedDays.insert(d)\n    }\n    requestCoverageIfNeeded(for: d)\n    // Keep single-selection reflected in selectedDay; otherwise nil\n    if selectedDays.count == 1, let only = selectedDays.first {\n      selectedDay = only\n    } else if selectedDays.isEmpty {\n      selectedDay = nil\n    } else {\n      selectedDay = nil\n    }\n    suppressFilterNotifications = false\n    // Manually save calendar state since didSet was suppressed\n    windowStateStore.saveCalendarSelection(\n      selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    // Update UI using next-runloop to avoid publishing during view updates\n    scheduleApplyFilters()\n    if dateDimension == .created {\n      scheduleFilterRefresh(force: true)\n    }\n  }\n\n  func clearAllFilters() {\n    suppressFilterNotifications = true\n    selectedPath = nil\n    selectedDay = nil\n    selectedDays.removeAll()\n    selectedProjectIDs.removeAll()\n    suppressFilterNotifications = false\n    // Manually save calendar state since didSet was suppressed\n    windowStateStore.saveCalendarSelection(\n      selectedDay: selectedDay, selectedDays: selectedDays, monthStart: sidebarMonthStart)\n    scheduleSelectionDrivenUpdate()\n    // Keep searchText unchanged to allow consecutive searches\n  }\n\n  // Clear only scope filters (directory and project), keep the date filter intact\n  func clearScopeFilters() {\n    suppressFilterNotifications = true\n    selectedPath = nil\n    selectedProjectIDs.removeAll()\n    suppressFilterNotifications = false\n    scheduleSelectionDrivenUpdate()\n  }\n\n  private func scheduleFiltersUpdate() {\n    filterDebounceTask?.cancel()\n    filterDebounceTask = Task { [weak self] in\n      guard let self else { return }\n      if filterDebounceNanoseconds > 0 {\n        try? await Task.sleep(nanoseconds: filterDebounceNanoseconds)\n      }\n      self.scheduleApplyFilters()\n    }\n  }\n\n  private func scheduleSelectionDrivenUpdate() {\n    let needRefresh = shouldRefreshForSelection()\n    if needRefresh {\n      scheduleFilterRefresh(force: true)\n    } else {\n      scheduleApplyFilters()\n    }\n  }\n\n  private func shouldRefreshForSelection() -> Bool {\n    guard dateDimension == .created else { return false }\n    let projectIsSingle = selectedProjectIDs.count == 1\n    let calendarIsSingle = (selectedDay != nil) || selectedDays.count == 1\n    return projectIsSingle || calendarIsSingle\n  }\n\n  private func singleSelectedProject() -> Set<String>? {\n    guard selectedProjectIDs.count == 1, let first = selectedProjectIDs.first else { return nil }\n    return [first]\n  }\n\n  private func singleSelectedProjectDirectory() -> [String]? {\n    guard let pid = selectedProjectIDs.first, selectedProjectIDs.count == 1 else { return nil }\n    guard let project = projects.first(where: { $0.id == pid }), let dir = project.directory,\n      !dir.isEmpty\n    else {\n      return nil\n    }\n    return [Self.canonicalPath(dir)]\n  }\n\n  private func currentDateRange() -> (Date, Date)? {\n    let cal = Calendar.current\n    var allDays: [Date] = []\n    if let day = selectedDay {\n      allDays.append(cal.startOfDay(for: day))\n    }\n    allDays.append(contentsOf: selectedDays.map { cal.startOfDay(for: $0) })\n    guard let minDay = allDays.min(), let maxDay = allDays.max() else { return nil }\n    let start = minDay\n    guard let end = cal.date(byAdding: .day, value: 1, to: maxDay)?.addingTimeInterval(-1) else {\n      return nil\n    }\n    return (start, end)\n  }\n\n  func applyFilters() {\n    filterTask?.cancel()\n\n    guard !allSessions.isEmpty else {\n      filterTask = nil\n      // Defer sections modification to avoid \"Publishing changes from within view updates\"\n      Task { @MainActor [weak self] in\n        self?.sections = []\n      }\n      return\n    }\n\n    filterGeneration &+= 1\n    let generation = filterGeneration\n    let snapshot = makeFilterSnapshot()\n    let started = Date()\n    logApplyFiltersStart(reason: snapshot.reasonDescription)\n\n    filterTask = Task { [weak self] in\n      guard let self else { return }\n      let computeTask = Task.detached(priority: .userInitiated) {\n        Self.computeFilteredSections(using: snapshot)\n      }\n      defer { computeTask.cancel() }\n      let result = await computeTask.value\n      guard !Task.isCancelled else { return }\n      guard self.filterGeneration == generation else { return }\n\n      // Snapshot hash to skip duplicate work within a short window.\n      let snapshotHash = snapshot.digest\n      if let lastHash = self.lastFilterSnapshotHash, lastHash == snapshotHash {\n        self.logApplyFiltersEnd(\n          reason: snapshot.reasonDescription + \" (skipped same snapshot)\",\n          elapsed: 0,\n          sections: self.sections.count,\n          sessions: self.allSessions.count\n        )\n        self.pendingApplyFilters = false\n        self.filterTask = nil\n        return\n      }\n      self.lastFilterSnapshotHash = snapshotHash\n\n      if !result.newCanonicalEntries.isEmpty {\n        self.canonicalCwdCache.merge(result.newCanonicalEntries) { _, new in new }\n      }\n      // Use pre-computed sections from background task; avoid replacing when identical\n      if self.sections != result.sections {\n        self.sections = result.sections\n      }\n      let elapsed = Date().timeIntervalSince(started)\n      self.logApplyFiltersEnd(\n        reason: snapshot.reasonDescription,\n        elapsed: elapsed,\n        sections: result.sections.count,\n        sessions: result.totalSessions\n      )\n      // If more filter requests were queued while this task ran, flush one more apply.\n      if self.pendingApplyFilters {\n        self.pendingApplyFilters = false\n        // Schedule on next runloop to avoid deep recursion.\n        DispatchQueue.main.async { [weak self] in\n          self?.applyFilters()\n        }\n      } else {\n        self.filterTask = nil\n      }\n    }\n  }\n\n  private func makeFilterSnapshot() -> FilterSnapshot {\n    let pathFilter: FilterSnapshot.PathFilter? = {\n      guard let path = selectedPath else { return nil }\n      let canonical = Self.canonicalPath(path)\n      let prefix = canonical == \"/\" ? \"/\" : canonical + \"/\"\n      return .init(canonicalPath: canonical, prefix: prefix)\n    }()\n\n    let trimmedSearch = quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    let quickNeedle = trimmedSearch.isEmpty ? nil : trimmedSearch.lowercased()\n\n    let projectFilter: FilterSnapshot.ProjectFilter? = {\n      guard !selectedProjectIDs.isEmpty else { return nil }\n      var allowedProjects = Set<String>()\n      for pid in selectedProjectIDs {\n        allowedProjects.insert(pid)\n        allowedProjects.formUnion(collectDescendants(of: pid, in: projects))\n      }\n      let allowedSources = projects.reduce(into: [String: Set<ProjectSessionSource>]()) {\n        $0[$1.id] = $1.sources\n      }\n      return .init(\n        memberships: projectMemberships,\n        allowedProjects: allowedProjects,\n        allowedSourcesByProject: allowedSources,\n        includeUnassigned: allowedProjects.contains(Self.otherProjectId)\n      )\n    }()\n\n    var dayIndexMap: [String: SessionDayIndex] = [:]\n    dayIndexMap.reserveCapacity(allSessions.count)\n    for session in allSessions {\n      dayIndexMap[session.id] = dayIndex(for: session)\n    }\n    let dayDescriptors = Self.makeDayDescriptors(\n      selectedDays: selectedDays,\n      singleDay: selectedDay\n    )\n\n    return FilterSnapshot(\n      sessions: allSessions,\n      sessionsVersion: sessionsVersion,\n      pathFilter: pathFilter,\n      projectFilter: projectFilter,\n      selectedDays: selectedDays,\n      singleDay: selectedDay,\n      dateDimension: dateDimension,\n      quickSearchNeedle: quickNeedle,\n      sortOrder: sortOrder,\n      visibleKinds: preferences.timelineVisibleKinds,\n      canonicalCache: canonicalCwdCache,\n      dayIndex: dayIndexMap,\n      dayCoverage: updatedMonthCoverage,\n      dayDescriptors: dayDescriptors,\n      reasonDescription:\n        \"filters: projects=\\(selectedProjectIDs.count) path=\\(selectedPath ?? \"nil\") days=\\(selectedDays.count) dim=\\(dateDimension.rawValue) search=\\(trimmedSearch.isEmpty ? \"none\" : \"non-empty\") isLoading=\\(isLoading)\"\n    )\n  }\n\n  nonisolated private static func computeFilteredSections(using snapshot: FilterSnapshot)\n    -> FilterComputationResult\n  {\n    var filtered = snapshot.sessions\n    var canonicalCache = snapshot.canonicalCache\n    var newCanonicalEntries: [String: String] = [:]\n\n    if let pathFilter = snapshot.pathFilter {\n      var matches: [SessionSummary] = []\n      matches.reserveCapacity(filtered.count)\n      for summary in filtered {\n        let canonical: String\n        if let cached = canonicalCache[summary.id] {\n          canonical = cached\n        } else {\n          let value = Self.canonicalPath(summary.cwd)\n          canonicalCache[summary.id] = value\n          newCanonicalEntries[summary.id] = value\n          canonical = value\n        }\n        if canonical == pathFilter.canonicalPath || canonical.hasPrefix(pathFilter.prefix) {\n          matches.append(summary)\n        }\n      }\n      filtered = matches\n    }\n\n    if let projectFilter = snapshot.projectFilter {\n      let memberships = projectFilter.memberships\n      let allowedProjects = projectFilter.allowedProjects\n      let allowedSources = projectFilter.allowedSourcesByProject\n      var matches: [SessionSummary] = []\n      matches.reserveCapacity(filtered.count)\n      for summary in filtered {\n        let membershipKey = \"\\(summary.source.projectSource.rawValue)|\\(summary.id)\"\n        if let assigned = memberships[membershipKey] {\n          guard allowedProjects.contains(assigned) else { continue }\n          let allowedSet = allowedSources[assigned] ?? ProjectSessionSource.allSet\n          if allowedSet.contains(summary.source.projectSource) { matches.append(summary) }\n        } else if projectFilter.includeUnassigned {\n          matches.append(summary)\n        }\n      }\n      filtered = matches\n    }\n\n    if !snapshot.dayDescriptors.isEmpty {\n      let calendar = Calendar.current\n      filtered = filtered.filter { summary in\n        let bucket = snapshot.dayIndex[summary.id]\n        return Self.matchesDayDescriptors(\n          summary: summary,\n          bucket: bucket,\n          descriptors: snapshot.dayDescriptors,\n          dimension: snapshot.dateDimension,\n          coverage: snapshot.dayCoverage,\n          calendar: calendar\n        )\n      }\n    }\n\n    if let needle = snapshot.quickSearchNeedle {\n      filtered = filtered.filter { s in\n        if s.effectiveTitle.lowercased().contains(needle) { return true }\n        if let c = s.userComment?.lowercased(), c.contains(needle) { return true }\n        return false\n      }\n    }\n\n    filtered = snapshot.sortOrder.sort(\n      filtered,\n      dimension: snapshot.dateDimension,\n      visibleKinds: snapshot.visibleKinds\n    )\n\n    let sections = Self.groupSessions(\n      filtered,\n      dimension: snapshot.dateDimension,\n      visibleKinds: snapshot.visibleKinds\n    )\n\n    return FilterComputationResult(\n      filteredSessions: filtered,\n      sections: sections,\n      newCanonicalEntries: newCanonicalEntries,\n      totalSessions: filtered.count\n    )\n  }\n\n  nonisolated private static func matchesDayDescriptors(\n    summary: SessionSummary,\n    bucket: SessionDayIndex?,\n    descriptors: [DaySelectionDescriptor],\n    dimension: DateDimension,\n    coverage: [SessionMonthCoverageKey: Set<Int>],\n    calendar: Calendar\n  ) -> Bool {\n    guard let bucket else { return false }\n    for descriptor in descriptors {\n      switch dimension {\n      case .created:\n        if calendar.isDate(bucket.created, inSameDayAs: descriptor.date) {\n          return true\n        }\n      case .updated:\n        let key = SessionMonthCoverageKey(sessionID: summary.id, monthKey: descriptor.monthKey)\n        if let days = coverage[key], days.contains(descriptor.day) {\n          return true\n        }\n        if calendar.isDate(bucket.updated, inSameDayAs: descriptor.date) {\n          return true\n        }\n      }\n    }\n    return false\n  }\n\n  nonisolated private static func referenceDate(\n    for session: SessionSummary, dimension: DateDimension\n  )\n    -> Date\n  {\n    switch dimension {\n    case .created: return session.startedAt\n    case .updated: return session.lastUpdatedAt ?? session.startedAt\n    }\n  }\n\n  private struct FilterSnapshot: Sendable {\n    struct PathFilter: Sendable {\n      let canonicalPath: String\n      let prefix: String\n    }\n\n    struct ProjectFilter: Sendable {\n      let memberships: [String: String]\n      let allowedProjects: Set<String>\n      let allowedSourcesByProject: [String: Set<ProjectSessionSource>]\n      let includeUnassigned: Bool\n    }\n\n    let sessions: [SessionSummary]\n    let sessionsVersion: UInt64\n    let pathFilter: PathFilter?\n    let projectFilter: ProjectFilter?\n    let selectedDays: Set<Date>\n    let singleDay: Date?\n    let dateDimension: DateDimension\n    let quickSearchNeedle: String?\n    let sortOrder: SessionSortOrder\n    let visibleKinds: Set<MessageVisibilityKind>\n    let canonicalCache: [String: String]\n    let dayIndex: [String: SessionDayIndex]\n    let dayCoverage: [SessionMonthCoverageKey: Set<Int>]\n    let dayDescriptors: [DaySelectionDescriptor]\n    let reasonDescription: String\n\n    var digest: Int {\n      var hasher = Hasher()\n      hasher.combine(pathFilter?.canonicalPath ?? \"\")\n      hasher.combine(pathFilter?.prefix ?? \"\")\n      hasher.combine(projectFilter?.allowedProjects.count ?? 0)\n      hasher.combine(selectedDays.count)\n      hasher.combine(singleDay?.timeIntervalSince1970 ?? 0)\n      hasher.combine(dateDimension.rawValue)\n      hasher.combine(quickSearchNeedle ?? \"\")\n      hasher.combine(sortOrder.rawValue)\n      hasher.combine(visibleKinds.count)\n      for value in visibleKinds.rawValues {\n        hasher.combine(value)\n      }\n      hasher.combine(sessionsVersion)\n      return hasher.finalize()\n    }\n  }\n\n  private struct FilterComputationResult: Sendable {\n    let filteredSessions: [SessionSummary]\n    let sections: [SessionDaySection]\n    let newCanonicalEntries: [String: String]\n    let totalSessions: Int\n  }\n\n  nonisolated private static func groupSessions(\n    _ sessions: [SessionSummary],\n    dimension: DateDimension,\n    visibleKinds: Set<MessageVisibilityKind>\n  )\n    -> [SessionDaySection]\n  {\n    let calendar = Calendar.current\n    let formatter = DateFormatter()\n    formatter.locale = Locale(identifier: \"en_US_POSIX\")\n    formatter.dateStyle = .medium\n    formatter.timeStyle = .none\n\n    var grouped: [Date: [SessionSummary]] = [:]\n    for session in sessions {\n      // Grouping honors the selected calendar dimension:\n      // - Created: group by startedAt\n      // - Last Updated: group by lastUpdatedAt (fallback to startedAt)\n      let referenceDate: Date = {\n        switch dimension {\n        case .created: return session.startedAt\n        case .updated: return session.lastUpdatedAt ?? session.startedAt\n        }\n      }()\n      let day = calendar.startOfDay(for: referenceDate)\n      grouped[day, default: []].append(session)\n    }\n\n    return\n      grouped\n      .sorted(by: { $0.key > $1.key })\n      .map { day, sessions in\n        let totalDuration = sessions.reduce(into: 0.0) { $0 += $1.duration }\n        let totalEvents = sessions.reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) }\n        let title: String\n        if calendar.isDateInToday(day) {\n          title = \"Today\"\n        } else if calendar.isDateInYesterday(day) {\n          title = \"Yesterday\"\n        } else {\n          title = formatter.string(from: day)\n        }\n        return SessionDaySection(\n          id: day,\n          title: title,\n          totalDuration: totalDuration,\n          totalEvents: totalEvents,\n          sessions: sessions\n        )\n      }\n  }\n\n  // MARK: - Fulltext search\n\n  private func scheduleFulltextSearchIfNeeded() {\n    scheduleFiltersUpdate()  // update metadata-only matches quickly\n    fulltextTask?.cancel()\n    let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !term.isEmpty else {\n      fulltextMatches.removeAll()\n      return\n    }\n    fulltextTask = Task { [allSessions] in\n      // naive full-scan\n      var matched = Set<String>()\n      for s in allSessions {\n        if Task.isCancelled { return }\n        if await indexer.fileContains(url: s.fileURL, term: term) {\n          matched.insert(s.id)\n        }\n      }\n      await MainActor.run {\n        self.fulltextMatches = matched\n        self.scheduleApplyFilters()\n      }\n    }\n  }\n\n  // MARK: - Calendar caches (placeholder for future optimization)\n  private func computeCalendarCaches() async {}\n\n  // MARK: - Background enrichment\n  private func startBackgroundEnrichment() {\n    enrichmentTask?.cancel()\n    guard let cacheKey = dayCacheKey(for: selectedDay) else {\n      // Should not happen; we now return a synthetic key even when day is nil\n      isEnriching = false\n      enrichmentProgress = 0\n      enrichmentTotal = 0\n      return\n    }\n\n    // When a day is selected, enrich that day's sessions; otherwise enrich currently displayed ones\n    let sessions: [SessionSummary]\n    if selectedDay != nil {\n      sessions = sessionsForCurrentDay()\n    } else {\n      sessions = sections.flatMap { $0.sessions }\n    }\n    let currentIDs = Set(sessions.map(\\.id))\n    if let cached = enrichmentSnapshots[cacheKey], cached == currentIDs {\n      isEnriching = false\n      enrichmentProgress = 0\n      enrichmentTotal = 0\n      return\n    }\n    if sessions.isEmpty {\n      isEnriching = false\n      enrichmentProgress = 0\n      enrichmentTotal = 0\n      enrichmentSnapshots[cacheKey] = currentIDs\n      return\n    }\n    enrichmentTask = Task { [weak self] in\n      guard let self else { return }\n\n      await MainActor.run {\n        self.isEnriching = true\n        self.enrichmentProgress = 0\n        self.enrichmentTotal = sessions.count\n      }\n\n      let concurrency = max(2, ProcessInfo.processInfo.processorCount / 2)\n      try? await withThrowingTaskGroup(of: (String, SessionSummary)?.self) { group in\n        var iterator = sessions.makeIterator()\n        var processedCount = 0\n\n        func addNext(_ n: Int) {\n          for _ in 0..<n {\n            guard let s = iterator.next() else { return }\n            group.addTask { [weak self] in\n              guard let self else { return nil }\n              if s.source.baseKind == .claude {\n                if let enriched = await self.claudeProvider.enrich(summary: s) {\n                  return (s.id, enriched)\n                }\n                return (s.id, s)\n              } else if s.source.baseKind == .gemini {\n                if let enriched = await self.geminiProvider.enrich(summary: s) {\n                  return (s.id, enriched)\n                }\n                return (s.id, s)\n              } else if let enriched = try await self.indexer.enrich(url: s.fileURL) {\n                return (s.id, enriched)\n              }\n              return (s.id, s)\n            }\n          }\n        }\n        addNext(concurrency)\n        var updatesBuffer: [(String, SessionSummary)] = []\n        var lastFlushTime = ContinuousClock.now\n        func flush() async {\n          guard !updatesBuffer.isEmpty else { return }\n          await MainActor.run {\n            var map = Dictionary(\n              uniqueKeysWithValues: self.allSessions.map { ($0.id, $0) })\n            for (id, item) in updatesBuffer {\n              var enriched = item\n              if let note = self.notesSnapshot[id] {\n                enriched.userTitle = note.title\n                enriched.userComment = note.comment\n              }\n              map[id] = enriched\n            }\n            self.allSessions = Array(map.values)\n            self.rebuildCanonicalCwdCache()\n            self.scheduleApplyFilters()\n          }\n          updatesBuffer.removeAll(keepingCapacity: true)\n          lastFlushTime = ContinuousClock.now\n        }\n        while let result = try await group.next() {\n          if let (id, enriched) = result {\n            updatesBuffer.append((id, enriched))\n            processedCount += 1\n\n            await MainActor.run {\n              self.enrichmentProgress = processedCount\n            }\n\n            let now = ContinuousClock.now\n            let elapsed = lastFlushTime.duration(to: now)\n            // Flush if buffer is large (50 items) OR enough time passed (1 second)\n            if updatesBuffer.count >= 50 || elapsed.components.seconds >= 1 {\n              await flush()\n            }\n          }\n          addNext(1)\n        }\n        await flush()\n\n        await MainActor.run {\n          self.isEnriching = false\n          self.enrichmentProgress = 0\n          self.enrichmentTotal = 0\n          self.enrichmentSnapshots[cacheKey] = currentIDs\n        }\n      }\n    }\n  }\n\n  private func sessionsForCurrentDay() -> [SessionSummary] {\n    guard let day = selectedDay else { return [] }\n    let calendar = Calendar.current\n    let pathFilter = selectedPath.map(Self.canonicalPath)\n    return allSessions.filter { summary in\n      let matchesDay: Bool = {\n        switch dateDimension {\n        case .created:\n          return calendar.isDate(summary.startedAt, inSameDayAs: day)\n        case .updated:\n          if let end = summary.lastUpdatedAt {\n            return calendar.isDate(end, inSameDayAs: day)\n          }\n          return calendar.isDate(summary.startedAt, inSameDayAs: day)\n        }\n      }()\n      guard matchesDay else { return false }\n      guard let path = pathFilter else { return true }\n      let canonical = canonicalCwdCache[summary.id] ?? Self.canonicalPath(summary.cwd)\n      return canonical == path || canonical.hasPrefix(path + \"/\")\n    }\n  }\n\n  private func rebuildCanonicalCwdCache() {\n    canonicalCwdCache = Dictionary(\n      uniqueKeysWithValues: allSessions.map {\n        ($0.id, Self.canonicalPath($0.cwd))\n      })\n  }\n\n  func rebuildGeminiProjectHashLookup() {\n    guard preferences.isCLIEnabled(.gemini) else {\n      geminiProjectPathByHash = [:]\n      return\n    }\n    geminiProjectPathByHash = Self.computeGeminiProjectHashes(from: projects)\n  }\n\n  func updateSelection(_ ids: Set<SessionSummary.ID>) {\n    selectedSessionIDs = ids\n  }\n\n  nonisolated static func canonicalPath(_ path: String) -> String {\n    let expanded = (path as NSString).expandingTildeInPath\n    var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path\n    if standardized.count > 1 && standardized.hasSuffix(\"/\") {\n      standardized.removeLast()\n    }\n    return standardized\n  }\n\n  private static func computeGeminiProjectHashes(from projects: [Project]) -> [String: String] {\n    var map: [String: String] = [:]\n    for project in projects {\n      guard let dir = project.directory, !dir.isEmpty else { continue }\n      guard let hash = geminiDirectoryHash(for: dir) else { continue }\n      map[hash] = canonicalPath(dir)\n    }\n    return map\n  }\n\n  private static func geminiDirectoryHash(for directory: String) -> String? {\n    let expanded = (directory as NSString).expandingTildeInPath\n    guard let data = expanded.data(using: .utf8) else { return nil }\n    let digest = SHA256.hash(data: data)\n    return digest.map { String(format: \"%02x\", $0) }.joined()\n  }\n\n  private static func geminiHashComponent(in path: String) -> String? {\n    guard let range = path.range(of: \"/.gemini/tmp/\") else { return nil }\n    let remainder = path[range.upperBound...]\n    guard\n      let candidate = remainder.split(\n        separator: \"/\", maxSplits: 1, omittingEmptySubsequences: false\n      )\n      .first\n    else { return nil }\n    let hash = String(candidate)\n    guard hash.count == 64,\n      hash.range(of: \"^[0-9a-f]+$\", options: .regularExpression) != nil\n    else { return nil }\n    return hash\n  }\n\n  func displayWorkingDirectory(for summary: SessionSummary) -> String {\n    guard summary.source.baseKind == .gemini else { return summary.cwd }\n    if let hash = Self.geminiHashComponent(in: summary.cwd),\n      let resolved = geminiProjectPathByHash[hash]\n    {\n      return resolved\n    }\n    if let hash = Self.geminiHashComponent(in: summary.fileURL.path),\n      let resolved = geminiProjectPathByHash[hash]\n    {\n      return resolved\n    }\n    return summary.cwd\n  }\n\n  func resolvedWorkingDirectory(for summary: SessionSummary) -> String {\n    let candidate: String\n    if summary.source.baseKind == .gemini && !summary.isRemote {\n      candidate = displayWorkingDirectory(for: summary)\n    } else {\n      candidate = summary.cwd\n    }\n    let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines)\n    if !trimmed.isEmpty, FileManager.default.fileExists(atPath: trimmed) {\n      return trimmed\n    }\n    if FileManager.default.fileExists(atPath: summary.cwd) {\n      return summary.cwd\n    }\n    let fallback = summary.fileURL.deletingLastPathComponent().path\n    return fallback.isEmpty ? NSHomeDirectory() : fallback\n  }\n\n  private func currentScope() -> SessionLoadScope {\n    switch dateDimension {\n    case .created:\n      // In Created mode, when a single day is selected, limit the scan\n      // scope to that specific day for better performance and more\n      // predictable behavior when users explicitly focus on \"today\".\n      if let day = selectedDay {\n        return .day(Calendar.current.startOfDay(for: day))\n      }\n      if selectedDays.count == 1, let only = selectedDays.first {\n        return .day(Calendar.current.startOfDay(for: only))\n      }\n      // Fallback: load the month currently being viewed in the calendar\n      // sidebar. Day filtering for the middle list still happens in\n      // applyFilters().\n      return .month(sidebarMonthStart)\n    case .updated:\n      // Updated dimension: load everything since updates can cross month\n      // boundaries and files on disk are organized by creation date.\n      return .all\n    }\n  }\n\n  func overviewAggregateScope() -> OverviewAggregateScope? {\n    if selectedPath != nil { return nil }\n    if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil }\n    if !quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return nil }\n    let projects = selectedProjectIDs.isEmpty ? nil : selectedProjectIDs\n    let cal = Calendar.current\n    var allDays: [Date] = []\n    if let day = selectedDay {\n      allDays.append(cal.startOfDay(for: day))\n    }\n    allDays.append(contentsOf: selectedDays.map { cal.startOfDay(for: $0) })\n    if allDays.isEmpty, let projects {\n      return OverviewAggregateScope(\n        dateDimension: dateDimension,\n        start: Date(timeIntervalSince1970: 0),\n        end: .distantFuture,\n        projectIds: projects\n      )\n    }\n    guard !allDays.isEmpty else { return nil }\n    let start = allDays.min() ?? Date()\n    let endBase = allDays.max() ?? start\n    guard let end = cal.date(byAdding: .day, value: 1, to: endBase)?.addingTimeInterval(-1) else {\n      return nil\n    }\n    return OverviewAggregateScope(\n      dateDimension: dateDimension,\n      start: start,\n      end: end,\n      projectIds: projects?.isEmpty == false ? projects : nil\n    )\n  }\n\n  /// Whether the Overview can safely use global cached aggregates without clashing with filters.\n  var canUseGlobalOverviewAggregate: Bool {\n    if dateDimension != .updated { return false }\n    if selectedDay != nil || !selectedDays.isEmpty { return false }\n    if selectedPath != nil { return false }\n    if !selectedProjectIDs.isEmpty { return false }\n    if !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false }\n    if !quickSearchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return false }\n    return true\n  }\n\n  private func logApplyFiltersStart(reason: String) {\n    diagLogger.log(\n      \"applyFilters start reason=\\(reason, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n    )\n  }\n\n  private func logApplyFiltersEnd(\n    reason: String, elapsed: TimeInterval, sections: Int, sessions: Int\n  ) {\n    diagLogger.log(\n      \"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))\"\n    )\n  }\n\n  private func configureDirectoryMonitor() {\n    directoryMonitor?.cancel()\n    directoryRefreshTask?.cancel()\n    guard preferences.isCLIEnabled(.codex) else {\n      directoryMonitor = nil\n      return\n    }\n    let root = preferences.sessionsRoot\n    guard FileManager.default.fileExists(atPath: root.path) else {\n      directoryMonitor = nil\n      return\n    }\n    directoryMonitor = DirectoryMonitor(url: root) { [weak self] in\n      Task { @MainActor in\n        self?.quickPulse()\n        self?.scheduleDirectoryRefresh()\n      }\n    }\n  }\n\n  private func configureClaudeDirectoryMonitor() {\n    claudeDirectoryMonitor?.cancel()\n    guard preferences.isCLIEnabled(.claude) else {\n      claudeDirectoryMonitor = nil\n      return\n    }\n    // Default Claude projects root: ~/.claude/projects\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    let projects =\n      home\n      .appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n    guard FileManager.default.fileExists(atPath: projects.path) else {\n      claudeDirectoryMonitor = nil\n      return\n    }\n    claudeDirectoryMonitor = DirectoryMonitor(url: projects) { [weak self] in\n      Task { @MainActor in\n        // Only perform targeted incremental refresh when we have a matching hint\n        if let hint = self?.pendingIncrementalHint, Date() < (hint.expiresAt) {\n          await self?.refreshIncremental(using: hint)\n        }\n      }\n    }\n  }\n\n  private func configureGeminiDirectoryMonitor() {\n    geminiDirectoryMonitor?.cancel()\n    guard preferences.isCLIEnabled(.gemini) else {\n      geminiDirectoryMonitor = nil\n      return\n    }\n    // Default Gemini tmp root: ~/.gemini/tmp\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let tmpRoot =\n      home\n      .appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"tmp\", isDirectory: true)\n    guard FileManager.default.fileExists(atPath: tmpRoot.path) else {\n      geminiDirectoryMonitor = nil\n      return\n    }\n    geminiDirectoryMonitor = DirectoryMonitor(url: tmpRoot) { [weak self] in\n      Task { @MainActor in\n        // Trigger incremental refresh for Gemini sessions\n        if let hint = self?.pendingIncrementalHint, Date() < (hint.expiresAt) {\n          await self?.refreshIncremental(using: hint)\n        } else {\n          // Fallback to general refresh\n          self?.quickPulse()\n          self?.scheduleDirectoryRefresh()\n        }\n      }\n    }\n  }\n\n  private func scheduleDirectoryRefresh() {\n    // Use file event aggregation to collect changes within 500-1000ms window\n    lastFileEventAt = Date()\n\n    // Cancel existing aggregation task and start a new one\n    fileEventAggregationTask?.cancel()\n    fileEventAggregationTask = Task { @MainActor [weak self] in\n      guard let self else { return }\n\n      // Aggregate file events: wait 500ms for rapid changes, up to 1000ms total\n      let rapidChangeWindow: UInt64 = 500_000_000  // 500ms\n      let maxAggregationWindow: UInt64 = 1_000_000_000  // 1000ms\n      let startTime = Date()\n\n      // Wait for the rapid change window\n      try? await Task.sleep(nanoseconds: rapidChangeWindow)\n      guard !Task.isCancelled else { return }\n\n      // Check if more events came in recently (within last 100ms)\n      let timeSinceLastEvent = Date().timeIntervalSince(self.lastFileEventAt)\n      if timeSinceLastEvent < 0.1 && Date().timeIntervalSince(startTime) < 1.0 {\n        // More events are coming in, wait a bit more (up to max window)\n        let remainingTime =\n          maxAggregationWindow - UInt64(Date().timeIntervalSince(startTime) * 1_000_000_000)\n        if remainingTime > 0 {\n          try? await Task.sleep(nanoseconds: min(remainingTime, 200_000_000))\n        }\n      }\n\n      guard !Task.isCancelled else { return }\n\n      // Now trigger the refresh with aggregated events\n      if let hint = self.pendingIncrementalHint, Date() < hint.expiresAt {\n        await self.refreshIncremental(using: hint)\n      } else {\n        // First try a targeted refresh for the current selection; fall back to full refresh otherwise\n        if !(await self.refreshSelectedSessions(sessionIds: self.selectedSessionIDs, force: true)) {\n          self.enrichmentSnapshots.removeAll()\n          // Use scope-based debouncing for the refresh\n          self.scheduleFilterRefresh(force: true)\n        }\n      }\n\n      self.fileEventAggregationTask = nil\n    }\n  }\n\n  /// Smart merge: only update allSessions if data actually changed\n  /// This prevents unnecessary UI re-renders when refreshing unchanged data\n  private func smartMergeAllSessions(newSessions: [SessionSummary]) {\n    // Quick check: if counts differ, definitely changed\n    guard allSessions.count == newSessions.count else {\n      allSessions = newSessions\n      return\n    }\n\n    // Build map of old sessions\n    let oldMap = Dictionary(uniqueKeysWithValues: allSessions.map { ($0.id, $0) })\n\n    // Build merged array, preserving unchanged session object references\n    var mergedSessions: [SessionSummary] = []\n    mergedSessions.reserveCapacity(newSessions.count)\n    var hasAnyChanges = false\n\n    for newSession in newSessions {\n      guard let oldSession = oldMap[newSession.id] else {\n        // New session appeared\n        mergedSessions.append(newSession)\n        hasAnyChanges = true\n        continue\n      }\n\n      // Parse Level Protection:\n      // If old session has better parse level than new session (e.g. Enriched vs Metadata),\n      // and file metadata (mtime/size) hasn't changed significantly, KEEP OLD SESSION.\n      if let oldLevel = oldSession.parseLevel, let newLevel = newSession.parseLevel,\n        oldLevel > newLevel\n      {\n        // Check if file is effectively unchanged to justify keeping old data\n        let lastUpdatedMatches =\n          abs(\n            (newSession.lastUpdatedAt ?? Date.distantPast).timeIntervalSince(\n              (oldSession.lastUpdatedAt ?? Date.distantPast))) < 0.1\n        let fileSizeMatches = (newSession.fileSizeBytes ?? 0) == (oldSession.fileSizeBytes ?? 0)\n\n        if lastUpdatedMatches && fileSizeMatches {\n          // Keep high-quality old session\n          mergedSessions.append(oldSession)\n          continue\n        }\n      }\n\n      // Check if this specific session actually changed by comparing key fields\n      // Use file metadata + critical timestamps to avoid false positives from parsing variations\n      let fileSizeMatches = oldSession.fileSizeBytes == newSession.fileSizeBytes\n      let startedAtMatches = oldSession.startedAt == newSession.startedAt\n      let lastUpdatedMatches = oldSession.lastUpdatedAt == newSession.lastUpdatedAt\n\n      // CRITICAL FIX: Fast parsing (buildSummaryFast) only reads first ~64 lines, causing:\n      // - Incomplete counts for tools, messages, etc.\n      // - UI flicker when refresh switches between fast parse (low counts) and full parse (correct counts)\n      // Solution: If file metadata unchanged but ANY count DECREASED, it's fast parse - keep old richer data\n      let fileUnchanged = fileSizeMatches && lastUpdatedMatches\n      let anyCountDecreased =\n        (newSession.userMessageCount < oldSession.userMessageCount\n          || newSession.assistantMessageCount < oldSession.assistantMessageCount\n          || newSession.toolInvocationCount < oldSession.toolInvocationCount)\n\n      if fileUnchanged && anyCountDecreased {\n        // File hasn't changed but counts decreased - this is fast parse, keep old richer data\n        mergedSessions.append(oldSession)\n      } else if fileSizeMatches && startedAtMatches && lastUpdatedMatches\n        && oldSession.userMessageCount == newSession.userMessageCount\n        && oldSession.assistantMessageCount == newSession.assistantMessageCount\n        && oldSession.toolInvocationCount == newSession.toolInvocationCount\n      {\n        // All counts match and file unchanged - truly no change\n        mergedSessions.append(oldSession)\n      } else {\n        // Content actually changed - use new object\n        mergedSessions.append(newSession)\n        hasAnyChanges = true\n      }\n    }\n\n    // Check if IDs changed (sessions added/removed)\n    if Set(oldMap.keys) != Set(mergedSessions.map { $0.id }) {\n      hasAnyChanges = true\n    }\n\n    // Only update if there are actual changes\n    if hasAnyChanges {\n      allSessions = mergedSessions\n    }\n    // If no changes at all, keep the existing allSessions array reference completely unchanged\n  }\n\n  private func invalidateEnrichmentCache(for day: Date?) {\n    if let key = dayCacheKey(for: day) {\n      enrichmentSnapshots.removeValue(forKey: key)\n    }\n  }\n\n  private func dayCacheKey(for day: Date?) -> String? {\n    let pathKey: String = selectedPath.map(Self.canonicalPath) ?? \"*\"\n    if let day {\n      let calendar = Calendar.current\n      let comps = calendar.dateComponents([.year, .month, .day], from: day)\n      guard let year = comps.year, let month = comps.month, let dayComponent = comps.day\n      else {\n        return nil\n      }\n      return \"\\(dateDimension.rawValue)|\\(year)-\\(month)-\\(dayComponent)|\\(pathKey)\"\n    }\n    // No day selected (All): use synthetic cache key to avoid re-enriching repeatedly\n    return \"\\(dateDimension.rawValue)|all|\\(pathKey)\"\n  }\n\n  private func scopeKey(_ scope: SessionLoadScope) -> String {\n    switch scope {\n    case .all: return \"all\"\n    case .today: return \"today\"\n    case .day(let date): return \"day-\\(Int(date.timeIntervalSince1970))\"\n    case .month(let date): return \"month-\\(Int(date.timeIntervalSince1970))\"\n    }\n  }\n\n  private func scheduleFilterRefresh(force: Bool) {\n    let scope = currentScope()\n    let key = scopeKey(scope)\n\n    // Cancel existing task for this scope only (allows different scopes to coexist)\n    scopedRefreshTasks[key]?.cancel()\n    pendingScopeRefreshForce[key] = (pendingScopeRefreshForce[key] ?? false) || force\n\n    if force {\n      if allSessions.isEmpty {\n        sections = []\n      }\n      isLoading = true\n    }\n\n    let task = Task { @MainActor [weak self] in\n      guard let self else { return }\n      // Use longer debounce delay for non-force refreshes to reduce frequency\n      // force=true: 10ms (user-initiated, responsive)\n      // force=false: 300ms (auto-triggered, debounced)\n      let runForce = self.pendingScopeRefreshForce[key] ?? false\n      let debounceNanoseconds: UInt64 = runForce ? 10_000_000 : 300_000_000\n      try? await Task.sleep(nanoseconds: debounceNanoseconds)\n      guard !Task.isCancelled else {\n        self.cleanupScopedTask(key: key)\n        return\n      }\n      self.pendingScopeRefreshForce[key] = nil\n      await self.refreshSessions(force: runForce)\n      self.cleanupScopedTask(key: key)\n    }\n\n    scopedRefreshTasks[key] = task\n    // Keep backward compatibility with existing code that cancels scheduledFilterRefresh\n    scheduledFilterRefresh = task\n  }\n\n  private func cleanupScopedTask(key: String) {\n    scopedRefreshTasks[key] = nil\n    pendingScopeRefreshForce[key] = nil\n  }\n\n  /// Debounced wrapper around refreshSessions to reduce repeated full enumerations.\n  private func scheduleRefreshDebounced(force: Bool) async {\n    refreshDebounceTask?.cancel()\n    pendingRefreshForce = pendingRefreshForce || force\n    refreshDebounceTask = Task { [weak self] in\n      guard let self else { return }\n      // File-event aggregation: coalesce bursts into a single refresh\n      let delay: UInt64 = self.pendingRefreshForce ? 50_000_000 : 500_000_000\n      try? await Task.sleep(nanoseconds: delay)\n      guard !Task.isCancelled else { return }\n      let runForce = self.pendingRefreshForce\n      self.pendingRefreshForce = false\n      await self.refreshSessions(force: runForce)\n    }\n  }\n\n  private func shouldSkipRefresh(scope: SessionLoadScope, force: Bool) -> Bool {\n    let key = scopeKey(scope)\n\n    // force=true (user-initiated): never skip\n    if force {\n      return false\n    }\n\n    // force=false (auto-triggered): check if already executing\n    if activeScopeRefreshes[key] != nil {\n      return true  // Skip if refresh for this scope is already in progress\n    }\n\n    // Skip if just completed (< 200ms) to filter rapid duplicates\n    guard let lastScope = lastRefreshScope, let lastTs = lastRefreshAt else { return false }\n    if lastScope == scope && Date().timeIntervalSince(lastTs) < 0.2 {\n      return true\n    }\n\n    return false\n  }\n\n  private func shouldRefreshSessionsForDateChange(oldValue: Date?, newValue: Date?) -> Bool {\n    // In Updated mode, all sessions are already loaded - no need to refresh\n    guard dateDimension == .created else { return false }\n\n    // In Created mode, only refresh if crossing month boundary\n    guard let old = oldValue, let new = newValue else {\n      return true  // Clearing or first selection\n    }\n\n    let oldMonth = Self.normalizeMonthStart(old)\n    let newMonth = Self.normalizeMonthStart(new)\n    return oldMonth != newMonth  // Only refresh when crossing months\n  }\n\n  private func shouldRefreshSessionsForDaysChange(oldValue: Set<Date>, newValue: Set<Date>) -> Bool\n  {\n    // In Updated mode, all sessions are already loaded - no need to refresh\n    guard dateDimension == .created else { return false }\n\n    // In Created mode, only refresh if any selected day crosses month boundary\n    let oldMonths = Set(oldValue.map { Self.normalizeMonthStart($0) })\n    let newMonths = Set(newValue.map { Self.normalizeMonthStart($0) })\n    return oldMonths != newMonths  // Only refresh when month set changes\n  }\n\n  // MARK: - Quick pulse: cheap, low-latency activity tracking via file mtime\n  private func quickPulse() {\n    let now = Date()\n    guard now.timeIntervalSince(lastQuickPulseAt) > 0.4 else { return }\n    lastQuickPulseAt = now\n    guard !sections.isEmpty else { return }\n    #if canImport(AppKit)\n      guard NSApp?.isActive != false else { return }\n    #endif\n    let displayedSessions = Array(self.sections.flatMap { $0.sessions }.prefix(200))\n    guard !displayedSessions.isEmpty else { return }\n    // Gate by visible rows digest to avoid scanning when the visible set didn't change\n    var hasher = Hasher()\n    for s in displayedSessions { hasher.combine(s.id) }\n    let digest = hasher.finalize()\n    if digest == lastDisplayedDigest { return }\n    lastDisplayedDigest = digest\n    quickPulseTask?.cancel()\n    // Take a snapshot of currently displayed sessions (limit for safety)\n    quickPulseTask = Task.detached { [weak self, displayedSessions] in\n      guard let self else { return }\n      let fm = FileManager.default\n      var modified: [String: Date] = [:]\n      for s in displayedSessions {\n        let path = s.fileURL.path\n        if let attrs = try? fm.attributesOfItem(atPath: path),\n          let m = attrs[.modificationDate] as? Date\n        {\n          modified[s.id] = m\n        }\n      }\n      let snapshot = modified\n      await MainActor.run {\n        let now = Date()\n        var heartbeatChanged = false\n        for (id, m) in snapshot {\n          let previous = self.fileMTimeCache[id]\n          self.fileMTimeCache[id] = m\n          if let previous, m > previous {\n            self.activityHeartbeat[id] = now\n            heartbeatChanged = true\n          }\n        }\n        self.recomputeActiveUpdatingIDs()\n        // Reschedule prune task if heartbeats changed\n        if heartbeatChanged {\n          self.scheduleActivityPruneIfNeeded()\n        }\n      }\n    }\n  }\n\n  private func monthKey(for day: Date?, dimension: DateDimension) -> String? {\n    guard let day else { return nil }\n    let calendar = Calendar.current\n    let comps = calendar.dateComponents([.year, .month], from: day)\n    guard let year = comps.year, let month = comps.month else { return nil }\n    return \"\\(dimension.rawValue)|\\(year)-\\(month)\"\n  }\n\n  // MARK: - Incremental refresh for New\n  func setIncrementalHintForCodexToday(window seconds: TimeInterval = 10) {\n    let day = Calendar.current.startOfDay(for: Date())\n    pendingIncrementalHint = PendingIncrementalRefreshHint(\n      kind: .codexDay(day), expiresAt: Date().addingTimeInterval(seconds))\n  }\n\n  func setIncrementalHintForGeminiToday(window seconds: TimeInterval = 10) {\n    let day = Calendar.current.startOfDay(for: Date())\n    pendingIncrementalHint = PendingIncrementalRefreshHint(\n      kind: .geminiDay(day), expiresAt: Date().addingTimeInterval(seconds))\n  }\n\n  func setIncrementalHintForClaudeProject(directory: String, window seconds: TimeInterval = 120) {\n    let canonical = Self.canonicalPath(directory)\n    pendingIncrementalHint = PendingIncrementalRefreshHint(\n      kind: .claudeProject(canonical),\n      expiresAt: Date().addingTimeInterval(seconds))\n\n    // Point a dedicated monitor at this project's folder to receive events for nested writes.\n    // Claude writes session files inside ~/.claude/projects/<encoded-cwd>/, which are not visible\n    // to a non-recursive top-level directory watcher.\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    let projectsRoot =\n      home\n      .appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n    let encoded = Self.encodeClaudeProjectFolder(from: canonical)\n    let projectURL = projectsRoot.appendingPathComponent(encoded, isDirectory: true)\n    if FileManager.default.fileExists(atPath: projectURL.path) {\n      if let monitor = claudeProjectMonitor {\n        monitor.updateURL(projectURL)\n      } else {\n        claudeProjectMonitor = DirectoryMonitor(url: projectURL) { [weak self] in\n          Task { await self?.refreshIncrementalForClaudeProject(directory: canonical) }\n        }\n      }\n    }\n  }\n\n  // Claude project folder encoding mirrors ClaudeSessionProvider.encodeProjectFolder\n  private static func encodeClaudeProjectFolder(from cwd: String) -> String {\n    let expanded = (cwd as NSString).expandingTildeInPath\n    var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path\n    if standardized.hasSuffix(\"/\") && standardized.count > 1 { standardized.removeLast() }\n    var name = standardized.replacingOccurrences(of: \":\", with: \"-\")\n    name = name.replacingOccurrences(of: \"/\", with: \"-\")\n    if !name.hasPrefix(\"-\") { name = \"-\" + name }\n    return name\n  }\n\n  private func mergeAndApply(_ subset: [SessionSummary]) {\n    guard !subset.isEmpty else { return }\n    var updatedSessions = allSessions\n    var indexById = Dictionary(uniqueKeysWithValues: allSessions.enumerated().map { ($1.id, $0) })\n    var changed = false\n    var newSessions: [SessionSummary] = []\n\n    for var session in subset {\n      if let note = notesSnapshot[session.id] {\n        session.userTitle = note.title\n        session.userComment = note.comment\n      }\n      if let idx = indexById[session.id] {\n        if updatedSessions[idx] != session {\n          updatedSessions[idx] = session\n          changed = true\n        }\n      } else {\n        indexById[session.id] = updatedSessions.count\n        updatedSessions.append(session)\n        newSessions.append(session)\n        changed = true\n        handleAutoAssignIfMatches(session)\n      }\n    }\n\n    guard changed else { return }\n    allSessions = updatedSessions\n    rebuildCanonicalCwdCache()\n\n    var viewNeedsUpdate = false\n    if !newSessions.isEmpty {\n      persistProjectAssignmentsToCache(newSessions)\n      _ = incrementProjectCounts(for: newSessions)\n      viewNeedsUpdate = true\n    }\n    if viewNeedsUpdate {\n      scheduleViewUpdate()\n    }\n    scheduleApplyFilters()\n    // Keep global total based on full scan (Codex + Claude [+ Remote]),\n    // not on currently loaded subset. Recompute asynchronously.\n    Task { await self.refreshGlobalCount() }\n  }\n\n  private func incrementProjectCounts(for newSessions: [SessionSummary]) -> Bool {\n    guard !newSessions.isEmpty else { return false }\n    var updated = projectCounts\n    var changed = false\n    let allowedSourcesByProject = projects.reduce(into: [String: Set<ProjectSessionSource>]()) {\n      $0[$1.id] = $1.sources\n    }\n\n    for session in newSessions {\n      if let projectId = projectId(for: session) {\n        let allowedSources = allowedSourcesByProject[projectId] ?? ProjectSessionSource.allSet\n        guard allowedSources.contains(session.source.projectSource) else { continue }\n        updated[projectId, default: 0] += 1\n        changed = true\n      } else {\n        updated[SessionListViewModel.otherProjectId, default: 0] += 1\n        changed = true\n      }\n    }\n\n    if changed {\n      projectCounts = updated\n    }\n    return changed\n  }\n\n  private func dayOfToday() -> Date { Calendar.current.startOfDay(for: Date()) }\n\n  func refreshIncrementalForNewCodexToday() async {\n    guard preferences.isCLIEnabled(.codex) else { return }\n    do {\n      let codexConfigs = preferences.sessionPathConfigs.filter { $0.kind == .codex && $0.enabled }\n      let codexIgnoredPaths = codexConfigs.flatMap { $0.ignoredSubpaths }\n      let subset = try await indexer.refreshSessions(\n        root: preferences.sessionsRoot,\n        scope: .day(dayOfToday()),\n        dateRange: currentDateRange(),\n        projectIds: singleSelectedProject(),\n        dateDimension: dateDimension,\n        ignoredPaths: codexIgnoredPaths)\n      await MainActor.run { self.mergeAndApply(subset) }\n    } catch {\n      // Swallow errors for incremental path; full refresh will recover if needed.\n    }\n  }\n\n  func refreshIncrementalForGeminiToday() async {\n    guard preferences.isCLIEnabled(.gemini) else { return }\n    do {\n      let geminiConfigs = preferences.sessionPathConfigs.filter { $0.kind == .gemini && $0.enabled }\n      let geminiIgnoredPaths = geminiConfigs.flatMap { $0.ignoredSubpaths }\n      let subset = try await geminiProvider.sessions(\n        scope: .day(dayOfToday()), ignoredPaths: geminiIgnoredPaths)\n      await MainActor.run { self.mergeAndApply(subset) }\n    } catch {\n      diagLogger.error(\n        \"refreshIncrementalForGeminiToday failed: \\(error.localizedDescription, privacy: .public)\")\n    }\n  }\n\n  func refreshIncrementalForClaudeToday() async {\n    guard preferences.isCLIEnabled(.claude) else { return }\n    do {\n      let claudeConfigs = preferences.sessionPathConfigs.filter { $0.kind == .claude && $0.enabled }\n      let claudeIgnoredPaths = claudeConfigs.flatMap { $0.ignoredSubpaths }\n      let subset = try await claudeProvider.sessions(\n        scope: .day(dayOfToday()), ignoredPaths: claudeIgnoredPaths)\n      await MainActor.run { self.mergeAndApply(subset) }\n    } catch {\n      diagLogger.error(\n        \"refreshIncrementalForClaudeToday failed: \\(error.localizedDescription, privacy: .public)\")\n    }\n  }\n\n  func refreshIncrementalForClaudeProject(directory: String) async {\n    guard preferences.isCLIEnabled(.claude) else { return }\n    do {\n      let subset = try await claudeProvider.sessions(inProjectDirectory: directory)\n      await MainActor.run { self.mergeAndApply(subset) }\n    } catch {\n      diagLogger.error(\n        \"refreshIncrementalForClaudeProject failed: \\(error.localizedDescription, privacy: .public)\"\n      )\n    }\n  }\n\n  private func refreshIncremental(using hint: PendingIncrementalRefreshHint) async {\n    switch hint.kind {\n    case .codexDay:\n      await refreshIncrementalForNewCodexToday()\n    case .geminiDay:\n      await refreshIncrementalForGeminiToday()\n    case .claudeProject(let dir):\n      await refreshIncrementalForClaudeProject(directory: dir)\n    }\n  }\n\n  nonisolated private static func computeMonthCounts(\n    sessions: [SessionSummary],\n    monthKey: String,\n    dimension: DateDimension,\n    dayIndex: [String: SessionDayIndex],\n    coverage: [String: Set<Int>] = [:]\n  ) -> [Int: Int] {\n    var counts: [Int: Int] = [:]\n    for session in sessions {\n      guard let bucket = dayIndex[session.id] else { continue }\n      switch dimension {\n      case .created:\n        guard bucket.createdMonthKey == monthKey else { continue }\n        counts[bucket.createdDay, default: 0] += 1\n      case .updated:\n        guard bucket.updatedMonthKey == monthKey else { continue }\n        if let days = coverage[session.id], !days.isEmpty {\n          for day in days { counts[day, default: 0] += 1 }\n        } else {\n          counts[bucket.updatedDay, default: 0] += 1\n        }\n      }\n    }\n    return counts\n  }\n}\n\nextension SessionListViewModel {\n  private func apply(\n    notes: [String: SessionNote], to sessions: inout [SessionSummary]\n  ) {\n    for index in sessions.indices {\n      if let note = notes[sessions[index].id] {\n        sessions[index].userTitle = note.title\n        sessions[index].userComment = note.comment\n      }\n    }\n  }\n\n  func refreshGlobalCount() async {\n    // Fast path: use cached coverage/meta to avoid re-parsing sessions on cold start.\n    if preferences.isCLIEnabled(.codex), let coverage = await indexer.currentCoverage() {\n      await MainActor.run { self.globalSessionCount = coverage.sessionCount }\n      diagLogger.log(\n        \"refreshGlobalCount via coverage count=\\(coverage.sessionCount, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      return\n    }\n    if preferences.isCLIEnabled(.codex), let meta = await indexer.currentMeta() {\n      await MainActor.run { self.globalSessionCount = meta.sessionCount }\n      diagLogger.log(\n        \"refreshGlobalCount via meta count=\\(meta.sessionCount, privacy: .public) ts=\\(self.ts(), format: .fixed(precision: 3))\"\n      )\n      return\n    }\n\n    // Fallback: enumerate cached summaries (or re-index) when no coverage/meta is available.\n    diagLogger.log(\"refreshGlobalCount fallback enumerate summaries\")\n    let codexSummaries: [SessionSummary]\n    if preferences.isCLIEnabled(.codex) {\n      do {\n        if let cached = try await indexer.cachedAllSummaries() {\n          codexSummaries = cached\n        } else {\n          codexSummaries = []\n        }\n      } catch {\n        diagLogger.error(\n          \"refreshGlobalCount failed to read codex cache: \\(error.localizedDescription, privacy: .public)\"\n        )\n        await MainActor.run { self.globalSessionCount = 0 }\n        return\n      }\n    } else {\n      codexSummaries = []\n    }\n\n    let claudeSummaries: [SessionSummary]\n    if preferences.isCLIEnabled(.claude) {\n      let claudeConfigs = preferences.sessionPathConfigs.filter { $0.kind == .claude && $0.enabled }\n      let claudeIgnoredPaths = claudeConfigs.flatMap { $0.ignoredSubpaths }\n      claudeSummaries =\n        (try? await claudeProvider.sessions(scope: .all, ignoredPaths: claudeIgnoredPaths)) ?? []\n    } else {\n      claudeSummaries = []\n    }\n    let geminiSummaries: [SessionSummary]\n    if preferences.isCLIEnabled(.gemini) {\n      let geminiConfigs = preferences.sessionPathConfigs.filter { $0.kind == .gemini && $0.enabled }\n      let geminiIgnoredPaths = geminiConfigs.flatMap { $0.ignoredSubpaths }\n      geminiSummaries =\n        (try? await geminiProvider.sessions(scope: .all, ignoredPaths: geminiIgnoredPaths)) ?? []\n    } else {\n      geminiSummaries = []\n    }\n\n    var idSet = Set<String>()\n    for s in codexSummaries { idSet.insert(s.id) }\n    for s in claudeSummaries { idSet.insert(s.id) }\n    for s in geminiSummaries { idSet.insert(s.id) }\n\n    var total = idSet.count\n    let enabledHosts = preferences.enabledRemoteHosts\n    if !enabledHosts.isEmpty {\n      let startRemote = Date()\n      let codexCount = preferences.isCLIEnabled(.codex)\n        ? await remoteProvider.countSessions(kind: .codex, enabledHosts: enabledHosts)\n        : 0\n      let claudeCount = preferences.isCLIEnabled(.claude)\n        ? await remoteProvider.countSessions(kind: .claude, enabledHosts: enabledHosts)\n        : 0\n      total += codexCount\n      total += claudeCount\n      let elapsed = Date().timeIntervalSince(startRemote)\n      diagLogger.log(\n        \"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))\"\n      )\n    }\n    await MainActor.run { self.globalSessionCount = total }\n  }\n\n  /// Refresh sidebar stats (calendar, path tree, and global count) without forcing session reload.\n  func refreshSidebarStats() async {\n    invalidateCalendarCaches()\n    ensureCalendarCounts(for: sidebarMonthStart, dimension: dateDimension)\n    await refreshPathTreeFromDisk()\n    await refreshGlobalCount()\n  }\n\n  /// Rebuild the path tree from on-disk counts for accurate sidebar navigation.\n  func refreshPathTreeFromDisk() async {\n    let enabledRemoteHostsForCounts = preferences.enabledRemoteHosts\n    let sessionsRootForCounts = preferences.sessionsRoot\n    var counts: [String: Int] = [:]\n\n    if preferences.isCLIEnabled(.codex) {\n      counts = await indexer.collectCWDCounts(root: sessionsRootForCounts)\n    }\n    if preferences.isCLIEnabled(.claude) {\n      let claudeCounts = await claudeProvider.collectCWDCounts()\n      for (key, value) in claudeCounts {\n        counts[key, default: 0] += value\n      }\n    }\n    if preferences.isCLIEnabled(.gemini) {\n      let geminiCounts = await geminiProvider.collectCWDCounts()\n      for (key, value) in geminiCounts {\n        counts[key, default: 0] += value\n      }\n    }\n    if !enabledRemoteHostsForCounts.isEmpty {\n      let remoteCodex = await remoteProvider.collectCWDAggregates(\n        kind: .codex,\n        enabledHosts: enabledRemoteHostsForCounts\n      )\n      for (key, value) in remoteCodex {\n        counts[key, default: 0] += value\n      }\n      let remoteClaude = await remoteProvider.collectCWDAggregates(\n        kind: .claude,\n        enabledHosts: enabledRemoteHostsForCounts\n      )\n      if preferences.isCLIEnabled(.claude) {\n        for (key, value) in remoteClaude {\n          counts[key, default: 0] += value\n        }\n      }\n    }\n\n    let tree = counts.buildPathTreeFromCounts()\n    pathTreeRootPublished = tree\n  }\n\n  /// User-driven refresh for usage status (status capsule tap / Command+R fallback).\n  func requestUsageStatusRefresh(for provider: UsageProviderKind) {\n    if !isCLIEnabled(for: provider) { return }\n    switch provider {\n    case .codex:\n      refreshCodexUsageStatus()\n    case .claude:\n      claudeUsageAutoRefreshEnabled = true\n      refreshClaudeUsageStatus(silent: false)\n    case .gemini:\n      refreshGeminiUsageStatus(silent: false)\n    }\n  }\n\n  func requestUsageStatusRefreshSilently(for provider: UsageProviderKind) {\n    if !isCLIEnabled(for: provider) { return }\n    switch provider {\n    case .codex:\n      refreshCodexUsageStatus(silent: true)\n    case .claude:\n      claudeUsageAutoRefreshEnabled = true\n      refreshClaudeUsageStatus(silent: true)\n    case .gemini:\n      refreshGeminiUsageStatus(silent: true)\n    }\n  }\n\n  /// Refresh usage with a simple throttle window to avoid repeated calls.\n  func requestUsageStatusRefreshThrottled(\n    for provider: UsageProviderKind,\n    triggerDate: Date = Date(),\n    minInterval: TimeInterval = 15\n  ) {\n    if let last = lastUsageRefreshByProvider[provider],\n      triggerDate.timeIntervalSince(last) < minInterval\n    {\n      return\n    }\n    lastUsageRefreshByProvider[provider] = triggerDate\n    requestUsageStatusRefreshSilently(for: provider)\n  }\n\n  private func setInitialClaudePlaceholder() {\n    self.setClaudeUsagePlaceholder(\"Load Claude usage\", action: .refresh)\n  }\n\n  private func isCLIEnabled(for provider: UsageProviderKind) -> Bool {\n    switch provider {\n    case .codex: return preferences.isCLIEnabled(.codex)\n    case .claude: return preferences.isCLIEnabled(.claude)\n    case .gemini: return preferences.isCLIEnabled(.gemini)\n    }\n  }\n\n  private func enabledCLIKindSet() -> Set<SessionSource.Kind> {\n    Self.cliEnabledKindSet(\n      codex: preferences.cliCodexEnabled,\n      claude: preferences.cliClaudeEnabled,\n      gemini: preferences.cliGeminiEnabled\n    )\n  }\n\n  private static func cliEnabledKindSet(\n    codex: Bool,\n    claude: Bool,\n    gemini: Bool\n  ) -> Set<SessionSource.Kind> {\n    var set: Set<SessionSource.Kind> = []\n    if codex { set.insert(.codex) }\n    if claude { set.insert(.claude) }\n    if gemini { set.insert(.gemini) }\n    return set\n  }\n\n  private func trimUsageSnapshotsForDisabledCLIs() {\n    if !preferences.isCLIEnabled(.codex) { usageSnapshots.removeValue(forKey: .codex) }\n    if !preferences.isCLIEnabled(.claude) { usageSnapshots.removeValue(forKey: .claude) }\n    if !preferences.isCLIEnabled(.gemini) { usageSnapshots.removeValue(forKey: .gemini) }\n  }\n\n  private func setClaudeUsagePlaceholder(\n    _ message: String,\n    action: UsageProviderSnapshot.Action? = .refresh,\n    availability: UsageProviderSnapshot.Availability = .empty\n  ) {\n    let snapshot = UsageProviderSnapshot(\n      provider: .claude,\n      title: UsageProviderKind.claude.displayName,\n      availability: availability,\n      metrics: [],\n      updatedAt: nil,\n      statusMessage: message,\n      requiresReauth: false,\n      origin: .builtin,\n      action: action\n    )\n    setUsageSnapshot(.claude, snapshot)\n  }\n\n  private func setGeminiUsagePlaceholder(\n    _ message: String,\n    action: UsageProviderSnapshot.Action? = .refresh,\n    availability: UsageProviderSnapshot.Availability = .empty\n  ) {\n    let snapshot = UsageProviderSnapshot(\n      provider: .gemini,\n      title: UsageProviderKind.gemini.displayName,\n      availability: availability,\n      metrics: [],\n      updatedAt: nil,\n      statusMessage: message,\n      requiresReauth: false,\n      origin: .builtin,\n      action: action\n    )\n    setUsageSnapshot(.gemini, snapshot)\n  }\n\n  private func setCodexUsagePlaceholder(\n    _ message: String,\n    action: UsageProviderSnapshot.Action? = .refresh,\n    availability: UsageProviderSnapshot.Availability = .empty\n  ) {\n    let snapshot = UsageProviderSnapshot(\n      provider: .codex,\n      title: UsageProviderKind.codex.displayName,\n      availability: availability,\n      metrics: [],\n      updatedAt: nil,\n      statusMessage: message,\n      requiresReauth: false,\n      origin: .builtin,\n      action: action\n    )\n    setUsageSnapshot(.codex, snapshot)\n  }\n\n  private func refreshCodexUsageStatus(silent: Bool = false) {\n    codexUsageTask?.cancel()\n    let candidates = latestCodexSessions(limit: 12)\n    codexUsageTask = Task { [weak self] in\n      guard let self else { return }\n      let origin = await self.providerOrigin(for: .codex)\n      guard origin == .builtin else {\n        await MainActor.run {\n          self.codexUsageStatus = nil\n          self.setUsageSnapshot(.codex, Self.thirdPartyUsageSnapshot(for: .codex))\n        }\n        return\n      }\n\n      // Fetch plan type from OAuth API (more reliable than RPC)\n      async let oauthPlanType: String? = Self.fetchCodexOAuthPlanType()\n      async let rpcSnapshot = self.codexAppServerProbe.fetchIfStaleOrNil(maxAge: 90)\n      async let tokenSnapshot: TokenUsageSnapshot? = {\n        guard !candidates.isEmpty else { return nil }\n        if let ripgrepSnapshot = await self.ripgrepStore.latestTokenUsage(in: candidates) {\n          return ripgrepSnapshot\n        }\n        return await Task.detached(priority: .utility) {\n          Self.fallbackTokenUsage(from: candidates)\n        }.value\n      }()\n\n      let oauthPlan = await oauthPlanType\n      let rpc = await rpcSnapshot\n      let snapshot = await tokenSnapshot\n\n      guard !Task.isCancelled else { return }\n      await MainActor.run {\n        // Use OAuth plan type only (no RPC fallback to avoid stale data)\n        let badge = Self.codexPlanBadgeFromOAuth(oauthPlan)\n\n        var codexStatus: CodexUsageStatus?\n        if let snapshot {\n          codexStatus = CodexUsageStatus(snapshot: snapshot)\n        } else if let rpc {\n          // Allow showing Codex quotas even when no recent session logs exist.\n          codexStatus = CodexUsageStatus(\n            updatedAt: rpc.fetchedAt,\n            contextUsedTokens: nil,\n            contextLimitTokens: nil,\n            primaryWindowUsedPercent: rpc.primaryUsedPercent,\n            primaryWindowMinutes: rpc.primaryWindowMinutes,\n            primaryResetAt: rpc.primaryResetAt,\n            secondaryWindowUsedPercent: rpc.secondaryUsedPercent,\n            secondaryWindowMinutes: rpc.secondaryWindowMinutes,\n            secondaryResetAt: rpc.secondaryResetAt\n          )\n        }\n\n        if let rpc, let existing = codexStatus {\n          let mergedUpdatedAt = max(existing.updatedAt, rpc.fetchedAt)\n          codexStatus = existing.overridingRateLimits(\n            updatedAt: mergedUpdatedAt,\n            primaryUsedPercent: rpc.primaryUsedPercent,\n            primaryWindowMinutes: rpc.primaryWindowMinutes,\n            primaryResetAt: rpc.primaryResetAt,\n            secondaryUsedPercent: rpc.secondaryUsedPercent,\n            secondaryWindowMinutes: rpc.secondaryWindowMinutes,\n            secondaryResetAt: rpc.secondaryResetAt\n          )\n        }\n\n        self.codexUsageStatus = codexStatus\n        if let codex = codexStatus {\n          let snapshot = codex.asProviderSnapshot(titleBadge: badge)\n          self.setUsageSnapshot(.codex, snapshot)\n        } else {\n          self.setUsageSnapshot(\n            .codex,\n            UsageProviderSnapshot(\n              provider: .codex,\n              title: UsageProviderKind.codex.displayName,\n              availability: .empty,\n              metrics: [],\n              updatedAt: nil,\n              statusMessage: \"No Codex usage data available yet.\",\n              origin: .builtin,\n              action: .refresh\n            )\n          )\n        }\n      }\n    }\n  }\n\n  private static func codexPlanBadge(from rawPlanType: String?) -> String? {\n    guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty\n    else {\n      return nil\n    }\n    let plan = raw.lowercased()\n    if plan.contains(\"free\") || plan.contains(\"unknown\") { return nil }\n    if plan.contains(\"plus\") { return \"Plus\" }\n    if plan.contains(\"pro\") { return \"Pro\" }\n    if plan.contains(\"team\") { return \"Team\" }\n    if plan.contains(\"enterprise\") { return \"Ent\" }\n    return raw.prefix(1).uppercased() + raw.dropFirst()\n  }\n\n  /// Fetch Codex plan type - matches CodexBar's resolvePlan strategy:\n  /// 1. Prioritize API response (most up-to-date, authoritative)\n  /// 2. Fall back to JWT only if API didn't return a plan type (not if it returned \"free\")\n  /// This avoids using stale JWT data when API successfully returns a plan type\n  private static func fetchCodexOAuthPlanType() async -> String? {\n    // Primary: Try API call first (matches CodexBar's resolvePlan logic)\n    do {\n      let apiPlan = try await CodexOAuthUsageFetcher.fetchPlanType()\n      // If API returned a plan type (even if \"free\"), use it - don't fall back to JWT\n      // This matches CodexBar: \"if let plan = response.planType?.rawValue, !plan.isEmpty { return plan }\"\n      if let apiPlan = apiPlan, !apiPlan.isEmpty {\n        return apiPlan\n      }\n    } catch {\n      // Check if this is a cancellation error (expected when new request cancels old one)\n      // FetchError.networkError wraps the underlying error, so we need to unwrap it\n      var isCancellation = false\n      if let fetchError = error as? CodexOAuthUsageFetcher.FetchError,\n         case .networkError(let underlyingError) = fetchError {\n        isCancellation = (underlyingError as? URLError)?.code == .cancelled\n          || (underlyingError as NSError).domain == NSURLErrorDomain && (underlyingError as NSError).code == NSURLErrorCancelled\n      } else {\n        // Direct URLError check\n        isCancellation = (error as? URLError)?.code == .cancelled\n          || (error as NSError).domain == NSURLErrorDomain && (error as NSError).code == NSURLErrorCancelled\n      }\n      \n      // Only log non-cancellation errors (cancellation is expected behavior)\n      if !isCancellation {\n        NSLog(\"[CodexUsage] OAuth plan type API fetch failed: \\(error.localizedDescription)\")\n      }\n    }\n\n    // Fallback: Parse JWT token only if API didn't return a plan type\n    // This matches CodexBar: JWT is fallback, not used when API returns a value\n    let jwtPlan = CodexOAuthUsageFetcher.fetchPlanTypeFromJWT()\n    if let jwtPlan = jwtPlan, !jwtPlan.isEmpty {\n      return jwtPlan\n    }\n    return nil\n  }\n\n  /// Convert OAuth plan type to display badge (using exact enum matching)\n  private static func codexPlanBadgeFromOAuth(_ planType: String?) -> String? {\n    guard let plan = planType?.lowercased(), !plan.isEmpty else {\n      return nil\n    }\n    // Match exact plan types from OAuth API\n    let badge: String?\n    switch plan {\n    case \"free\", \"guest\", \"free_workspace\":\n      badge = \"Free\"  // Show \"Free\" badge for free users\n    case \"go\":\n      badge = \"Go\"\n    case \"plus\":\n      badge = \"Plus\"\n    case \"pro\":\n      badge = \"Pro\"\n    case \"team\":\n      badge = \"Team\"\n    case \"business\", \"enterprise\":\n      badge = \"Ent\"\n    case \"education\", \"edu\", \"k12\", \"quorum\":\n      badge = \"Edu\"\n    default:\n      // Show first letter capitalized for unknown types\n      badge = plan.prefix(1).uppercased() + plan.dropFirst()\n    }\n    return badge\n  }\n\n  private static func claudePlanBadge(from rawPlanType: String?) -> String? {\n    guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty\n    else {\n      return nil\n    }\n    let plan = raw.lowercased()\n    if plan.contains(\"free\") || plan.contains(\"unknown\") { return nil }\n    if plan.contains(\"max\") { return \"Max\" }\n    if plan.contains(\"pro\") { return \"Pro\" }\n    if plan.contains(\"team\") { return \"Team\" }\n    if plan.contains(\"enterprise\") { return \"Ent\" }\n    return raw.prefix(1).uppercased() + raw.dropFirst()\n  }\n\n  private static func geminiPlanBadge(from rawPlanType: String?) -> String? {\n    guard let raw = rawPlanType?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty\n    else {\n      return nil\n    }\n    let plan = raw.lowercased()\n    if plan.contains(\"free\") || plan.contains(\"unknown\") { return nil }\n    if plan.contains(\"ultra\") { return \"Ultra\" }\n    if plan.contains(\"pro\") { return \"Pro\" }\n    return raw.prefix(1).uppercased() + raw.dropFirst()\n  }\n\n  nonisolated private static func fallbackTokenUsage(from sessions: [SessionSummary])\n    -> TokenUsageSnapshot?\n  {\n    guard !sessions.isEmpty else { return nil }\n    let loader = SessionTimelineLoader()\n    for session in sessions {\n      if let snapshot = loader.loadLatestTokenUsageWithFallback(url: session.fileURL) {\n        return snapshot\n      }\n    }\n    return nil\n  }\n\n  private func latestCodexSessions(limit: Int) -> [SessionSummary] {\n    let sorted =\n      allSessions\n      .filter { $0.source == .codexLocal }\n      .sorted { ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt) }\n    guard !sorted.isEmpty else { return [] }\n    return Array(sorted.prefix(limit))\n  }\n\n  private func refreshClaudeUsageStatus(silent: Bool) {\n    claudeUsageTask?.cancel()\n    claudeUsageTask = Task { [weak self] in\n      guard let self else { return }\n      let origin = await self.providerOrigin(for: .claude)\n      guard origin == .builtin else {\n        await MainActor.run {\n          self.setUsageSnapshot(.claude, Self.thirdPartyUsageSnapshot(for: .claude))\n        }\n        return\n      }\n      let client = self.claudeUsageClient\n      do {\n        let status = try await client.fetchUsageStatus()\n        guard !Task.isCancelled else { return }\n        await MainActor.run {\n          let badge = Self.claudePlanBadge(from: status.planType)\n          NSLog(\"[ClaudeUsage] planType=\\(status.planType ?? \"nil\"), badge=\\(badge ?? \"nil\")\")\n          self.setUsageSnapshot(.claude, status.asProviderSnapshot(titleBadge: badge))\n        }\n      } catch {\n        NSLog(\"[ClaudeUsage] API fetch failed: \\(error)\")\n        guard !Task.isCancelled else { return }\n        let descriptor = Self.claudeUsageErrorState(from: error)\n        if silent {\n          await SystemNotifier.shared.notify(\n            title: \"Claude\",\n            body: descriptor.message\n          )\n        } else {\n          await MainActor.run {\n            self.setUsageSnapshot(\n              .claude,\n              UsageProviderSnapshot(\n                provider: .claude,\n                title: UsageProviderKind.claude.displayName,\n                availability: .empty,\n                metrics: [],\n                updatedAt: nil,\n                statusMessage: descriptor.message,\n                requiresReauth: descriptor.requiresReauth,\n                origin: .builtin,\n                action: descriptor.action\n              )\n            )\n          }\n        }\n      }\n    }\n  }\n\n  private func refreshGeminiUsageStatus(silent: Bool) {\n    geminiUsageTask?.cancel()\n    geminiUsageTask = Task { [weak self] in\n      guard let self else { return }\n      let origin = await self.providerOrigin(for: .gemini)\n      guard origin == .builtin else {\n        await MainActor.run {\n          self.setUsageSnapshot(.gemini, Self.thirdPartyUsageSnapshot(for: .gemini))\n        }\n        return\n      }\n      do {\n        let status = try await self.geminiUsageClient.fetchUsageStatus()\n        guard !Task.isCancelled else { return }\n        await MainActor.run {\n          let badge = Self.geminiPlanBadge(from: status.planType)\n          self.setUsageSnapshot(.gemini, status.asProviderSnapshot(titleBadge: badge))\n        }\n      } catch {\n        NSLog(\"[GeminiUsage] API fetch failed: \\(error)\")\n        guard !Task.isCancelled else { return }\n        let descriptor = Self.geminiUsageErrorState(from: error)\n        if silent {\n          await SystemNotifier.shared.notify(\n            title: \"Gemini\",\n            body: descriptor.message\n          )\n        } else {\n          await MainActor.run {\n            self.setUsageSnapshot(\n              .gemini,\n              UsageProviderSnapshot(\n                provider: .gemini,\n                title: UsageProviderKind.gemini.displayName,\n                availability: .empty,\n                metrics: [],\n                updatedAt: nil,\n                statusMessage: descriptor.message,\n                requiresReauth: descriptor.requiresReauth,\n                origin: .builtin,\n                action: descriptor.action\n              )\n            )\n          }\n        }\n      }\n    }\n  }\n\n  private struct ClaudeUsageErrorDescriptor {\n    var message: String\n    var requiresReauth: Bool\n    var action: UsageProviderSnapshot.Action?\n  }\n\n  private static func claudeUsageErrorState(from error: Error) -> ClaudeUsageErrorDescriptor {\n    guard let clientError = error as? ClaudeUsageAPIClient.ClientError else {\n      return ClaudeUsageErrorDescriptor(\n        message: \"Unable to get Claude usage.\",\n        requiresReauth: false,\n        action: .refresh\n      )\n    }\n    switch clientError {\n    case .credentialNotFound:\n      return ClaudeUsageErrorDescriptor(\n        message: \"Not logged in to Claude. Run claude code to refresh.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .keychainAccessRestricted:\n      return ClaudeUsageErrorDescriptor(\n        message: \"CodMate needs access to Claude login records in the keychain.\",\n        requiresReauth: false,\n        action: .authorizeKeychain\n      )\n    case .malformedCredential, .missingAccessToken:\n      return ClaudeUsageErrorDescriptor(\n        message: \"Claude login information is invalid. Please log in again and refresh.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .credentialExpired:\n      return ClaudeUsageErrorDescriptor(\n        message:\n          \"No Claude usage recently. \",\n        requiresReauth: false,\n        action: .refresh\n      )\n    case .requestFailed(let code):\n      if code == 401 {\n        return ClaudeUsageErrorDescriptor(\n          message: \"Claude rejected the usage request. Please log in again and refresh.\",\n          requiresReauth: true,\n          action: .refresh\n        )\n      }\n      return ClaudeUsageErrorDescriptor(\n        message: \"Claude usage request failed (HTTP \\(code)).\",\n        requiresReauth: false,\n        action: .refresh\n      )\n    case .emptyResponse, .decodingFailed:\n      return ClaudeUsageErrorDescriptor(\n        message: \"Unable to parse Claude usage temporarily. Please try again later.\",\n        requiresReauth: false,\n        action: .refresh\n      )\n    }\n  }\n\n  private struct GeminiUsageErrorDescriptor {\n    var message: String\n    var requiresReauth: Bool\n    var action: UsageProviderSnapshot.Action?\n  }\n\n  private static func geminiUsageErrorState(from error: Error) -> GeminiUsageErrorDescriptor {\n    guard let clientError = error as? GeminiUsageAPIClient.ClientError else {\n      return GeminiUsageErrorDescriptor(\n        message: \"Unable to get Gemini usage.\",\n        requiresReauth: false,\n        action: .refresh\n      )\n    }\n\n    switch clientError {\n    case .credentialNotFound:\n      return GeminiUsageErrorDescriptor(\n        message: \"Not logged in to Gemini. Run gemini CLI to refresh and retry.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .keychainAccess(let status):\n      return GeminiUsageErrorDescriptor(\n        message: SecCopyErrorMessageString(status, nil) as String? ?? \"Keychain access denied.\",\n        requiresReauth: false,\n        action: .authorizeKeychain\n      )\n    case .malformedCredential, .missingAccessToken:\n      return GeminiUsageErrorDescriptor(\n        message: \"Gemini login info is invalid. Please log in again.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .credentialExpired(let date):\n      let formatter = DateFormatter()\n      formatter.dateStyle = .medium\n      formatter.timeStyle = .short\n      return GeminiUsageErrorDescriptor(\n        message: \"Gemini login expired on \\(formatter.string(from: date)).\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .projectNotFound:\n      return GeminiUsageErrorDescriptor(\n        message: \"Gemini project not found. Run gemini login or set GOOGLE_CLOUD_PROJECT.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .unsupportedAuthType(let authType):\n      return GeminiUsageErrorDescriptor(\n        message: \"Gemini \\(authType) auth is not supported. Please log in with Google.\",\n        requiresReauth: true,\n        action: .refresh\n      )\n    case .requestFailed(let code):\n      let needsLogin = code == 401 || code == 403\n      return GeminiUsageErrorDescriptor(\n        message: needsLogin\n          ? \"Gemini rejected the usage request. Please log in again.\"\n          : \"Gemini usage request failed (HTTP \\(code)).\",\n        requiresReauth: needsLogin,\n        action: .refresh\n      )\n    case .emptyResponse, .decodingFailed:\n      return GeminiUsageErrorDescriptor(\n        message: \"Unable to parse Gemini usage temporarily. Please try again later.\",\n        requiresReauth: false,\n        action: .refresh\n      )\n    }\n  }\n\n  private func setUsageSnapshot(_ provider: UsageProviderKind, _ new: UsageProviderSnapshot) {\n    if let old = usageSnapshots[provider], Self.usageSnapshotCoreEqual(old, new) {\n      return\n    }\n    usageSnapshots[provider] = new\n  }\n\n  private func autoRefreshUsageIfNeeded(\n    codexOrigin: UsageProviderOrigin,\n    claudeOrigin: UsageProviderOrigin,\n    geminiOrigin: UsageProviderOrigin\n  ) {\n    guard !didAutoRefreshUsage else { return }\n    didAutoRefreshUsage = true\n\n    let shouldRefresh: (UsageProviderKind) -> Bool = { provider in\n      guard let snapshot = self.usageSnapshots[provider] else { return true }\n      if snapshot.origin == .thirdParty { return false }\n      if snapshot.availability == .ready { return false }\n      // If availability is .comingSoon, it means refresh is already in progress (placeholder set)\n      if snapshot.availability == .comingSoon { return false }\n      return snapshot.updatedAt == nil\n    }\n\n    // Only refresh if not already refreshing (avoid duplicate refresh from refreshSessionsForProviderChange)\n    if preferences.isCLIEnabled(.codex), codexOrigin == .builtin, shouldRefresh(.codex) {\n      refreshCodexUsageStatus()\n    }\n    if preferences.isCLIEnabled(.claude), claudeOrigin == .builtin, shouldRefresh(.claude) {\n      refreshClaudeUsageStatus(silent: false)\n    }\n    if preferences.isCLIEnabled(.gemini), geminiOrigin == .builtin, shouldRefresh(.gemini) {\n      refreshGeminiUsageStatus(silent: false)\n    }\n  }\n\n  private static func usageSnapshotCoreEqual(_ a: UsageProviderSnapshot, _ b: UsageProviderSnapshot)\n    -> Bool\n  {\n    if a.origin != b.origin { return false }\n    if a.availability != b.availability { return false }\n    if a.statusMessage != b.statusMessage { return false }\n    if a.action != b.action { return false }\n    if a.titleBadge != b.titleBadge { return false }  // Compare titleBadge\n    let au = a.updatedAt?.timeIntervalSinceReferenceDate\n    let bu = b.updatedAt?.timeIntervalSinceReferenceDate\n    if au != bu { return false }\n    let ap = a.urgentMetric()?.progress\n    let bp = b.urgentMetric()?.progress\n    if ap != bp { return false }\n    let ar = a.urgentMetric()?.resetDate?.timeIntervalSinceReferenceDate\n    let br = b.urgentMetric()?.resetDate?.timeIntervalSinceReferenceDate\n    return ar == br\n  }\n\n  private func providerOrigin(for provider: UsageProviderKind) async -> UsageProviderOrigin {\n    if provider == .gemini {\n      // Gemini usage is always treated as built-in; no third-party override today.\n      return .builtin\n    }\n    let consumer: ProvidersRegistryService.Consumer = {\n      switch provider {\n      case .codex: return .codex\n      case .claude: return .claudeCode\n      case .gemini: return .codex\n      }\n    }()\n    let bindings = await providersRegistry.getBindings()\n    if let raw = bindings.activeProvider?[consumer.rawValue]?.trimmingCharacters(\n      in: .whitespacesAndNewlines),\n      !raw.isEmpty\n    {\n      return .thirdParty\n    }\n    return .builtin\n  }\n\n  private static func thirdPartyUsageSnapshot(for provider: UsageProviderKind)\n    -> UsageProviderSnapshot\n  {\n    UsageProviderSnapshot(\n      provider: provider,\n      title: provider.displayName,\n      availability: .empty,\n      metrics: [],\n      updatedAt: nil,\n      statusMessage: \"Usage data isn't available while a custom provider is active.\",\n      origin: .thirdParty\n    )\n  }\n\n  // MARK: - Sandbox Permission Helpers\n\n  /// Ensure we have access to sessions directories in sandbox mode\n  private func ensureSessionsAccess() async {\n    guard SecurityScopedBookmarks.shared.isSandboxed else { return }\n\n    // Check if sessions root path is under a known required directory\n    let sessionsPath = preferences.sessionsRoot.path\n    let realHome = getRealUserHome()\n    let normalizedPath = sessionsPath.replacingOccurrences(of: \"~\", with: realHome)\n\n    // Try to start access for Codex directory if sessions root is under ~/.codex\n    if normalizedPath.hasPrefix(realHome + \"/.codex\") {\n      SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .codexSessions)\n    }\n\n    // Try to start access for Claude directory if needed\n    SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .claudeSessions)\n    // Try to start access for Gemini directory if needed\n    SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .geminiSessions)\n\n    // Try to start access for CodMate directory if needed\n    SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .codmateData)\n\n    // Ensure SSH config directory access so remote mirroring can read keys/config\n    SandboxPermissionsManager.shared.startAccessingIfAuthorized(directory: .sshConfig)\n  }\n\n  /// Get the real user home directory (not sandbox container)\n  private func getRealUserHome() -> String {\n    if let homeDir = getpwuid(getuid())?.pointee.pw_dir {\n      return String(cString: homeDir)\n    }\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n      return home\n    }\n    return NSHomeDirectory()\n  }\n\n  func timeline(for summary: SessionSummary) async -> [ConversationTurn] {\n    if summary.source.baseKind == .claude {\n      return await claudeProvider.timeline(for: summary) ?? []\n    } else if summary.source.baseKind == .gemini {\n      return await geminiProvider.timeline(for: summary) ?? []\n    }\n    let loader = SessionTimelineLoader()\n    return (try? loader.load(url: summary.fileURL)) ?? []\n  }\n\n  // MARK: - Timeline Cache (in-memory)\n\n  func cachedTimeline(for summary: SessionSummary) async -> [ConversationTurn]? {\n    guard let entry = timelineCache[summary.id] else { return nil }\n    let signature = timelineCacheSignature(for: summary)\n    guard entry.signature == signature else { return nil }\n    return entry.turns\n  }\n\n  func storeTimeline(_ turns: [ConversationTurn], for summary: SessionSummary) async {\n    let signature = timelineCacheSignature(for: summary)\n    timelineCache[summary.id] = TimelineCacheEntry(signature: signature, turns: turns)\n  }\n\n  private func timelineCacheSignature(for summary: SessionSummary) -> TimelineCacheSignature {\n    // Prefer on-disk metadata for local sessions; fall back to summary hints.\n    if !summary.source.isRemote,\n      let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path)\n    {\n      let mtime = attrs[.modificationDate] as? Date\n      let size = (attrs[.size] as? NSNumber)?.uint64Value\n      return TimelineCacheSignature(modifiedAt: mtime, fileSize: size)\n    }\n    return TimelineCacheSignature(\n      modifiedAt: summary.lastUpdatedAt, fileSize: summary.fileSizeBytes)\n  }\n\n  // MARK: - Timeline Previews\n\n  /// Load lightweight timeline previews from cache. Returns nil if cache is invalid or missing.\n  func loadTimelinePreviews(for summary: SessionSummary) async -> [ConversationTurnPreview]? {\n    // Get file attributes for mtime validation\n    guard let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path),\n      let mtime = attrs[.modificationDate] as? Date\n    else {\n      return nil\n    }\n\n    let size = (attrs[.size] as? NSNumber)?.uint64Value\n\n    // Fetch from SQLite cache\n    let previews = try? await indexer.fetchTimelinePreviews(\n      sessionId: summary.id,\n      fileModificationTime: mtime,\n      fileSize: size\n    )\n\n    return previews\n  }\n\n  /// Update timeline preview cache for a session\n  func updateTimelinePreviews(for summary: SessionSummary, turns: [ConversationTurn]) async {\n    guard let attrs = try? FileManager.default.attributesOfItem(atPath: summary.fileURL.path),\n      let mtime = attrs[.modificationDate] as? Date\n    else {\n      return\n    }\n\n    let size = (attrs[.size] as? NSNumber)?.uint64Value\n\n    // Convert turns to previews\n    let previews = turns.enumerated().map { index, turn in\n      ConversationTurnPreview(from: turn, sessionId: summary.id, index: index)\n    }\n\n    // Store in SQLite\n    do {\n      try await indexer.upsertTimelinePreviews(\n        previews,\n        sessionId: summary.id,\n        fileModificationTime: mtime,\n        fileSize: size\n      )\n      diagLogger.log(\n        \"Timeline previews cached for session \\(summary.id, privacy: .public): \\(previews.count, privacy: .public) turns\"\n      )\n    } catch {\n      diagLogger.error(\n        \"Failed to cache timeline previews for \\(summary.id, privacy: .public): \\(error.localizedDescription, privacy: .public)\"\n      )\n    }\n  }\n\n  func ripgrepDiagnostics() async -> SessionRipgrepStore.Diagnostics {\n    await ripgrepStore.diagnostics()\n  }\n\n  func rebuildRipgrepIndexes() async {\n    coverageDebounceTasks.values.forEach { $0.cancel() }\n    coverageDebounceTasks.removeAll()\n    coverageLoadTasks.values.forEach { $0.cancel() }\n    coverageLoadTasks.removeAll()\n    await ripgrepStore.resetAll()\n    updatedMonthCoverage.removeAll()\n    monthCountsCache.removeAll()\n    scheduleViewUpdate()\n    if dateDimension == .updated {\n      // Use current selected path for accurate cache key\n      triggerCoverageLoad(\n        for: sidebarMonthStart, dimension: dateDimension, projectPath: selectedPath)\n    }\n    scheduleApplyFilters()\n  }\n\n  /// Fully rebuild the session index (in-memory + on-disk caches) by\n  /// clearing cached summaries and forcing a full refresh from JSONL logs.\n  func rebuildSessionIndex() async {\n    await indexer.resetAllCaches()\n    enrichmentSnapshots.removeAll()\n    await refreshSessions(force: true)\n  }\n\n  /// Force refresh coverage for current view scope (Cmd+R)\n  func forceRefreshCurrentScope() async {\n    let projectPath = selectedPath\n    let monthStart = sidebarMonthStart\n\n    // Cancel ongoing tasks for this scope\n    let key = coverageCacheKey(monthStart, dateDimension, projectPath: projectPath)\n    coverageDebounceTasks[key]?.cancel()\n    coverageDebounceTasks.removeValue(forKey: key)\n    coverageLoadTasks[key]?.cancel()\n    coverageLoadTasks.removeValue(forKey: key)\n\n    // Clear cache for this scope\n    monthCountsCache.removeValue(forKey: cacheKey(monthStart, dateDimension))\n\n    // Trigger fresh scan\n    if dateDimension == .updated {\n      triggerCoverageLoad(\n        for: monthStart,\n        dimension: dateDimension,\n        projectPath: projectPath,\n        forceRefresh: true\n      )\n    }\n\n    scheduleApplyFilters()\n  }\n\n  /// Notify that a session file has been modified (for incremental cache invalidation)\n  func notifySessionFileModified(at fileURL: URL) async {\n    await ripgrepStore.markFileModified(fileURL.path)\n  }\n\n  // Invalidate all cached monthly counts; next access will recompute\n  func invalidateCalendarCaches() {\n    monthCountsCache.removeAll()\n    scheduleViewUpdate()\n  }\n  private func performInitialRemoteSyncIfNeeded() async {\n    guard !preferences.enabledRemoteHosts.isEmpty else { return }\n    // Don't force refresh on launch - let user trigger refresh via Command+R or filesystem events\n    await syncRemoteHosts(force: false, refreshAfter: false)\n  }\n\n  func syncRemoteHosts(force: Bool = true, refreshAfter: Bool = true) async {\n    let enabledHosts = preferences.enabledRemoteHosts\n    guard !enabledHosts.isEmpty else { return }\n    let hostCount = enabledHosts.count\n    AppLogger.shared.info(\n      \"Syncing \\(hostCount) remote host\\(hostCount == 1 ? \"\" : \"s\")\", source: \"Remote\")\n    await remoteProvider.syncHosts(enabledHosts, force: force)\n    await updateRemoteSyncStates()\n    AppLogger.shared.success(\"Remote sync complete\", source: \"Remote\")\n    if refreshAfter {\n      await refreshSessions(force: true)\n    }\n  }\n\n  private func updateRemoteSyncStates() async {\n    let snapshot = await remoteProvider.syncStatusSnapshot()\n    await MainActor.run {\n      self.remoteSyncStates = snapshot\n    }\n  }\n}\n\nextension SessionListViewModel {\n  private func membershipKey(for id: String, source: ProjectSessionSource) -> String {\n    \"\\(source.rawValue)|\\(id)\"\n  }\n\n  private func membershipKey(for summary: SessionSummary) -> String {\n    membershipKey(for: summary.id, source: summary.source.projectSource)\n  }\n\n  func projectId(for summary: SessionSummary) -> String? {\n    projectMemberships[membershipKey(for: summary)]\n  }\n\n  func projectId(for sessionId: String, source: ProjectSessionSource) -> String? {\n    projectMemberships[membershipKey(for: sessionId, source: source)]\n  }\n\n  func cachedInstructions(for summary: SessionSummary) async -> String? {\n    let projectId = projectId(for: summary)\n    let keys = SessionIndexSQLiteStore.candidateProjectKeys(projectId: projectId, cwd: summary.cwd)\n    return await indexer.cachedInstructions(forKeys: keys)\n  }\n\n  func sessionSummary(for id: String) -> SessionSummary? {\n    sessionLookup[id]\n  }\n\n  func sessionDragIdentifier(for summary: SessionSummary) -> String {\n    \"session::\\(summary.source.projectSource.rawValue)::\\(summary.id)\"\n  }\n\n  func sessionAssignment(forIdentifier identifier: String) -> SessionAssignment? {\n    if let parsed = parseSessionIdentifier(identifier) {\n      return parsed\n    }\n    if let summary = sessionSummary(for: identifier) {\n      return SessionAssignment(id: summary.id, source: summary.source.projectSource)\n    }\n    return SessionAssignment(id: identifier, source: .codex)\n  }\n\n  private func parseSessionIdentifier(_ value: String) -> SessionAssignment? {\n    let parts = value.components(separatedBy: \"::\")\n    guard parts.count == 3, parts[0] == \"session\" else { return nil }\n    guard let source = ProjectSessionSource(rawValue: parts[1]) else { return nil }\n    return SessionAssignment(id: parts[2], source: source)\n  }\n\n  // MARK: - Task Management\n\n  /// Automatically assign unassigned sessions to the \"Others\" task\n  private func autoAssignSessionsToOthersTask() {\n    Task { [weak self] in\n      guard let self else { return }\n      let sessions = await MainActor.run { self.allSessions }\n\n      for session in sessions {\n        // Check if session is already assigned to a task\n        let taskId = await self.tasksStore.taskId(for: session.id)\n        if taskId == nil {\n          // Not assigned to any task, assign to Others\n          await self.tasksStore.assignToOthers(sessionId: session.id)\n        }\n      }\n    }\n  }\n\n  /// Get the task ID for a given session\n  func getTaskId(for sessionId: String) async -> UUID? {\n    return await tasksStore.taskId(for: sessionId)\n  }\n\n  /// Get all tasks\n  func getTasks() async -> [CodMateTask] {\n    return await tasksStore.listTasks()\n  }\n\n  /// Get tasks for a specific project\n  func getTasks(for projectId: String) async -> [CodMateTask] {\n    return await tasksStore.listTasks(for: projectId)\n  }\n\n  /// Create a new task\n  func createTask(_ task: CodMateTask) async {\n    await tasksStore.upsertTask(task)\n  }\n\n  /// Update an existing task\n  func updateTask(_ task: CodMateTask) async {\n    await tasksStore.upsertTask(task)\n  }\n\n  /// Delete a task\n  func deleteTask(id: UUID) async {\n    await tasksStore.deleteTask(id: id)\n  }\n\n  /// Assign sessions to a task\n  func assignSessions(_ sessionIds: [String], to taskId: UUID?) async {\n    await tasksStore.assignSessions(sessionIds, to: taskId)\n  }\n}\n"
  },
  {
    "path": "models/SessionLoadScope.swift",
    "content": "import Foundation\n\nenum SessionLoadScope: Equatable, Sendable {\n    case today\n    case day(Date)    // startOfDay\n    case month(Date)  // first day of month\n    case all\n}\n"
  },
  {
    "path": "models/SessionNavigation.swift",
    "content": "import Foundation\n\nenum SessionNavigationItem: Hashable, Identifiable {\n    case allSessions\n    case calendarDay(Date)     // startOfDay\n    case pathPrefix(String)    // absolute directory path prefix\n\n    var id: String {\n        switch self {\n        case .allSessions:\n            return \"all\"\n        case let .calendarDay(day):\n            return \"day-\\(ISO8601DateFormatter().string(from: day))\"\n        case let .pathPrefix(prefix):\n            return \"path-\\(prefix)\"\n        }\n    }\n\n    var title: String {\n        switch self {\n        case .allSessions:\n            return \"All Sessions\"\n        case let .calendarDay(day):\n            let df = DateFormatter()\n            df.dateStyle = .medium\n            df.timeStyle = .none\n            return df.string(from: day)\n        case let .pathPrefix(prefix):\n            return URL(fileURLWithPath: prefix, isDirectory: true).lastPathComponent\n        }\n    }\n\n    var systemImage: String {\n        switch self {\n        case .allSessions:\n            return \"tray.full\"\n        case .calendarDay:\n            return \"calendar\"\n        case .pathPrefix:\n            return \"folder\"\n        }\n    }\n}\n"
  },
  {
    "path": "models/SessionPathConfig.swift",
    "content": "import Foundation\n\nstruct SessionPathConfig: Codable, Identifiable, Hashable, Sendable {\n    let id: String\n    let kind: SessionSource.Kind\n    var path: String\n    var enabled: Bool\n    var displayName: String?\n    var ignoredSubpaths: [String]\n    var disabledSubpaths: Set<String>\n\n    var isDefault: Bool {\n        displayName != nil\n    }\n\n    init(\n        id: String = UUID().uuidString,\n        kind: SessionSource.Kind,\n        path: String,\n        enabled: Bool = true,\n        displayName: String? = nil,\n        ignoredSubpaths: [String] = [],\n        disabledSubpaths: Set<String> = []\n    ) {\n        self.id = id\n        self.kind = kind\n        self.path = path\n        self.enabled = enabled\n        self.displayName = displayName\n        self.ignoredSubpaths = ignoredSubpaths\n        self.disabledSubpaths = disabledSubpaths\n    }\n\n    // Custom Codable implementation for backward compatibility\n    enum CodingKeys: String, CodingKey {\n        case id, kind, path, enabled, displayName, ignoredSubpaths, disabledSubpaths\n    }\n\n    init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        id = try container.decode(String.self, forKey: .id)\n        kind = try container.decode(SessionSource.Kind.self, forKey: .kind)\n        path = try container.decode(String.self, forKey: .path)\n        enabled = try container.decode(Bool.self, forKey: .enabled)\n        displayName = try container.decodeIfPresent(String.self, forKey: .displayName)\n        ignoredSubpaths =\n            try container.decodeIfPresent([String].self, forKey: .ignoredSubpaths) ?? []\n        // Backward compatibility: if disabledSubpaths is missing, default to empty set\n        disabledSubpaths =\n            try container.decodeIfPresent(Set<String>.self, forKey: .disabledSubpaths) ?? []\n    }\n\n    func encode(to encoder: Encoder) throws {\n        var container = encoder.container(keyedBy: CodingKeys.self)\n        try container.encode(id, forKey: .id)\n        try container.encode(kind, forKey: .kind)\n        try container.encode(path, forKey: .path)\n        try container.encode(enabled, forKey: .enabled)\n        try container.encodeIfPresent(displayName, forKey: .displayName)\n        try container.encode(ignoredSubpaths, forKey: .ignoredSubpaths)\n        try container.encode(disabledSubpaths, forKey: .disabledSubpaths)\n    }\n}\n"
  },
  {
    "path": "models/SessionSource+CaseIterable.swift",
    "content": "import Foundation\n\nextension SessionSource.Kind: CaseIterable {\n  static var allCases: [SessionSource.Kind] { [.codex, .claude, .gemini] }\n}\n"
  },
  {
    "path": "models/SessionSummary.swift",
    "content": "import Foundation\n\nstruct SessionTokenBreakdown: Codable, Hashable, Sendable {\n    let input: Int\n    let output: Int\n    let cacheRead: Int\n    let cacheCreation: Int\n\n    /// Total tokens = input + output (cache_read is already included in input, just marked for billing discount)\n    var total: Int { input + output }\n}\n\nstruct SessionSummary: Identifiable, Hashable, Sendable, Codable {\n    let id: String\n    let fileURL: URL\n    let fileSizeBytes: UInt64?\n    let startedAt: Date\n    let endedAt: Date?\n    // Sum of actual active conversation segments (user → Codex),\n    // computed from grouped timeline turns during enrichment.\n    // Nil until enriched; falls back to (endedAt - startedAt) in UI when nil.\n    let activeDuration: TimeInterval?\n    let cliVersion: String\n    let cwd: String\n    let originator: String\n    let instructions: String?\n    let model: String?\n    let approvalPolicy: String?\n    let userMessageCount: Int\n    let assistantMessageCount: Int\n    var toolInvocationCount: Int\n    let responseCounts: [String: Int]\n    let turnContextCount: Int\n    var messageTypeCounts: [String: Int]? = nil\n    let totalTokens: Int?\n    var tokenBreakdown: SessionTokenBreakdown? = nil\n    let eventCount: Int\n    let lineCount: Int\n    let lastUpdatedAt: Date?\n    let source: SessionSource\n    let remotePath: String?\n\n    // User-provided metadata (rename/comment)\n    var userTitle: String? = nil\n    var userComment: String? = nil\n\n    // Task association (optional - nil means standalone session)\n    var taskId: UUID? = nil\n\n    public enum ParseLevel: String, Codable, Sendable, Comparable {\n        case metadata\n        case full\n        case enriched\n\n        public static func < (lhs: ParseLevel, rhs: ParseLevel) -> Bool {\n            switch (lhs, rhs) {\n            case (.metadata, .full), (.metadata, .enriched), (.full, .enriched): return true\n            default: return false\n            }\n        }\n    }\n\n    var parseLevel: ParseLevel? = nil\n\n    var duration: TimeInterval {\n        if let activeDuration { return activeDuration }\n        guard let end = endedAt ?? lastUpdatedAt else { return 0 }\n        return end.timeIntervalSince(startedAt)\n    }\n\n    var actualTotalTokens: Int {\n        return totalTokens ?? 0\n    }\n\n    func visibleEventCount(using visibleKinds: Set<MessageVisibilityKind>) -> Int {\n        guard let messageTypeCounts, !messageTypeCounts.isEmpty else { return eventCount }\n        let allowed = visibleKinds.rawValues\n        var total = 0\n        for (key, count) in messageTypeCounts where allowed.contains(key) {\n            total += count\n        }\n        return total\n    }\n\n    var displayName: String {\n        let filename = fileURL.deletingPathExtension().lastPathComponent\n\n        // Handle new format: agent-6afec743 -> extract agentId from filename\n        if filename.hasPrefix(\"agent-\") {\n            // Use the agentId portion from filename to distinguish between parallel agents\n            let agentId = String(filename.dropFirst(\"agent-\".count))\n            if !agentId.isEmpty {\n                return \"agent-\\(agentId)\"\n            }\n            // Fallback to sessionId if agentId extraction failed\n            return id\n        }\n\n        // Handle old UUID format: ed5f5b12-a30b-4c86-b3ff-5bcf5dba65c0 -> use as is\n        if filename.components(separatedBy: \"-\").count == 5 &&\n           filename.count == 36 &&\n           UUID(uuidString: filename) != nil {\n            return filename\n        }\n\n        // Handle rollout format: \"rollout-2025-10-17T14-11-18-0199f124-8c38-7140-969c-396260d0099c\"\n        // Keep only the last 5 segments after removing rollout + timestamp (5 parts)\n        let components = filename.components(separatedBy: \"-\")\n        if components.count >= 7 {\n            // Skip first component (rollout) and next 5 components (timestamp), keep last 5\n            let sessionIdComponents = Array(components.dropFirst(6))\n            return sessionIdComponents.joined(separator: \"-\")\n        }\n\n        return filename\n    }\n\n    // Prefer user-provided title when available\n    var effectiveTitle: String { (userTitle?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap { $0.isEmpty ? nil : $0 } ?? displayName }\n\n    var instructionSnippet: String {\n        guard let instructions, !instructions.isEmpty else { return \"—\" }\n        let trimmed = instructions.trimmingCharacters(in: .whitespacesAndNewlines)\n        if trimmed.count <= 220 {\n            return trimmed\n        }\n        let index = trimmed.index(trimmed.startIndex, offsetBy: 220)\n        return \"\\(trimmed[..<index])…\"\n    }\n\n    // Prefer user comment (100 chars) when available\n    var commentSnippet: String {\n        if let s = userComment?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty {\n            if s.count <= 100 { return s }\n            let idx = s.index(s.startIndex, offsetBy: 100)\n            return String(s[..<idx]) + \"…\"\n        }\n        return instructionSnippet\n    }\n\n    var readableDuration: String {\n        return Self.durationFormatter.string(from: duration) ?? \"—\"\n    }\n\n    private static let durationFormatter: DateComponentsFormatter = {\n        let formatter = DateComponentsFormatter()\n        formatter.allowedUnits = [.hour, .minute, .second]\n        formatter.unitsStyle = .abbreviated\n        formatter.zeroFormattingBehavior = .pad\n        return formatter\n    }()\n\n    var displayModel: String? {\n        guard let model else { return nil }\n        return source.friendlyModelName(for: model)\n    }\n\n    var remoteHost: String? { source.remoteHost }\n    var isRemote: Bool { source.isRemote }\n    var identityKey: String {\n        if let host = remoteHost {\n            return \"\\(host)::\\(id)\"\n        }\n        return id\n    }\n\n    var fileSizeDisplay: String {\n        guard let bytes = resolvedFileSizeBytes else { return \"—\" }\n        let formatter = ByteCountFormatter()\n        formatter.allowedUnits = [.useKB, .useMB]\n        formatter.countStyle = .file\n        return formatter.string(fromByteCount: Int64(bytes))\n    }\n\n    var resolvedFileSizeBytes: UInt64? {\n        return fileSizeBytes\n    }\n\n    func matches(search term: String) -> Bool {\n        guard !term.isEmpty else { return true }\n        \n        if id.localizedCaseInsensitiveContains(term) { return true }\n        if displayName.localizedCaseInsensitiveContains(term) { return true }\n        if let userTitle, userTitle.localizedCaseInsensitiveContains(term) { return true }\n        if let userComment, userComment.localizedCaseInsensitiveContains(term) { return true }\n        if cliVersion.localizedCaseInsensitiveContains(term) { return true }\n        if cwd.localizedCaseInsensitiveContains(term) { return true }\n        if originator.localizedCaseInsensitiveContains(term) { return true }\n        if let instructions, instructions.localizedCaseInsensitiveContains(term) { return true }\n        if let model, model.localizedCaseInsensitiveContains(term) { return true }\n        if let approvalPolicy, approvalPolicy.localizedCaseInsensitiveContains(term) { return true }\n        if let host = remoteHost, host.localizedCaseInsensitiveContains(term) { return true }\n        if let remotePath, remotePath.localizedCaseInsensitiveContains(term) { return true }\n        \n        return false\n    }\n}\n\nextension SessionSummary {\n    func overridingSource(_ newSource: SessionSource, remotePath: String? = nil) -> SessionSummary {\n        if newSource == source, remotePath == self.remotePath { return self }\n        let adjustedModel = (newSource.baseKind == source.baseKind) ? model : nil\n        let adjustedApproval = (newSource.baseKind == source.baseKind) ? approvalPolicy : nil\n        var s = SessionSummary(\n            id: id,\n            fileURL: fileURL,\n            fileSizeBytes: fileSizeBytes,\n            startedAt: startedAt,\n            endedAt: endedAt,\n            activeDuration: activeDuration,\n            cliVersion: cliVersion,\n            cwd: cwd,\n            originator: originator,\n            instructions: instructions,\n            model: adjustedModel,\n            approvalPolicy: adjustedApproval,\n            userMessageCount: userMessageCount,\n            assistantMessageCount: assistantMessageCount,\n            toolInvocationCount: toolInvocationCount,\n            responseCounts: responseCounts,\n            turnContextCount: turnContextCount,\n            messageTypeCounts: messageTypeCounts,\n            totalTokens: totalTokens,\n            tokenBreakdown: tokenBreakdown,\n            eventCount: eventCount,\n            lineCount: lineCount,\n            lastUpdatedAt: lastUpdatedAt,\n            source: newSource,\n            remotePath: remotePath ?? self.remotePath,\n            userTitle: userTitle,\n            userComment: userComment,\n            taskId: taskId\n        )\n        s.parseLevel = parseLevel\n        return s\n    }\n\n    func withInstructionPreview(_ preview: String?) -> SessionSummary {\n        var s = SessionSummary(\n            id: id,\n            fileURL: fileURL,\n            fileSizeBytes: fileSizeBytes,\n            startedAt: startedAt,\n            endedAt: endedAt,\n            activeDuration: activeDuration,\n            cliVersion: cliVersion,\n            cwd: cwd,\n            originator: originator,\n            instructions: preview,\n            model: model,\n            approvalPolicy: approvalPolicy,\n            userMessageCount: userMessageCount,\n            assistantMessageCount: assistantMessageCount,\n            toolInvocationCount: toolInvocationCount,\n            responseCounts: responseCounts,\n            turnContextCount: turnContextCount,\n            messageTypeCounts: messageTypeCounts,\n            totalTokens: totalTokens,\n            tokenBreakdown: tokenBreakdown,\n            eventCount: eventCount,\n            lineCount: lineCount,\n            lastUpdatedAt: lastUpdatedAt,\n            source: source,\n            remotePath: remotePath,\n            userTitle: userTitle,\n            userComment: userComment,\n            taskId: taskId\n        )\n        s.parseLevel = parseLevel\n        return s\n    }\n\n    func withRemoteMetadata(source: SessionSource, remotePath: String) -> SessionSummary {\n        return overridingSource(source, remotePath: remotePath)\n    }\n\n    func overridingCounts(\n        userMessages: Int? = nil,\n        assistantMessages: Int? = nil,\n        toolInvocations: Int? = nil\n    ) -> SessionSummary {\n        var s = SessionSummary(\n            id: id,\n            fileURL: fileURL,\n            fileSizeBytes: fileSizeBytes,\n            startedAt: startedAt,\n            endedAt: endedAt,\n            activeDuration: activeDuration,\n            cliVersion: cliVersion,\n            cwd: cwd,\n            originator: originator,\n            instructions: instructions,\n            model: model,\n            approvalPolicy: approvalPolicy,\n            userMessageCount: userMessages ?? userMessageCount,\n            assistantMessageCount: assistantMessages ?? assistantMessageCount,\n            toolInvocationCount: toolInvocations ?? toolInvocationCount,\n            responseCounts: responseCounts,\n            turnContextCount: turnContextCount,\n            messageTypeCounts: messageTypeCounts,\n            totalTokens: totalTokens,\n            tokenBreakdown: tokenBreakdown,\n            eventCount: eventCount,\n            lineCount: lineCount,\n            lastUpdatedAt: lastUpdatedAt,\n            source: source,\n            remotePath: remotePath,\n            userTitle: userTitle,\n            userComment: userComment,\n            taskId: taskId\n        )\n        s.parseLevel = parseLevel\n        return s\n    }\n\n    func overridingTokens(\n        totalTokens: Int?,\n        tokenBreakdown: SessionTokenBreakdown?\n    ) -> SessionSummary {\n        var s = SessionSummary(\n            id: id,\n            fileURL: fileURL,\n            fileSizeBytes: fileSizeBytes,\n            startedAt: startedAt,\n            endedAt: endedAt,\n            activeDuration: activeDuration,\n            cliVersion: cliVersion,\n            cwd: cwd,\n            originator: originator,\n            instructions: instructions,\n            model: model,\n            approvalPolicy: approvalPolicy,\n            userMessageCount: userMessageCount,\n            assistantMessageCount: assistantMessageCount,\n            toolInvocationCount: toolInvocationCount,\n            responseCounts: responseCounts,\n            turnContextCount: turnContextCount,\n            messageTypeCounts: messageTypeCounts,\n            totalTokens: totalTokens,\n            tokenBreakdown: tokenBreakdown ?? self.tokenBreakdown,\n            eventCount: eventCount,\n            lineCount: lineCount,\n            lastUpdatedAt: lastUpdatedAt,\n            source: source,\n            remotePath: remotePath,\n            userTitle: userTitle,\n            userComment: userComment,\n            taskId: taskId\n        )\n        s.parseLevel = parseLevel\n        return s\n    }\n    \n    func withParseLevel(_ level: ParseLevel?) -> SessionSummary {\n        var s = self\n        s.parseLevel = level\n        return s\n    }\n\n    func withParseLevel(fromString levelString: String?) -> SessionSummary {\n        guard let levelString, let level = ParseLevel(rawValue: levelString) else { return self }\n        return withParseLevel(level)\n    }\n\n    func withTokenBreakdownFallback(_ breakdown: SessionTokenBreakdown?) -> SessionSummary {\n        guard tokenBreakdown == nil, let breakdown else { return self }\n        var s = self\n        s.tokenBreakdown = breakdown\n        return s\n    }\n}\n\nenum SessionSortOrder: String, CaseIterable, Identifiable, Sendable {\n    case mostRecent\n    case longestDuration\n    case mostActivity\n    case alphabetical\n    case largestSize\n\n    var id: String { rawValue }\n\n    var title: String {\n        switch self {\n        case .mostRecent: return \"Recent\"\n        case .longestDuration: return \"Duration\"\n        case .mostActivity: return \"Activity\"\n        case .alphabetical: return \"Name\"\n        case .largestSize: return \"Size\"\n        }\n    }\n\n    func sort(_ sessions: [SessionSummary]) -> [SessionSummary] {\n        switch self {\n        case .mostRecent:\n            return sessions.sorted {\n                ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt)\n            }\n        case .longestDuration:\n            return sessions.sorted { $0.duration > $1.duration }\n        case .mostActivity:\n            return sessions.sorted {\n                if $0.eventCount != $1.eventCount { return $0.eventCount > $1.eventCount }\n                let l0 = $0.lastUpdatedAt ?? $0.startedAt\n                let l1 = $1.lastUpdatedAt ?? $1.startedAt\n                if l0 != l1 { return l0 > l1 }\n                return $0.effectiveTitle\n                    .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending\n            }\n        case .alphabetical:\n            return sessions.sorted {\n                let cmp = $0.effectiveTitle.localizedStandardCompare($1.effectiveTitle)\n                if cmp == .orderedSame {\n                    let l0 = $0.lastUpdatedAt ?? $0.startedAt\n                    let l1 = $1.lastUpdatedAt ?? $1.startedAt\n                    if l0 != l1 { return l0 > l1 }\n                    return $0.id < $1.id\n                }\n                return cmp == .orderedAscending\n            }\n        case .largestSize:\n            return sessions.sorted { ($0.fileSizeBytes ?? 0) > ($1.fileSizeBytes ?? 0) }\n        }\n    }\n\n    // Dimension-aware sorting variant used by the middle list. For \"Recent\",\n    // order by created vs. last-updated depending on the calendar mode; other\n    // sort orders fall back to the default behavior above.\n    func sort(_ sessions: [SessionSummary], dimension: DateDimension) -> [SessionSummary] {\n        switch self {\n        case .mostRecent:\n            let key: (SessionSummary) -> Date = {\n                switch dimension {\n                case .created: return $0.startedAt\n                case .updated: return $0.lastUpdatedAt ?? $0.startedAt\n                }\n            }\n            return sessions.sorted { key($0) > key($1) }\n        default:\n            return sort(sessions)\n        }\n    }\n\n    func sort(\n        _ sessions: [SessionSummary],\n        dimension: DateDimension,\n        visibleKinds: Set<MessageVisibilityKind>\n    ) -> [SessionSummary] {\n        switch self {\n        case .mostRecent:\n            return sort(sessions, dimension: dimension)\n        case .mostActivity:\n            return sessions.sorted {\n                let lCount = $0.visibleEventCount(using: visibleKinds)\n                let rCount = $1.visibleEventCount(using: visibleKinds)\n                if lCount != rCount { return lCount > rCount }\n                let l0 = $0.lastUpdatedAt ?? $0.startedAt\n                let l1 = $1.lastUpdatedAt ?? $1.startedAt\n                if l0 != l1 { return l0 > l1 }\n                return $0.effectiveTitle\n                    .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending\n            }\n        default:\n            return sort(sessions)\n        }\n    }\n\n    func sort(\n        _ sessions: [SessionSummary],\n        visibleKinds: Set<MessageVisibilityKind>\n    ) -> [SessionSummary] {\n        switch self {\n        case .mostActivity:\n            return sessions.sorted {\n                let lCount = $0.visibleEventCount(using: visibleKinds)\n                let rCount = $1.visibleEventCount(using: visibleKinds)\n                if lCount != rCount { return lCount > rCount }\n                let l0 = $0.lastUpdatedAt ?? $0.startedAt\n                let l1 = $1.lastUpdatedAt ?? $1.startedAt\n                if l0 != l1 { return l0 > l1 }\n                return $0.effectiveTitle\n                    .localizedCaseInsensitiveCompare($1.effectiveTitle) == .orderedAscending\n            }\n        default:\n            return sort(sessions)\n        }\n    }\n}\n\nstruct SessionDaySection: Identifiable, Hashable, Sendable {\n    let id: Date\n    let title: String\n    let totalDuration: TimeInterval\n    let totalEvents: Int\n    let sessions: [SessionSummary]\n}\n\nenum SessionSource: Hashable, Sendable {\n    case codexLocal\n    case claudeLocal\n    case geminiLocal\n    case codexRemote(host: String)\n    case claudeRemote(host: String)\n    case geminiRemote(host: String)\n\n    var isRemote: Bool {\n        switch self {\n        case .codexRemote, .claudeRemote, .geminiRemote: return true\n        default: return false\n        }\n    }\n\n    var remoteHost: String? {\n        switch self {\n        case .codexRemote(let host), .claudeRemote(let host), .geminiRemote(let host): return host\n        default: return nil\n        }\n    }\n\n    var baseKind: Kind {\n        switch self {\n        case .codexLocal, .codexRemote: return .codex\n        case .claudeLocal, .claudeRemote: return .claude\n        case .geminiLocal, .geminiRemote: return .gemini\n        }\n    }\n\n    enum Kind: String, Codable, Sendable {\n    case codex\n    case claude\n    case gemini\n    \n    var cliExecutableName: String {\n        switch self {\n        case .codex: return \"codex\"\n        case .claude: return \"claude\"\n        case .gemini: return \"gemini\"\n        }\n    }\n    }\n}\n\nextension SessionSource.Kind {\n    var displayName: String {\n        switch self {\n        case .codex: return \"Codex\"\n        case .claude: return \"Claude\"\n        case .gemini: return \"Gemini\"\n        }\n    }\n}\n\nextension SessionSource: Codable {\n    private enum CodingKeys: String, CodingKey {\n        case kind\n        case host\n    }\n\n    func encode(to encoder: Encoder) throws {\n        switch self {\n        case .codexLocal:\n            var container = encoder.singleValueContainer()\n            try container.encode(\"codex\")\n        case .claudeLocal:\n            var container = encoder.singleValueContainer()\n            try container.encode(\"claude\")\n        case .geminiLocal:\n            var container = encoder.singleValueContainer()\n            try container.encode(\"gemini\")\n        case .codexRemote(let host):\n            var container = encoder.container(keyedBy: CodingKeys.self)\n            try container.encode(\"codexRemote\", forKey: .kind)\n            try container.encode(host, forKey: .host)\n        case .claudeRemote(let host):\n            var container = encoder.container(keyedBy: CodingKeys.self)\n            try container.encode(\"claudeRemote\", forKey: .kind)\n            try container.encode(host, forKey: .host)\n        case .geminiRemote(let host):\n            var container = encoder.container(keyedBy: CodingKeys.self)\n            try container.encode(\"geminiRemote\", forKey: .kind)\n            try container.encode(host, forKey: .host)\n        }\n    }\n\n    init(from decoder: Decoder) throws {\n        if let singleValue = try? decoder.singleValueContainer(),\n           let raw = try? singleValue.decode(String.self)\n        {\n            switch raw {\n            case \"codex\":\n                self = .codexLocal\n            case \"claude\":\n                self = .claudeLocal\n            case \"gemini\":\n                self = .geminiLocal\n            case \"codexLocal\":\n                self = .codexLocal\n            case \"claudeLocal\":\n                self = .claudeLocal\n            case \"geminiLocal\":\n                self = .geminiLocal\n            default:\n                throw DecodingError.dataCorruptedError(\n                    in: singleValue, debugDescription: \"Unknown SessionSource raw value \\(raw)\")\n            }\n            return\n        }\n\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        let kind = try container.decode(String.self, forKey: .kind)\n        switch kind {\n        case \"codexRemote\":\n            let host = try container.decode(String.self, forKey: .host)\n            self = .codexRemote(host: host)\n        case \"claudeRemote\":\n            let host = try container.decode(String.self, forKey: .host)\n            self = .claudeRemote(host: host)\n        case \"geminiRemote\":\n            let host = try container.decode(String.self, forKey: .host)\n            self = .geminiRemote(host: host)\n        case \"codex\":\n            self = .codexLocal\n        case \"claude\":\n            self = .claudeLocal\n        case \"gemini\":\n            self = .geminiLocal\n        default:\n            throw DecodingError.dataCorruptedError(\n                forKey: .kind, in: container, debugDescription: \"Unknown SessionSource kind \\(kind)\")\n        }\n    }\n}\n"
  },
  {
    "path": "models/SettingCategory.swift",
    "content": "import Foundation\n\nenum SettingCategory: String, CaseIterable, Identifiable {\n  case general\n  case terminal\n  case notifications\n  case command\n  case providers\n  case codex\n  case gemini\n  case remoteHosts\n  case gitReview\n  case claudeCode\n  case advanced\n  case mcpServer\n  case about\n\n  // Customize displayed order and allow hiding categories without breaking enums elsewhere.\n  // Remote Hosts appears as a top-level settings page alongside Codex.\n  static var allCases: [SettingCategory] {\n    [\n      .general,\n      .terminal,\n      .providers,\n      .gitReview,\n      .mcpServer,\n      .remoteHosts,\n      .codex,\n      .gemini,\n      .claudeCode,\n      .notifications,\n      .advanced,\n      .about\n    ]\n  }\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .general: return \"General\"\n    case .terminal: return \"Terminal\"\n    case .notifications: return \"Notifications\"\n    case .command: return \"Command\"\n    case .providers: return \"Providers\"\n    case .codex: return \"Codex\"\n    case .gemini: return \"Gemini CLI\"\n    case .remoteHosts: return \"Remote Hosts\"\n    case .gitReview: return \"Git Review\"\n    case .claudeCode: return \"Claude Code\"\n    case .advanced: return \"Advanced\"\n    case .mcpServer: return \"Extensions\"\n    case .about: return \"About\"\n    }\n  }\n\n  var icon: String {\n    switch self {\n    case .general: return \"gear\"\n    case .terminal: return \"terminal\"\n    case .notifications: return \"bell\"\n    case .command: return \"slider.horizontal.3\"\n    case .providers: return \"server.rack\"\n    case .codex: return \"sparkles\"\n    case .gemini: return \"sparkles.rectangle.stack\"\n    case .remoteHosts: return \"antenna.radiowaves.left.and.right\"\n    case .advanced: return \"gearshape.2\"\n    case .gitReview: return \"square.and.pencil\"\n    case .claudeCode: return \"chevron.left.slash.chevron.right\"\n    case .mcpServer: return \"puzzlepiece.extension\"\n    case .about: return \"info.circle\"\n    }\n  }\n\n  var description: String {\n    switch self {\n    case .general: return \"Basic application settings\"\n    case .terminal: return \"Terminal and resume preferences\"\n    case .notifications: return \"Notification preferences and hooks\"\n    case .command: return \"Command execution policies\"\n    case .providers: return \"Global providers and bindings\"\n    case .codex: return \"Codex CLI configuration\"\n    case .gemini: return \"Gemini CLI configuration\"\n    case .remoteHosts: return \"Remote SSH host configuration\"\n    case .gitReview: return \"Git changes viewer and commit generation\"\n    case .claudeCode: return \"Claude Code configuration\"\n    case .advanced: return \"Paths and deep diagnostics\"\n    case .mcpServer: return \"Manage MCP servers and Skills\"\n    case .about: return \"App info and project links\"\n  }\n}\n}\n"
  },
  {
    "path": "models/SidebarState.swift",
    "content": "import Foundation\n\nstruct SidebarState: Equatable {\n    var totalSessionCount: Int\n    var isLoading: Bool\n    var visibleAllCount: Int\n    var selectedProjectIDs: Set<String>\n    var selectedDay: Date?\n    var selectedDays: Set<Date>\n    var dateDimension: DateDimension\n    var monthStart: Date\n    var calendarCounts: [Int: Int]\n    var enabledProjectDays: Set<Int>?\n}\n\nstruct SidebarActions {\n    var selectAllProjects: () -> Void\n    var requestNewProject: () -> Void\n    var requestNewTask: () -> Void\n    var setDateDimension: (DateDimension) -> Void\n    var setMonthStart: (Date) -> Void\n    var setSelectedDay: (Date?) -> Void\n    var toggleSelectedDay: (Date) -> Void\n}\n"
  },
  {
    "path": "models/SkillsLibraryViewModel.swift",
    "content": "import Foundation\nimport UniformTypeIdentifiers\n#if canImport(AppKit)\nimport AppKit\n#endif\n\nstruct SkillSummary: Identifiable, Hashable {\n    let id: String\n    var name: String\n    var description: String\n    var summary: String\n    var tags: [String]\n    var source: String\n    var path: String?\n    var isSelected: Bool\n    var targets: MCPServerTargets\n    var sourceType: String?\n\n    var displayName: String { name.isEmpty ? id : name }\n    var isTemplateCreated: Bool { sourceType == \"template\" }\n}\n\n@MainActor\nfinal class SkillsLibraryViewModel: ObservableObject {\n    private let store = SkillsStore()\n    private let syncer = SkillsSyncService()\n\n    @Published var skills: [SkillSummary] = []\n    @Published var selectedSkillId: String?\n    @Published var searchText: String = \"\"\n    @Published var isLoading: Bool = false\n    @Published var errorMessage: String?\n    @Published var installStatusMessage: String?\n\n    @Published var showInstallSheet: Bool = false\n    @Published var installMode: SkillInstallMode = .folder\n    @Published var pendingInstallURL: URL?\n    @Published var pendingInstallText: String = \"\"\n    @Published var installConflict: SkillInstallConflict?\n\n    @Published var showCreateSheet: Bool = false\n    @Published var newSkillName: String = \"\"\n    @Published var newSkillDescription: String = \"\"\n    @Published var createErrorMessage: String?\n    @Published var pendingWizardDraft: SkillWizardDraft? = nil\n    @Published var createStartsWithWizard: Bool = false\n    @Published var wizardPreviewSkill: SkillSummary? = nil\n\n    private var wizardPreviewURL: URL? = nil\n    @Published var showImportSheet: Bool = false\n    @Published var importCandidates: [SkillImportCandidate] = []\n    @Published var isImporting: Bool = false\n    @Published var importStatusMessage: String?\n\n    var filteredSkills: [SkillSummary] {\n        let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return skills }\n        return skills.filter { skill in\n            let hay = [skill.displayName, skill.summary, skill.tags.joined(separator: \" \"), skill.source]\n                .joined(separator: \" \")\n                .lowercased()\n            return hay.contains(trimmed.lowercased())\n        }\n    }\n\n    var selectedSkill: SkillSummary? {\n        guard let id = selectedSkillId else { return nil }\n        return skills.first(where: { $0.id == id })\n    }\n\n    func load() async {\n        isLoading = true\n        defer { isLoading = false }\n        let records = await store.list()\n        skills = await withTaskGroup(of: (Int, SkillSummary).self) { group in\n            for (index, record) in records.enumerated() {\n                group.addTask {\n                    let sourceType = await self.store.getSourceType(\n                        at: URL(fileURLWithPath: record.path)\n                    )\n                    return (index, SkillSummary(\n                        id: record.id,\n                        name: record.name,\n                        description: record.description,\n                        summary: record.summary,\n                        tags: record.tags,\n                        source: record.source,\n                        path: record.path,\n                        isSelected: record.isEnabled,\n                        targets: record.targets,\n                        sourceType: sourceType\n                    ))\n                }\n            }\n            var results: [(Int, SkillSummary)] = []\n            for await result in group {\n                results.append(result)\n            }\n            return results.sorted(by: { $0.0 < $1.0 }).map { $0.1 }\n        }\n        if selectedSkillId == nil || !skills.contains(where: { $0.id == selectedSkillId }) {\n            selectedSkillId = skills.first?.id\n        }\n    }\n\n    // MARK: - Import (Home)\n    func beginImportFromHome() {\n        showImportSheet = true\n        Task { await loadImportCandidatesFromHome() }\n    }\n\n    func loadImportCandidatesFromHome() async {\n        isImporting = true\n        importStatusMessage = \"Scanning…\"\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: home,\n                purpose: .generalAccess,\n                message: \"Authorize your Home folder to import skills\"\n            )\n        }\n\n        let scanned = await Task.detached(priority: .userInitiated) {\n            await SkillsImportService.scan(scope: .home)\n        }.value\n        let existing = await store.list()\n        let managedIds = Set(existing.map(\\.id))\n        // CodMate store is the source of truth; provider directories can drift if edited by other tools.\n        let filtered = scanned.filter { !managedIds.contains($0.id) }\n\n        var candidates: [SkillImportCandidate] = []\n        for item in filtered {\n            var updated = item\n            if let conflict = await store.conflictInfo(forProposedId: item.id) {\n                updated.hasConflict = true\n                updated.isSelected = false\n                updated.resolution = .skip\n                updated.renameId = conflict.suggestedId\n                updated.suggestedId = conflict.suggestedId\n                updated.conflictDetail = conflict.existingIsManaged\n                    ? \"Existing CodMate-managed skill\"\n                    : \"Skill already exists\"\n            }\n            candidates.append(updated)\n        }\n\n        importCandidates = candidates\n        isImporting = false\n        importStatusMessage = candidates.isEmpty ? \"No skills found.\" : nil\n    }\n\n    func cancelImport() {\n        showImportSheet = false\n        importCandidates = []\n        importStatusMessage = nil\n    }\n\n    func importSelectedSkills() async {\n        let selected = importCandidates.filter { $0.isSelected }\n        guard !selected.isEmpty else {\n            importStatusMessage = \"No skills selected.\"\n            return\n        }\n\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: codmate,\n                purpose: .generalAccess,\n                message: \"Authorize ~/.codmate to import skills\"\n            )\n        }\n\n        var importedCount = 0\n        var importedCandidateIds: Set<String> = []\n        var importedCandidates: [SkillImportCandidate] = []\n        for item in selected {\n            let resolution = item.hasConflict ? item.resolution : .overwrite\n            switch resolution {\n            case .skip:\n                continue\n            case .overwrite:\n                let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil)\n                let outcome = await store.install(request: req, resolution: .overwrite)\n                if case .installed(let record) = outcome {\n                    await store.markImported(id: record.id)\n                    importedCount += 1\n                    importedCandidateIds.insert(item.id)\n                    importedCandidates.append(item)\n                }\n            case .rename:\n                let newId = item.renameId.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !newId.isEmpty else { continue }\n                let req = SkillInstallRequest(mode: .folder, url: URL(fileURLWithPath: item.sourcePath), text: nil)\n                let outcome = await store.install(request: req, resolution: .rename(newId))\n                if case .installed(let record) = outcome {\n                    await store.markImported(id: record.id)\n                    importedCount += 1\n                    importedCandidateIds.insert(item.id)\n                    importedCandidates.append(item)\n                }\n            }\n        }\n\n        if !importedCandidates.isEmpty {\n            removeImportedProviderCopies(importedCandidates)\n        }\n        await load()\n        await persistAndSync()\n        importStatusMessage = \"Imported \\(importedCount) skill(s).\"\n        if !importedCandidateIds.isEmpty {\n            importCandidates.removeAll { importedCandidateIds.contains($0.id) }\n        }\n        if importCandidates.isEmpty {\n            closeImportSheetAfterDelay()\n        }\n    }\n\n    private func closeImportSheetAfterDelay(_ delay: TimeInterval = 0.6) {\n        Task { @MainActor in\n            try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n            self.showImportSheet = false\n            self.importStatusMessage = nil\n        }\n    }\n\n    private func removeImportedProviderCopies(_ items: [SkillImportCandidate]) {\n        let home = SessionPreferencesStore.getRealUserHomeURL()\n        let providerRoots: [String: URL] = [\n            \"Codex\": home.appendingPathComponent(\".codex\", isDirectory: true)\n                .appendingPathComponent(\"skills\", isDirectory: true),\n            \"Claude\": home.appendingPathComponent(\".claude\", isDirectory: true)\n                .appendingPathComponent(\"skills\", isDirectory: true)\n        ]\n        if SecurityScopedBookmarks.shared.isSandboxed {\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: home.appendingPathComponent(\".codex\", isDirectory: true),\n                purpose: .generalAccess,\n                message: \"Authorize ~/.codex to adopt imported skills\"\n            )\n            AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n                directory: home.appendingPathComponent(\".claude\", isDirectory: true),\n                purpose: .generalAccess,\n                message: \"Authorize ~/.claude to adopt imported skills\"\n            )\n        }\n\n        let fm = FileManager.default\n        for item in items {\n            if item.sourcePaths.isEmpty {\n                for source in item.sources {\n                    guard let root = providerRoots[source] else { continue }\n                    let dir = URL(fileURLWithPath: item.sourcePath, isDirectory: true)\n                    if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) {\n                        try? fm.removeItem(at: dir)\n                    }\n                }\n                continue\n            }\n            for (source, path) in item.sourcePaths {\n                guard let root = providerRoots[source] else { continue }\n                let dir = URL(fileURLWithPath: path).deletingLastPathComponent()\n                if dir.standardizedFileURL.path.hasPrefix(root.standardizedFileURL.path) {\n                    try? fm.removeItem(at: dir)\n                }\n            }\n        }\n    }\n\n    func prepareInstall(mode: SkillInstallMode, url: URL? = nil, text: String? = nil) {\n        installMode = mode\n        pendingInstallURL = url\n        pendingInstallText = text ?? \"\"\n        installStatusMessage = nil\n        installConflict = nil\n        showInstallSheet = true\n    }\n\n    func cancelInstall() {\n        showInstallSheet = false\n        pendingInstallURL = nil\n        pendingInstallText = \"\"\n        installStatusMessage = nil\n    }\n\n    func testInstall() {\n        installStatusMessage = \"Validating…\"\n        Task {\n            let request = installRequest()\n            let ok = await store.validate(request: request)\n            await MainActor.run {\n                installStatusMessage = ok ? \"Looks good. Ready to install.\" : \"Unable to validate this source.\"\n            }\n        }\n    }\n\n    func finishInstall() {\n        installStatusMessage = \"Installing…\"\n        Task {\n            let request = installRequest()\n            let outcome = await store.install(request: request, resolution: nil)\n            await MainActor.run {\n                handleInstallOutcome(outcome)\n            }\n        }\n    }\n\n    func updateSkillTarget(id: String, target: MCPServerTarget, value: Bool) {\n        guard let idx = skills.firstIndex(where: { $0.id == id }) else { return }\n        var updated = skills[idx]\n        updated.targets.setEnabled(value, for: target)\n        if value && !updated.isSelected {\n            updated.isSelected = true\n        } else if !updated.targets.codex && !updated.targets.claude && !updated.targets.gemini {\n            updated.isSelected = false\n        }\n        skills[idx] = updated\n        Task { await persistAndSync() }\n    }\n\n    func updateSkillSelection(id: String, value: Bool) {\n        guard let idx = skills.firstIndex(where: { $0.id == id }) else { return }\n        skills[idx].isSelected = value\n        if !value {\n            skills[idx].targets.codex = false\n            skills[idx].targets.claude = false\n            skills[idx].targets.gemini = false\n        } else {\n            skills[idx].targets.codex = true\n            skills[idx].targets.claude = true\n            skills[idx].targets.gemini = true\n        }\n        Task { await persistAndSync() }\n    }\n\n    func handleDrop(_ providers: [NSItemProvider]) -> Bool {\n        guard let provider = providers.first else { return false }\n        if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n            provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in\n                guard let data = item as? Data,\n                      let url = URL(dataRepresentation: data, relativeTo: nil)\n                else { return }\n                Task { @MainActor in\n                    let isZip = url.pathExtension.lowercased() == \"zip\"\n                    self.prepareInstall(mode: isZip ? .zip : .folder, url: url)\n                }\n            }\n            return true\n        }\n        if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {\n            provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in\n                if let url = item as? URL {\n                    Task { @MainActor in\n                        self.prepareInstall(mode: .url, text: url.absoluteString)\n                    }\n                }\n            }\n            return true\n        }\n        if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n            provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in\n                let text: String?\n                if let data = item as? Data {\n                    text = String(data: data, encoding: .utf8)\n                } else {\n                    text = item as? String\n                }\n                guard let text, !text.isEmpty else { return }\n                Task { @MainActor in\n                    self.prepareInstall(mode: .url, text: text)\n                }\n            }\n            return true\n        }\n        return false\n    }\n\n    func resolveInstallConflict(_ resolution: SkillConflictResolution) {\n        installStatusMessage = \"Installing…\"\n        Task {\n            let request = installRequest()\n            let outcome = await store.install(request: request, resolution: resolution)\n            await MainActor.run {\n                handleInstallOutcome(outcome)\n            }\n        }\n    }\n\n    func uninstall(id: String) {\n        Task {\n            await store.uninstall(id: id)\n            await load()\n            await persistAndSync()\n        }\n    }\n\n    func prepareCreateSkill(startWithWizard: Bool = false) {\n        createStartsWithWizard = startWithWizard\n        newSkillName = \"\"\n        newSkillDescription = \"\"\n        createErrorMessage = nil\n        pendingWizardDraft = nil\n        clearWizardPreview()\n        showCreateSheet = true\n    }\n\n    func cancelCreateSkill() {\n        showCreateSheet = false\n        newSkillName = \"\"\n        newSkillDescription = \"\"\n        createErrorMessage = nil\n        pendingWizardDraft = nil\n        createStartsWithWizard = false\n        clearWizardPreview()\n    }\n\n    func createSkill() {\n        createErrorMessage = nil\n        Task {\n            do {\n                guard var draft = pendingWizardDraft else {\n                    await MainActor.run {\n                        createErrorMessage = \"Use the wizard to create a skill.\"\n                    }\n                    return\n                }\n                let trimmedName = newSkillName.trimmingCharacters(in: .whitespacesAndNewlines)\n                if !trimmedName.isEmpty {\n                    draft.id = trimmedName\n                    draft.name = trimmedName\n                }\n                let trimmedDesc = newSkillDescription.trimmingCharacters(in: .whitespacesAndNewlines)\n                if !trimmedDesc.isEmpty {\n                    draft.description = trimmedDesc\n                }\n                let record = try await store.createFromWizard(draft: draft, enabled: false)\n                await MainActor.run {\n                    showCreateSheet = false\n                    newSkillName = \"\"\n                    newSkillDescription = \"\"\n                    pendingWizardDraft = nil\n                    createStartsWithWizard = false\n                    clearWizardPreview()\n                }\n                await load()\n                await MainActor.run {\n                    selectedSkillId = record.id\n                }\n                await persistAndSync()\n            } catch let error as SkillCreationError {\n                await MainActor.run {\n                    createErrorMessage = error.localizedDescription\n                }\n            } catch {\n                await MainActor.run {\n                    createErrorMessage = \"Failed to create skill: \\(error.localizedDescription)\"\n                }\n            }\n        }\n    }\n\n    func applyWizardDraft(_ draft: SkillWizardDraft) {\n        pendingWizardDraft = draft\n        newSkillName = draft.id.isEmpty ? draft.name : draft.id\n        newSkillDescription = draft.description\n        createErrorMessage = nil\n        refreshWizardPreview()\n    }\n\n    func refreshWizardPreview() {\n        guard var draft = pendingWizardDraft else {\n            clearWizardPreview()\n            return\n        }\n        let trimmedName = newSkillName.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmedName.isEmpty {\n            draft.id = trimmedName\n            draft.name = trimmedName\n        }\n        let trimmedDesc = newSkillDescription.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmedDesc.isEmpty {\n            draft.description = trimmedDesc\n        }\n\n        let previewDir: URL\n        if let existing = wizardPreviewURL {\n            previewDir = existing\n        } else {\n            let previewId = \"wizard-preview-\\(UUID().uuidString)\"\n            previewDir = FileManager.default.temporaryDirectory\n                .appendingPathComponent(previewId, isDirectory: true)\n            wizardPreviewURL = previewDir\n        }\n        let previewId = \"wizard-preview-\\(UUID().uuidString)\"\n        do {\n            try FileManager.default.createDirectory(at: previewDir, withIntermediateDirectories: true)\n            let markdown = store.generateSkillMarkdownFromDraft(draft, id: previewId)\n            let skillFile = previewDir.appendingPathComponent(\"SKILL.md\", isDirectory: false)\n            try markdown.write(to: skillFile, atomically: true, encoding: .utf8)\n\n            let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description\n            let targets = draft.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false)\n            wizardPreviewSkill = SkillSummary(\n                id: previewId,\n                name: draft.name,\n                description: draft.description,\n                summary: summary,\n                tags: draft.tags,\n                source: \"Wizard Preview\",\n                path: previewDir.path,\n                isSelected: false,\n                targets: targets,\n                sourceType: \"preview\"\n            )\n        } catch {\n            wizardPreviewSkill = nil\n        }\n    }\n\n    private func clearWizardPreview() {\n        if let url = wizardPreviewURL {\n            try? FileManager.default.removeItem(at: url)\n        }\n        wizardPreviewURL = nil\n        wizardPreviewSkill = nil\n    }\n\n    func openInEditor(_ skill: SkillSummary, using editor: EditorApp) {\n        guard let path = skill.path, !path.isEmpty else {\n            errorMessage = \"Skill path not available\"\n            return\n        }\n\n        let dirURL = URL(fileURLWithPath: path)\n\n        var isDirectory: ObjCBool = false\n        guard FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory),\n              isDirectory.boolValue else {\n            errorMessage = \"Skill directory does not exist: \\(path)\"\n            return\n        }\n\n        if let executablePath = findExecutableInPath(editor.cliCommand) {\n            let process = Process()\n            process.executableURL = URL(fileURLWithPath: executablePath)\n            process.arguments = [path]\n            process.standardOutput = Pipe()\n            process.standardError = Pipe()\n\n            do {\n                try process.run()\n                return\n            } catch {\n            }\n        }\n\n        if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: editor.bundleIdentifier) {\n            let config = NSWorkspace.OpenConfiguration()\n            config.activates = true\n\n            NSWorkspace.shared.open(\n                [dirURL],\n                withApplicationAt: appURL,\n                configuration: config\n            ) { _, error in\n                if let error = error {\n                    DispatchQueue.main.async {\n                        self.errorMessage = \"Failed to open \\(editor.title): \\(error.localizedDescription)\"\n                    }\n                }\n            }\n            return\n        }\n\n        errorMessage = \"\\(editor.title) is not installed. Please install it or try a different editor.\"\n    }\n\n    private func findExecutableInPath(_ name: String) -> String? {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n        process.arguments = [name]\n\n        let pipe = Pipe()\n        process.standardOutput = pipe\n        process.standardError = Pipe()\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n\n            guard process.terminationStatus == 0 else { return nil }\n\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            let path = String(data: data, encoding: .utf8)?\n                .trimmingCharacters(in: .whitespacesAndNewlines)\n\n            return path?.isEmpty == false ? path : nil\n        } catch {\n            return nil\n        }\n    }\n\n    private func installRequest() -> SkillInstallRequest {\n        SkillInstallRequest(mode: installMode, url: pendingInstallURL, text: pendingInstallText)\n    }\n\n    private func handleInstallOutcome(_ outcome: SkillInstallOutcome) {\n        switch outcome {\n        case .installed:\n            installStatusMessage = \"Installed.\"\n            showInstallSheet = false\n            pendingInstallURL = nil\n            pendingInstallText = \"\"\n            Task { await reloadAfterInstall() }\n        case .conflict(let conflict):\n            installStatusMessage = \"Skill already exists.\"\n            installConflict = conflict\n        case .skipped:\n            installStatusMessage = \"Install skipped.\"\n        }\n    }\n\n    private func reloadAfterInstall() async {\n        await load()\n        await persistAndSync()\n    }\n\n    private func persistAndSync() async {\n        var records = await store.list()\n        for idx in records.indices {\n            if let summary = skills.first(where: { $0.id == records[idx].id }) {\n                records[idx].name = summary.name\n                records[idx].description = summary.description\n                records[idx].summary = summary.summary\n                records[idx].tags = summary.tags\n                records[idx].source = summary.source\n                if let path = summary.path { records[idx].path = path }\n                records[idx].isEnabled = summary.isSelected\n                records[idx].targets = summary.targets\n            }\n        }\n        await store.saveAll(records)\n        let home = SessionPreferencesStore.getRealUserHomeURL()\n        AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n            directory: home.appendingPathComponent(\".codex\", isDirectory: true),\n            purpose: .generalAccess,\n            message: \"Authorize ~/.codex to sync Codex skills\"\n        )\n        AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n            directory: home.appendingPathComponent(\".claude\", isDirectory: true),\n            purpose: .generalAccess,\n            message: \"Authorize ~/.claude to sync Claude skills\"\n        )\n        let warnings = await syncer.syncGlobal(skills: records)\n        if let warning = warnings.first {\n            errorMessage = warning.message\n        } else {\n            errorMessage = nil\n        }\n    }\n}\n"
  },
  {
    "path": "models/SkillsModels.swift",
    "content": "import Foundation\n\nstruct SkillRecord: Identifiable, Codable, Hashable {\n  var id: String\n  var name: String\n  var description: String\n  var summary: String\n  var tags: [String]\n  var source: String\n  var path: String\n  var isEnabled: Bool\n  var targets: MCPServerTargets\n  var installedAt: Date\n}\n\nenum SkillInstallMode: String, CaseIterable, Codable {\n  case folder\n  case zip\n  case url\n\n  var title: String {\n    switch self {\n    case .folder: return \"Folder\"\n    case .zip: return \"Zip\"\n    case .url: return \"URL\"\n    }\n  }\n}\n\nstruct SkillInstallRequest: Hashable, Sendable {\n  var mode: SkillInstallMode\n  var url: URL?\n  var text: String?\n}\n\nenum SkillConflictResolution: Hashable, Sendable {\n  case overwrite\n  case skip\n  case rename(String)\n}\n\nstruct SkillInstallConflict: Identifiable, Hashable {\n  let id: UUID = UUID()\n  let proposedId: String\n  let destination: URL\n  let existingIsManaged: Bool\n  let suggestedId: String\n}\n\nenum SkillInstallOutcome: Hashable {\n  case installed(SkillRecord)\n  case skipped\n  case conflict(SkillInstallConflict)\n}\n\nstruct SkillSyncWarning: Hashable, Sendable {\n  var message: String\n}\n"
  },
  {
    "path": "models/StatusBarLogEntry.swift",
    "content": "import Foundation\n\nenum StatusBarLogLevel: String, Codable, CaseIterable, Identifiable {\n  case info\n  case success\n  case warning\n  case error\n\n  var id: String { rawValue }\n}\n\nstruct StatusBarLogEntry: Identifiable, Equatable {\n  let id = UUID()\n  let timestamp: Date\n  let level: StatusBarLogLevel\n  let message: String\n  let source: String?\n\n  init(message: String, level: StatusBarLogLevel = .info, source: String? = nil, timestamp: Date = Date()) {\n    self.message = message\n    self.level = level\n    self.source = source\n    self.timestamp = timestamp\n  }\n}\n"
  },
  {
    "path": "models/StatusBarVisibility.swift",
    "content": "import Foundation\n\nenum StatusBarVisibility: String, CaseIterable, Identifiable, Codable {\n  case auto\n  case always\n  case hidden\n\n  var id: String { rawValue }\n}\n"
  },
  {
    "path": "models/SystemMenuVisibility.swift",
    "content": "import Foundation\n\nenum SystemMenuVisibility: String, CaseIterable, Identifiable, Sendable {\n  case hidden\n  case visible\n  case menuOnly\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .hidden: return \"Hidden\"\n    case .visible: return \"Shown\"\n    case .menuOnly: return \"Menu Bar Only\"\n    }\n  }\n}\n"
  },
  {
    "path": "models/Task.swift",
    "content": "import Foundation\n\n// MARK: - Task Type\n\nenum TaskType: String, Codable, CaseIterable, Identifiable, Sendable {\n    case feature = \"feature\"\n    case bugFix = \"bug_fix\"\n    case discussion = \"discussion\"\n    case refactor = \"refactor\"\n    case documentation = \"documentation\"\n    case other = \"other\"\n\n    var id: String { rawValue }\n\n    var displayName: String {\n        switch self {\n        case .feature: return \"Feature\"\n        case .bugFix: return \"Bug Fix\"\n        case .discussion: return \"Discussion\"\n        case .refactor: return \"Refactor\"\n        case .documentation: return \"Documentation\"\n        case .other: return \"Other\"\n        }\n    }\n\n    var icon: String {\n        switch self {\n        case .feature: return \"star.fill\"\n        case .bugFix: return \"ladybug.fill\"\n        case .discussion: return \"bubble.left.and.bubble.right.fill\"\n        case .refactor: return \"arrow.triangle.2.circlepath\"\n        case .documentation: return \"doc.text.fill\"\n        case .other: return \"ellipsis.circle.fill\"\n        }\n    }\n\n    var descriptionTemplate: String {\n        switch self {\n        case .feature:\n            return \"Implement a new feature or functionality\"\n        case .bugFix:\n            return \"Fix a bug or resolve an issue\"\n        case .discussion:\n            return \"Discuss requirements, architecture, or approach\"\n        case .refactor:\n            return \"Refactor code to improve structure or performance\"\n        case .documentation:\n            return \"Write or update documentation\"\n        case .other:\n            return \"General task\"\n        }\n    }\n}\n\n// MARK: - Task Status\n\nenum TaskStatus: String, Codable, CaseIterable, Identifiable, Sendable {\n    case pending\n    case inProgress = \"in_progress\"\n    case completed\n    case canceled\n    case archived\n\n    var id: String { rawValue }\n\n    var displayName: String {\n        switch self {\n        case .pending: return \"Pending\"\n        case .inProgress: return \"In Progress\"\n        case .completed: return \"Completed\"\n        case .canceled: return \"Canceled\"\n        case .archived: return \"Archived\"\n        }\n    }\n\n    var icon: String {\n        switch self {\n        case .pending: return \"circle\"\n        case .inProgress: return \"circle.dotted\"\n        case .completed: return \"checkmark.circle.fill\"\n        case .canceled: return \"xmark.circle.fill\"\n        case .archived: return \"archivebox.fill\"\n        }\n    }\n}\n\nenum ContextType: String, Codable, Sendable {\n    case userMarked = \"user_marked\"\n    case autoSuggested = \"auto_suggested\"\n}\n\nstruct ContextItem: Identifiable, Hashable, Codable, Sendable {\n    var id: UUID\n    var content: String\n    var sourceSessionId: String\n    var sourceMessageId: String?\n    var addedAt: Date\n    var type: ContextType\n\n    init(\n        id: UUID = UUID(),\n        content: String,\n        sourceSessionId: String,\n        sourceMessageId: String? = nil,\n        addedAt: Date = Date(),\n        type: ContextType = .userMarked\n    ) {\n        self.id = id\n        self.content = content\n        self.sourceSessionId = sourceSessionId\n        self.sourceMessageId = sourceMessageId\n        self.addedAt = addedAt\n        self.type = type\n    }\n}\n\nstruct CodMateTask: Identifiable, Hashable, Codable, Sendable {\n    var id: UUID\n    var title: String\n    var description: String?\n    var taskType: TaskType\n    var projectId: String\n    var createdAt: Date\n    var updatedAt: Date\n\n    // Shared context\n    var sharedContext: [ContextItem]\n    var agentsConfig: String? // Reference to Agents.md sections\n    var memoryItems: [String] // Memory item IDs\n\n    // Contained sessions\n    var sessionIds: [String]\n\n    // Metadata\n    var status: TaskStatus\n    var tags: [String]\n\n    // Primary provider for this task\n    var primaryProvider: ProjectSessionSource?\n\n    init(\n        id: UUID = UUID(),\n        title: String,\n        description: String? = nil,\n        taskType: TaskType = .other,\n        projectId: String,\n        createdAt: Date = Date(),\n        updatedAt: Date = Date(),\n        sharedContext: [ContextItem] = [],\n        agentsConfig: String? = nil,\n        memoryItems: [String] = [],\n        sessionIds: [String] = [],\n        status: TaskStatus = .pending,\n        tags: [String] = [],\n        primaryProvider: ProjectSessionSource? = nil\n    ) {\n        self.id = id\n        self.title = title\n        self.description = description\n        self.taskType = taskType\n        self.projectId = projectId\n        self.createdAt = createdAt\n        self.updatedAt = updatedAt\n        self.sharedContext = sharedContext\n        self.agentsConfig = agentsConfig\n        self.memoryItems = memoryItems\n        self.sessionIds = sessionIds\n        self.status = status\n        self.tags = tags\n        self.primaryProvider = primaryProvider\n    }\n\n    var effectiveTitle: String {\n        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? \"Untitled Task\" : trimmed\n    }\n\n    var effectiveDescription: String? {\n        guard let desc = description else { return nil }\n        let trimmed = desc.trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? nil : trimmed\n    }\n\n    func matches(search term: String) -> Bool {\n        guard !term.isEmpty else { return true }\n        let needle = term.lowercased()\n        let haystack = [\n            title,\n            description ?? \"\",\n            tags.joined(separator: \" \"),\n            agentsConfig ?? \"\"\n        ].map { $0.lowercased() }\n\n        return haystack.contains(where: { $0.contains(needle) })\n    }\n}\n\n// CodMateTask with enriched session summaries for display\nstruct TaskWithSessions: Identifiable, Hashable {\n    let task: CodMateTask\n    let sessions: [SessionSummary]\n\n    var id: UUID { task.id }\n\n    var totalDuration: TimeInterval {\n        sessions.reduce(0) { $0 + $1.duration }\n    }\n\n    var totalTokens: Int {\n        sessions.reduce(0) { $0 + $1.turnContextCount }\n    }\n\n    var lastActivityDate: Date {\n        let sessionDates = sessions.compactMap { $0.lastUpdatedAt ?? $0.startedAt }\n        return sessionDates.max() ?? task.updatedAt\n    }\n}\n"
  },
  {
    "path": "models/TerminalCursorStyleOption.swift",
    "content": "import Foundation\n\nenum TerminalCursorStyleOption: String, CaseIterable, Identifiable, Codable, Hashable {\n    case blinkBlock\n    case steadyBlock\n    case blinkUnderline\n    case steadyUnderline\n    case blinkBar\n    case steadyBar\n\n    var id: String { rawValue }\n\n    var title: String {\n        switch self {\n        case .blinkBlock: return \"Blinking Block\"\n        case .steadyBlock: return \"Steady Block\"\n        case .blinkUnderline: return \"Blinking Underline\"\n        case .steadyUnderline: return \"Steady Underline\"\n        case .blinkBar: return \"Blinking Bar\"\n        case .steadyBar: return \"Steady Bar\"\n        }\n    }\n\n    // Ghostty cursor configuration string\n    var ghosttyConfigValue: String {\n        switch self {\n        case .blinkBlock: return \"block\"\n        case .steadyBlock: return \"block\"\n        case .blinkUnderline: return \"underline\"\n        case .steadyUnderline: return \"underline\"\n        case .blinkBar: return \"bar\"\n        case .steadyBar: return \"bar\"\n        }\n    }\n\n    var ghosttyBlinkEnabled: Bool {\n        switch self {\n        case .blinkBlock, .blinkUnderline, .blinkBar:\n            return true\n        case .steadyBlock, .steadyUnderline, .steadyBar:\n            return false\n        }\n    }\n}\n\n"
  },
  {
    "path": "models/TimelineEvent.swift",
    "content": "import Foundation\n\nenum TimelineActor: Hashable {\n    case user\n    case assistant\n    case tool\n    case info\n}\n\nstruct TimelineEvent: Identifiable, Hashable {\n    let id: String\n    let timestamp: Date\n    let actor: TimelineActor\n    let visibilityKind: MessageVisibilityKind\n    let title: String?\n    let text: String?\n    let metadata: [String: String]?\n    let attachments: [TimelineAttachment]\n    let repeatCount: Int\n    let callID: String?\n\n    init(\n        id: String,\n        timestamp: Date,\n        actor: TimelineActor,\n        title: String?,\n        text: String?,\n        metadata: [String: String]?,\n        repeatCount: Int = 1,\n        attachments: [TimelineAttachment] = [],\n        visibilityKind: MessageVisibilityKind? = nil,\n        callID: String? = nil\n    ) {\n        self.id = id\n        self.timestamp = timestamp\n        self.actor = actor\n        self.visibilityKind = visibilityKind\n            ?? MessageVisibilityKind.infer(actor: actor, title: title, metadata: metadata)\n        self.title = title\n        self.text = text\n        self.metadata = metadata\n        self.attachments = attachments\n        self.repeatCount = repeatCount\n        self.callID = callID\n    }\n\n    func incrementingRepeatCount() -> TimelineEvent {\n        TimelineEvent(\n            id: id,\n            timestamp: timestamp,\n            actor: actor,\n            title: title,\n            text: text,\n            metadata: metadata,\n            repeatCount: repeatCount + 1,\n            attachments: attachments,\n            visibilityKind: visibilityKind,\n            callID: callID\n        )\n    }\n}\n\nextension TimelineEvent {\n    static let environmentContextTitle = \"Environment Context\"\n}\n\n// MARK: - Message visibility kinds and helpers\nenum MessageVisibilityKind: String, CaseIterable, Identifiable {\n    case user\n    case assistant\n    case tool\n    case codeEdit\n    case reasoning\n    case tokenUsage\n    case environmentContext\n    case turnContext\n    case infoOther\n\n    var id: String { rawValue }\n\n    var title: String {\n        settingsLabel\n    }\n\n    var settingsLabel: String {\n        switch self {\n        case .user: return \"User Message\"\n        case .assistant: return \"Assistant Message\"\n        case .tool: return \"Tool Invocation\"\n        case .codeEdit: return \"Code Edit\"\n        case .reasoning: return \"Reasoning\"\n        case .tokenUsage: return \"Token Usage\"\n        case .environmentContext: return \"Environment Context\"\n        case .turnContext: return \"Turn Context\"\n        case .infoOther: return \"Other Info\"\n        }\n    }\n}\n\nextension MessageVisibilityKind {\n    static let timelineDefault: Set<MessageVisibilityKind> = [\n        .user, .assistant, .codeEdit, .reasoning\n        // environment context is shown in its dedicated section by default\n    ]\n\n    static let markdownDefault: Set<MessageVisibilityKind> = [\n        .user, .assistant\n    ]\n\n    static func coerced(from raw: String) -> MessageVisibilityKind? {\n        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n        if let exact = MessageVisibilityKind(rawValue: trimmed) { return exact }\n        switch trimmed {\n        case \"syncing\":\n            return .turnContext\n        case \"environment\":\n            return .environmentContext\n        case \"code_edit\":\n            return .codeEdit\n        case \"codeedit\":\n            return .codeEdit\n        default:\n            return nil\n        }\n    }\n\n    static func infer(\n        actor: TimelineActor,\n        title: String?,\n        metadata: [String: String]?\n    ) -> MessageVisibilityKind {\n        if let mapped = mappedKind(rawType: nil, title: title, metadata: metadata) {\n            return mapped\n        }\n\n        switch actor {\n        case .user:\n            return .user\n        case .assistant:\n            return .assistant\n        case .tool:\n            return .tool\n        case .info:\n            return .infoOther\n        }\n    }\n\n    static func mappedKind(\n        rawType: String?,\n        title: String?,\n        metadata: [String: String]?\n    ) -> MessageVisibilityKind? {\n        if let kind = kindFromToken(rawType) { return kind }\n        if let kind = kindFromToken(metadata?[\"event_kind\"]) { return kind }\n        if let kind = kindFromToken(title) { return kind }\n        return nil\n    }\n\n    static func kindFromToken(_ value: String?) -> MessageVisibilityKind? {\n        let normalized = normalize(value)\n        guard !normalized.isEmpty else { return nil }\n\n        func matchesExact(_ tokens: [String]) -> Bool {\n            tokens.contains(normalized)\n        }\n\n        func matchesContains(_ tokens: [String]) -> Bool {\n            tokens.contains(where: { normalized.contains($0) })\n        }\n\n        if matchesExact([\"user\", \"user message\", \"user msg\"]) { return .user }\n        if matchesExact([\"assistant\", \"assistant message\", \"agent message\"]) { return .assistant }\n        if matchesContains([\"tool call\", \"tool output\", \"tool result\", \"function call\", \"tool\"]) {\n            return .tool\n        }\n        if matchesContains([\"code edit\", \"file edit\", \"apply patch\", \"applypatch\", \"codeedit\", \"patch\"]) {\n            return .codeEdit\n        }\n        if matchesContains([\"token usage\", \"token count\", \"token\"]) { return .tokenUsage }\n        if matchesContains([\"agent reasoning\", \"reasoning\", \"thinking\", \"thought\"]) { return .reasoning }\n        if matchesContains([\"environment context\"]) { return .environmentContext }\n        if matchesExact([\"context updated\"]) || matchesContains([\"turn context\"]) { return .turnContext }\n        if matchesContains([\"collaboration mode\", \"permissions instructions\", \"permissions instruction\"]) {\n            return .infoOther\n        }\n        if matchesExact([\"info\", \"warning\", \"error\", \"info other\", \"info_other\"])\n            || matchesContains([\"system message\", \"system summary\"]) { return .infoOther }\n\n        return nil\n    }\n\n    private static func normalize(_ value: String?) -> String {\n        guard let raw = value else { return \"\" }\n        var normalized = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n        if normalized.isEmpty { return \"\" }\n        normalized = normalized\n            .replacingOccurrences(of: \"_\", with: \" \")\n            .replacingOccurrences(of: \"-\", with: \" \")\n        while normalized.contains(\"  \") {\n            normalized = normalized.replacingOccurrences(of: \"  \", with: \" \")\n        }\n        return normalized\n    }\n}\n\nextension MessageVisibilityKind {\n    var defaultActor: TimelineActor {\n        switch self {\n        case .user: return .user\n        case .assistant: return .assistant\n        case .tool, .codeEdit: return .tool\n        case .reasoning, .tokenUsage, .environmentContext, .turnContext, .infoOther:\n            return .info\n        }\n    }\n}\n\nextension Set where Element == MessageVisibilityKind {\n    func contains(event: TimelineEvent) -> Bool {\n        contains(event.visibilityKind)\n    }\n\n    var rawValues: [String] {\n        map { $0.rawValue }.sorted()\n    }\n\n    static func fromRawValues(_ rawValues: [String]?) -> Set<MessageVisibilityKind>? {\n        guard let rawValues else { return nil }\n        return Set(rawValues.compactMap { MessageVisibilityKind.coerced(from: $0) })\n    }\n}\n\nstruct TimelineAttachment: Hashable, Sendable {\n    enum Kind: String, Hashable, Sendable {\n        case image\n    }\n\n    let id: String\n    let kind: Kind\n    let label: String?\n    let dataURL: String?\n    let url: URL?\n\n    init(\n        kind: Kind,\n        label: String? = nil,\n        dataURL: String? = nil,\n        url: URL? = nil,\n        id: String = UUID().uuidString\n    ) {\n        self.id = id\n        self.kind = kind\n        self.label = label\n        self.dataURL = dataURL\n        self.url = url\n    }\n\n    static func == (lhs: TimelineAttachment, rhs: TimelineAttachment) -> Bool {\n        lhs.id == rhs.id\n    }\n\n    func hash(into hasher: inout Hasher) {\n        hasher.combine(id)\n    }\n}\n"
  },
  {
    "path": "models/UnifiedProviderCatalog.swift",
    "content": "import Foundation\nimport SwiftUI\n\nstruct UnifiedProviderChoice: Identifiable, Hashable {\n  enum Kind: String { case oauth, apiKey }\n  let id: String\n  let title: String\n  let kind: Kind\n  let isAvailable: Bool\n  let availabilityHint: String?\n}\n\nstruct UnifiedProviderSection: Identifiable, Hashable {\n  let id: String\n  let title: String\n  let providers: [UnifiedProviderChoice]\n}\n\n@MainActor\nfinal class UnifiedProviderCatalogModel: ObservableObject {\n  @Published private(set) var sections: [UnifiedProviderSection] = []\n  @Published private(set) var modelsByProviderId: [String: [String]] = [:]\n  @Published private(set) var availabilityByProviderId: [String: String] = [:]\n  @Published private(set) var kindByProviderId: [String: UnifiedProviderChoice.Kind] = [:]\n\n  private var registryProviders: [ProvidersRegistryService.Provider] = []\n  // Model ID to provider mapping (for reliable provider inference in autoProxy mode)\n  private var modelToProviderMap: [String: String] = [:]\n  // Rerouted models by label (for provider inference fallback)\n  private var reroutedModelsByLabel: [String: [String]] = [:]\n\n  func reload(preferences: SessionPreferencesStore, forceRefresh: Bool = false) async {\n    let taskToken = AppLogger.shared.beginTask(\"Reloading provider catalog\", source: \"ProviderCatalog\")\n    let registry = ProvidersRegistryService()\n    let providers = await registry.listProviders()\n    registryProviders = providers\n    AppLogger.shared.info(\"Loaded \\(providers.count) providers from registry\", source: \"ProviderCatalog\")\n\n    // All providers now use Auto-Proxy mode through CLIProxyAPI\n    // No separate rerouteBuiltIn/reroute3P switches - providers are enabled/disabled via the Providers list\n    // OAuth is enabled at account level (oauthAccountsEnabled), not provider level\n    let oauthAccountsEnabledSet = preferences.oauthAccountsEnabled\n    let apiKeyEnabledSet = preferences.apiKeyProvidersEnabled\n    let proxyRunning = CLIProxyService.shared.isRunning\n\n    AppLogger.shared.info(\"Proxy running=\\(proxyRunning), OAuth accounts enabled=\\(oauthAccountsEnabledSet.count), API key enabled=\\(apiKeyEnabledSet.count)\", source: \"ProviderCatalog\")\n\n    var localModels: [CLIProxyService.LocalModel] = []\n    // Always fetch models if proxy is running, even if reroute is not enabled\n    // This allows auto-proxy mode to show available models for selection\n    if proxyRunning {\n      localModels = await CLIProxyService.shared.fetchLocalModels(forceRefresh: forceRefresh)\n      AppLogger.shared.info(\"Fetched \\(localModels.count) models from CLIProxyAPI\", source: \"ProviderCatalog\")\n    } else {\n      AppLogger.shared.warning(\"Skipping local model fetch: CLIProxy not running\", source: \"ProviderCatalog\")\n    }\n    let mapped = mapLocalModels(localModels)\n\n    var nextSections: [UnifiedProviderSection] = []\n    var nextModels: [String: [String]] = [:]\n    var availability: [String: String] = [:]\n    var kinds: [String: UnifiedProviderChoice.Kind] = [:]\n\n    // OAuth section - support multiple accounts per provider\n    let oauthProviders = LocalAuthProvider.allCases.sorted {\n      $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending\n    }\n    var oauthChoices: [UnifiedProviderChoice] = []\n    let oauthAccounts = CLIProxyService.shared.listOAuthAccounts()\n\n    for provider in oauthProviders {\n      let providerAccounts = oauthAccounts.filter { $0.provider == provider }\n\n      if providerAccounts.isEmpty {\n        // No accounts - show provider as unavailable\n        let id = UnifiedProviderID.oauth(provider, accountId: nil)\n        let hint = availabilityHintForOAuth(\n          proxyRunning: proxyRunning,\n          oauthEnabled: false,\n          authAvailable: false,\n          providerName: provider.displayName\n        )\n        let choice = UnifiedProviderChoice(\n          id: id,\n          title: provider.displayName,\n          kind: .oauth,\n          isAvailable: false,\n          availabilityHint: hint\n        )\n        oauthChoices.append(choice)\n        kinds[id] = .oauth\n        nextModels[id] = []\n        if let hint { availability[id] = hint }\n      } else {\n        // Multiple accounts - create one choice per account\n        for account in providerAccounts.sorted(by: { ($0.email ?? \"\") < ($1.email ?? \"\") }) {\n          let id = UnifiedProviderID.oauth(provider, accountId: account.id)\n          // Account is available if proxy is running and account is enabled\n          let accountEnabled = oauthAccountsEnabledSet.contains(account.id)\n          let available = proxyRunning && accountEnabled\n          let hint = availabilityHintForOAuth(\n            proxyRunning: proxyRunning,\n            oauthEnabled: accountEnabled,\n            authAvailable: true,\n            providerName: provider.displayName\n          )\n          let accountLabel = account.email ?? account.id\n          let title = \"\\(provider.displayName) (\\(accountLabel))\"\n          let choice = UnifiedProviderChoice(\n            id: id,\n            title: title,\n            kind: .oauth,\n            isAvailable: available,\n            availabilityHint: available ? nil : hint\n          )\n          oauthChoices.append(choice)\n          kinds[id] = .oauth\n          if available && accountEnabled {\n            // Use provider-level models (all accounts of same provider share models)\n            // Only include models if account is enabled\n            let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil)\n            let models = sortModels(mapped.builtIn[providerBaseId] ?? [])\n            nextModels[id] = models\n            // Also store at provider level for backward compatibility\n            if nextModels[providerBaseId] == nil {\n              nextModels[providerBaseId] = models\n            }\n          } else {\n            nextModels[id] = []\n            if let hint { availability[id] = hint }\n          }\n        }\n      }\n    }\n\n    // Sort all OAuth choices by title (provider name + account)\n    oauthChoices.sort { $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending }\n\n    if !oauthChoices.isEmpty {\n      nextSections.append(\n        UnifiedProviderSection(id: \"oauth\", title: \"OAuth Providers\", providers: oauthChoices)\n      )\n    }\n\n    // API Key section\n    let apiChoices: [UnifiedProviderChoice] = providers\n      .sorted {\n        UnifiedProviderID.providerDisplayName($0).localizedCaseInsensitiveCompare(\n          UnifiedProviderID.providerDisplayName($1)) == .orderedAscending\n      }\n      .map { provider in\n        let id = UnifiedProviderID.api(provider.id)\n        let isEnabled = apiKeyEnabledSet.contains(provider.id)\n        // Provider is available if proxy is running (all providers use Auto-Proxy mode)\n        let available = proxyRunning\n        let hint = availabilityHintForAPIKey(proxyRunning: proxyRunning)\n        kinds[id] = .apiKey\n        if proxyRunning && isEnabled {\n          // Try multiple label variations to match models from CLIProxyAPI\n          // CLIProxyAPI uses provider.name ?? provider.id as the name in config.yaml\n          let providerName = provider.name ?? provider.id\n          let displayName = UnifiedProviderID.providerDisplayName(provider)\n\n          // Try normalized display name first\n          var models: [String] = []\n          let normalizedDisplayName = normalizeLabel(displayName)\n          if let found = mapped.rerouted[normalizedDisplayName] {\n            models = found\n          } else {\n            // Try normalized provider name (as used in syncThirdPartyProviders)\n            let normalizedName = normalizeLabel(providerName)\n            if let found = mapped.rerouted[normalizedName] {\n              models = found\n            } else {\n              // Try normalized provider ID\n              let normalizedId = normalizeLabel(provider.id)\n              if let found = mapped.rerouted[normalizedId] {\n                models = found\n              } else {\n                // Try all rerouted keys to find a match (fuzzy matching)\n                for (key, modelList) in mapped.rerouted {\n                  if key.contains(normalizedName) || normalizedName.contains(key) ||\n                     key.contains(normalizedDisplayName) || normalizedDisplayName.contains(key) {\n                    models.append(contentsOf: modelList)\n                  }\n                }\n              }\n            }\n          }\n          nextModels[id] = sortModels(Array(Set(models)))\n        } else {\n          // Provider is enabled but CLIProxyAPI not running or no models returned\n          // Do not fallback to catalog - only show models actually available via CLIProxyAPI\n          nextModels[id] = []\n        }\n        if !available, let hint { availability[id] = hint }\n        return UnifiedProviderChoice(\n          id: id,\n          title: UnifiedProviderID.providerDisplayName(provider),\n          kind: .apiKey,\n          isAvailable: available,\n          availabilityHint: available ? nil : hint\n        )\n      }\n    if !apiChoices.isEmpty {\n      nextSections.append(\n        UnifiedProviderSection(id: \"api\", title: \"API Key Providers\", providers: apiChoices)\n      )\n    }\n\n    // Add auto-proxy models: all models from CLI Proxy API\n    // For auto-proxy mode, show all available models regardless of reroute/enabled settings\n    // This allows users to see and select models even if they haven't configured reroute yet\n    if proxyRunning {\n      var allProxyModels = Set<String>()\n\n      // Collect all OAuth provider models (only from enabled accounts)\n      // Check if any account of this provider is enabled\n      for provider in LocalAuthProvider.allCases {\n        let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil)\n        if let models = mapped.builtIn[providerBaseId], !models.isEmpty {\n          // Check if any account of this provider is enabled\n          let providerAccounts = oauthAccounts.filter { $0.provider == provider }\n          let hasEnabledAccount = providerAccounts.contains { oauthAccountsEnabledSet.contains($0.id) }\n          if hasEnabledAccount {\n            allProxyModels.formUnion(models)\n          }\n        }\n      }\n\n      // Collect all API key provider models (only from enabled providers)\n      AppLogger.shared.info(\"API key enabled providers: \\(Array(apiKeyEnabledSet))\", source: \"ProviderCatalog\")\n      AppLogger.shared.info(\"Rerouted model labels: \\(Array(mapped.rerouted.keys))\", source: \"ProviderCatalog\")\n\n      for provider in providers {\n        let isEnabled = apiKeyEnabledSet.contains(provider.id)\n        let providerName = provider.name ?? provider.id\n        let displayName = UnifiedProviderID.providerDisplayName(provider)\n        // Try multiple label variations\n        let normalizedDisplayName = normalizeLabel(displayName)\n        let normalizedName = normalizeLabel(providerName)\n        let normalizedId = normalizeLabel(provider.id)\n\n        // Debug: log all attempts\n        AppLogger.shared.info(\"Provider \\(provider.id): trying to match - displayName='\\(normalizedDisplayName)', name='\\(normalizedName)', id='\\(normalizedId)'\", source: \"ProviderCatalog\")\n\n        var foundModels: [String] = []\n        if let models = mapped.rerouted[normalizedDisplayName] {\n          foundModels = models\n          AppLogger.shared.info(\"Provider \\(provider.id): matched by displayName '\\(normalizedDisplayName)' -> \\(models.count) models\", source: \"ProviderCatalog\")\n        } else if let models = mapped.rerouted[normalizedName] {\n          foundModels = models\n          AppLogger.shared.info(\"Provider \\(provider.id): matched by name '\\(normalizedName)' -> \\(models.count) models\", source: \"ProviderCatalog\")\n        } else if let models = mapped.rerouted[normalizedId] {\n          foundModels = models\n          AppLogger.shared.info(\"Provider \\(provider.id): matched by id '\\(normalizedId)' -> \\(models.count) models\", source: \"ProviderCatalog\")\n        } else {\n          // Try fuzzy matching\n          for (key, modelList) in mapped.rerouted {\n            if key.contains(normalizedName) || normalizedName.contains(key) ||\n               key.contains(normalizedDisplayName) || normalizedDisplayName.contains(key) {\n              foundModels.append(contentsOf: modelList)\n            }\n          }\n          if !foundModels.isEmpty {\n            AppLogger.shared.info(\"Provider \\(provider.id): matched by fuzzy -> \\(foundModels.count) models\", source: \"ProviderCatalog\")\n          }\n        }\n\n        if !foundModels.isEmpty {\n          if isEnabled {\n            allProxyModels.formUnion(foundModels)\n            AppLogger.shared.info(\"Provider \\(provider.id): ADDED \\(foundModels.count) models to auto-proxy (enabled)\", source: \"ProviderCatalog\")\n          } else {\n            AppLogger.shared.info(\"Provider \\(provider.id): SKIPPED \\(foundModels.count) models (disabled)\", source: \"ProviderCatalog\")\n          }\n        } else {\n          AppLogger.shared.warning(\"Provider \\(provider.id) (\\(providerName)): NO models matched from rerouted labels\", source: \"ProviderCatalog\")\n        }\n      }\n\n      // Only include models from enabled providers - do not include models from disabled providers\n      // This prevents showing models from providers that users have explicitly disabled\n      let sortedModels = sortModels(Array(allProxyModels))\n      AppLogger.shared.info(\"Auto-proxy: \\(sortedModels.count) models (from enabled providers only)\", source: \"ProviderCatalog\")\n      nextModels[UnifiedProviderID.autoProxyId] = sortedModels\n    } else {\n      AppLogger.shared.warning(\"Auto-proxy models empty: CLIProxy not running\", source: \"ProviderCatalog\")\n      nextModels[UnifiedProviderID.autoProxyId] = []\n    }\n\n    sections = nextSections\n    modelsByProviderId = nextModels\n    availabilityByProviderId = availability\n    kindByProviderId = kinds\n\n    let autoProxyCount = nextModels[UnifiedProviderID.autoProxyId]?.count ?? 0\n    AppLogger.shared.endTask(taskToken, message: \"Catalog reloaded: \\(nextSections.count) sections, auto-proxy=\\(autoProxyCount) models\", source: \"ProviderCatalog\")\n  }\n\n  func normalizeProviderId(_ raw: String?) -> String? {\n    UnifiedProviderID.normalize(raw, registryProviders: registryProviders)\n  }\n\n  func models(for providerId: String?) -> [String] {\n    guard let providerId else { return [] }\n    // For OAuth accounts, models are shared at provider level\n    let parsed = UnifiedProviderID.parse(providerId)\n    if case .oauth(let provider, _) = parsed {\n      let providerBaseId = UnifiedProviderID.oauth(provider, accountId: nil)\n      return modelsByProviderId[providerBaseId] ?? modelsByProviderId[providerId] ?? []\n    }\n    return modelsByProviderId[providerId] ?? []\n  }\n\n  /// Returns sanitized models with both display names and original IDs\n  func sanitizedModels(for providerId: String?) -> [ModelNameSanitizer.SanitizedModel] {\n    let rawModels = models(for: providerId)\n    return ModelNameSanitizer.sanitize(rawModels)\n  }\n\n  /// Returns the display name for a single model (sanitized)\n  func displayName(for model: String) -> String {\n    return ModelNameSanitizer.sanitizeSingle(model)\n  }\n\n  /// Resolves a display name back to the original model ID for a given provider\n  func resolveModelId(displayName: String, providerId: String?) -> String? {\n    let sanitized = sanitizedModels(for: providerId)\n    return sanitized.first { $0.displayName == displayName }?.originalId\n  }\n\n  func isProviderAvailable(_ providerId: String?) -> Bool {\n    guard let providerId else { return true }\n    return availabilityByProviderId[providerId] == nil\n  }\n\n  func availabilityHint(for providerId: String?) -> String? {\n    guard let providerId else { return nil }\n    return availabilityByProviderId[providerId]\n  }\n\n  func sectionTitle(for providerId: String?) -> String? {\n    guard let providerId, let kind = kindByProviderId[providerId] else { return nil }\n    switch kind {\n    case .oauth: return \"OAuth Providers\"\n    case .apiKey: return \"API Key Providers\"\n    }\n  }\n\n  /// Get provider title from provider ID\n  func providerTitle(for providerId: String?) -> String? {\n    guard let providerId else { return nil }\n    // Search through sections to find the provider\n    for section in sections {\n      if let provider = section.providers.first(where: { $0.id == providerId }) {\n        return provider.title\n      }\n    }\n    // Fallback: parse provider ID and generate title\n    let parsed = UnifiedProviderID.parse(providerId)\n    switch parsed {\n    case .oauth(let authProvider, let accountId):\n      if let accountId = accountId, !accountId.isEmpty {\n        return \"\\(authProvider.displayName) (\\(accountId))\"\n      }\n      return authProvider.displayName\n    case .api(let apiId):\n      // Try to find in registry\n      if let provider = registryProviders.first(where: { $0.id == apiId }) {\n        return UnifiedProviderID.providerDisplayName(provider)\n      }\n      return apiId\n    case .autoProxy:\n      return \"Auto-Proxy (CliProxyAPI)\"\n    default:\n      return nil\n    }\n  }\n\n  /// Infer provider from model ID (useful when providerId is autoProxy)\n  /// Returns the service provider display name (e.g., \"AICodeWith\", \"OpenRouter\") or OAuth provider name\n  ///\n  /// This method ONLY uses metadata (owned_by) - the single source of truth.\n  /// No fallback, no pattern matching. Only recognize service providers, not manufacturers.\n  func inferProviderFromModel(_ modelId: String) -> String? {\n    // Only use metadata mapping built from LocalModel.owned_by\n    if let providerId = modelToProviderMap[modelId] {\n      let title = providerTitle(for: providerId)\n      AppLogger.shared.info(\"[inferProvider] '\\(modelId)' → metadata: providerId=\\(providerId), title=\\(title ?? \"nil\")\", source: \"ProviderCatalog\")\n      return title\n    }\n\n    // No fallback - if model is not in metadata map, return nil\n    AppLogger.shared.warning(\"[inferProvider] '\\(modelId)' → No metadata found, returning nil\", source: \"ProviderCatalog\")\n    return nil\n  }\n\n  // MARK: - Local model mapping\n  private struct LocalModelMap {\n    var builtIn: [String: [String]]\n    var rerouted: [String: [String]]\n  }\n\n  private func mapLocalModels(_ models: [CLIProxyService.LocalModel]) -> LocalModelMap {\n    var builtIn: [String: [String]] = [:]\n    var rerouted: [String: [String]] = [:]\n    var modelToProvider: [String: String] = [:]\n\n    for provider in LocalAuthProvider.allCases {\n      builtIn[UnifiedProviderID.oauth(provider)] = []\n    }\n\n    AppLogger.shared.info(\"Mapping \\(models.count) models from CLIProxyAPI\", source: \"ProviderCatalog\")\n    var skippedModels: [(id: String, reason: String)] = []\n\n    for model in models {\n      // Debug: Log all OAuth models metadata for comparison\n      if model.provider != nil || model.source != nil || model.owned_by != nil {\n        let providerVal = model.provider ?? \"nil\"\n        let sourceVal = model.source ?? \"nil\"\n        let ownedByVal = model.owned_by ?? \"nil\"\n        AppLogger.shared.info(\"[DEBUG] Model '\\(model.id)' raw metadata: provider='\\(providerVal)', source='\\(sourceVal)', owned_by='\\(ownedByVal)'\", source: \"ProviderCatalog\")\n      }\n\n      if let builtin = builtInProvider(for: model),\n        let auth = UnifiedProviderID.authProvider(for: builtin)\n      {\n        let id = UnifiedProviderID.oauth(auth)\n        var list = builtIn[id] ?? []\n        if !list.contains(model.id) { list.append(model.id) }\n        builtIn[id] = list\n        // Map model to provider for reliable inference\n        modelToProvider[model.id] = id\n        AppLogger.shared.info(\"Model '\\(model.id)' → builtIn(\\(builtin.rawValue)) via metadata=[\\(model.provider ?? \"nil\"), \\(model.source ?? \"nil\"), \\(model.owned_by ?? \"nil\")]\", source: \"ProviderCatalog\")\n        continue\n      }\n      guard let label = rerouteProviderLabel(for: model) else {\n        skippedModels.append((id: model.id, reason: \"No label (provider=\\(model.provider ?? \"nil\"), source=\\(model.source ?? \"nil\"), owned_by=\\(model.owned_by ?? \"nil\"))\"))\n        continue\n      }\n      // Debug: log rerouted model details\n      let normalizedLabel = normalizeLabel(label)\n      AppLogger.shared.info(\"Model '\\(model.id)' → rerouted['\\(label)'] (normalized: '\\(normalizedLabel)') via metadata=[\\(model.provider ?? \"nil\"), \\(model.source ?? \"nil\"), \\(model.owned_by ?? \"nil\")]\", source: \"ProviderCatalog\")\n\n      let key = normalizedLabel\n      var list = rerouted[key] ?? []\n      if !list.contains(model.id) { list.append(model.id) }\n      rerouted[key] = list\n      // For rerouted models, try to find the API provider ID\n      // Try multiple matching strategies to handle different label formats\n      var matchedProviderId: String? = nil\n      if let apiProvider = findAPIProviderByLabel(label) {\n        matchedProviderId = UnifiedProviderID.api(apiProvider.id)\n      } else {\n        // Try matching by provider name or ID directly\n        for provider in registryProviders {\n          let providerName = provider.name ?? provider.id\n          let normalizedProviderName = normalizeLabel(providerName)\n          let normalizedProviderId = normalizeLabel(provider.id)\n          if key == normalizedProviderName || key == normalizedProviderId {\n            matchedProviderId = UnifiedProviderID.api(provider.id)\n            break\n          }\n        }\n      }\n      // Store the mapping (use virtual ID if no match found)\n      if let matchedId = matchedProviderId {\n        modelToProvider[model.id] = matchedId\n      } else {\n        // If provider not found in registry, create a virtual provider ID using the label\n        // This handles cases where users configure openai-compatibility providers directly in CLIProxyAPI\n        let virtualProviderId = UnifiedProviderID.api(label)\n        modelToProvider[model.id] = virtualProviderId\n      }\n    }\n    // Store the mapping for later use\n    modelToProviderMap = modelToProvider\n    // Store rerouted models by label for fallback inference\n    reroutedModelsByLabel = rerouted\n\n    // Log skipped models for debugging\n    if !skippedModels.isEmpty {\n      AppLogger.shared.warning(\"Skipped \\(skippedModels.count) models without provider labels:\", source: \"ProviderCatalog\")\n      for (id, reason) in skippedModels.prefix(5) {\n        AppLogger.shared.warning(\"  - '\\(id)': \\(reason)\", source: \"ProviderCatalog\")\n      }\n      if skippedModels.count > 5 {\n        AppLogger.shared.warning(\"  ... and \\(skippedModels.count - 5) more\", source: \"ProviderCatalog\")\n      }\n    }\n\n    return LocalModelMap(builtIn: builtIn, rerouted: rerouted)\n  }\n\n  private func findAPIProviderByLabel(_ label: String) -> ProvidersRegistryService.Provider? {\n    let normalized = normalizeLabel(label)\n    // First, try to find in registry providers\n    if let provider = registryProviders.first(where: { provider in\n      let displayName = UnifiedProviderID.providerDisplayName(provider)\n      return normalizeLabel(displayName) == normalized || normalizeLabel(provider.id) == normalized\n    }) {\n      return provider\n    }\n    // If not found, return nil (caller will create a virtual provider)\n    return nil\n  }\n\n  private func builtInProvider(for model: CLIProxyService.LocalModel) -> LocalServerBuiltInProvider? {\n    let hint = model.provider ?? model.source ?? model.owned_by\n    if let hint,\n      let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesOwnedBy(hint) })\n    {\n      return provider\n    }\n\n    // If owned_by is present but doesn't match any built-in provider,\n    // this is a third-party provider - don't do pattern matching on model ID\n    if let ownedBy = model.owned_by?.trimmingCharacters(in: .whitespacesAndNewlines),\n       !ownedBy.isEmpty,\n       !LocalServerBuiltInProvider.allCases.contains(where: { $0.matchesOwnedBy(ownedBy) }) {\n      return nil\n    }\n\n    // Only do pattern matching if owned_by is nil or matched a built-in provider\n    let modelId = model.id\n    if let provider = LocalServerBuiltInProvider.allCases.first(where: { $0.matchesModelId(modelId) }) {\n      return provider\n    }\n    return nil\n  }\n\n  private func rerouteProviderLabel(for model: CLIProxyService.LocalModel) -> String? {\n    // Priority 1: Use cached provider name from config.yaml (most reliable)\n    // This directly reads from our own config.yaml, no guessing needed\n    if let cachedName = CLIProxyService.shared.getProviderName(for: model.id) {\n      return cachedName\n    }\n\n    // Priority 2: Fall back to metadata fields (provider > source > owned_by)\n    // CLIProxyAPI returns models with source field containing the provider name from config.yaml\n    let hint = model.provider ?? model.source ?? model.owned_by\n    let trimmed = hint?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    return trimmed.isEmpty ? nil : trimmed\n  }\n\n  private func normalizeLabel(_ value: String) -> String {\n    value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n  }\n\n  private func sortModels(_ list: [String]) -> [String] {\n    list.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n  }\n\n  private func availabilityHintForOAuth(\n    proxyRunning: Bool,\n    oauthEnabled: Bool,\n    authAvailable: Bool,\n    providerName: String\n  ) -> String? {\n    if !proxyRunning {\n      return \"CLI Proxy API isn't running. Start it in Providers → CLI Proxy API.\"\n    }\n    if !oauthEnabled {\n      return \"Enable this provider in Providers to use this option.\"\n    }\n    if !authAvailable {\n      return \"Sign in to \\(providerName) in Providers to use this option.\"\n    }\n    return nil\n  }\n\n  private func availabilityHintForAPIKey(proxyRunning: Bool) -> String? {\n    if !proxyRunning {\n      return \"CLI Proxy API isn't running. Start it in Providers → CLI Proxy API.\"\n    }\n    return nil\n  }\n}\n"
  },
  {
    "path": "models/UnifiedProviderID.swift",
    "content": "import Foundation\n\nenum UnifiedProviderID {\n  static let oauthPrefix = \"oauth:\"\n  static let apiPrefix = \"api:\"\n  static let legacyReroutePrefix = \"local-reroute:\"\n\n  /// Special provider ID for \"Auto (CLI Proxy API)\" mode in simple picker\n  static let autoProxyId = \"__auto_cli_proxy__\"\n\n  enum Parsed: Equatable {\n    case oauth(LocalAuthProvider, accountId: String?)\n    case api(String)\n    case legacyBuiltin(LocalServerBuiltInProvider)\n    case legacyReroute(String)\n    case autoProxy\n    case unknown(String)\n  }\n\n  static func oauth(_ provider: LocalAuthProvider, accountId: String? = nil) -> String {\n    if let accountId = accountId, !accountId.isEmpty {\n      return \"\\(oauthPrefix)\\(provider.rawValue):\\(accountId)\"\n    }\n    return \"\\(oauthPrefix)\\(provider.rawValue)\"\n  }\n\n  static func api(_ id: String) -> String {\n    \"\\(apiPrefix)\\(id)\"\n  }\n\n  static func parse(_ raw: String) -> Parsed {\n    // Check for special auto proxy ID first\n    if raw == autoProxyId {\n      return .autoProxy\n    }\n    if raw.hasPrefix(oauthPrefix) {\n      let value = String(raw.dropFirst(oauthPrefix.count))\n      // Check if it contains account ID (format: provider:accountId)\n      if let colonIndex = value.firstIndex(of: \":\") {\n        let providerValue = String(value[..<colonIndex])\n        let accountId = String(value[value.index(after: colonIndex)...])\n        if let provider = LocalAuthProvider(rawValue: providerValue) {\n          return .oauth(provider, accountId: accountId)\n        }\n      } else {\n        // Legacy format without account ID\n        if let provider = LocalAuthProvider(rawValue: value) {\n          return .oauth(provider, accountId: nil)\n        }\n      }\n      return .unknown(raw)\n    }\n    if raw.hasPrefix(apiPrefix) {\n      let value = String(raw.dropFirst(apiPrefix.count))\n      return .api(value)\n    }\n    if let builtin = LocalServerBuiltInProvider.from(providerId: raw) {\n      return .legacyBuiltin(builtin)\n    }\n    if raw.hasPrefix(legacyReroutePrefix) {\n      let value = String(raw.dropFirst(legacyReroutePrefix.count)).trimmingCharacters(\n        in: .whitespacesAndNewlines)\n      return .legacyReroute(value)\n    }\n    return .unknown(raw)\n  }\n\n  static func normalize(\n    _ raw: String?,\n    registryProviders: [ProvidersRegistryService.Provider]\n  ) -> String? {\n    guard let raw = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {\n      return nil\n    }\n    switch parse(raw) {\n    case .autoProxy:\n      return autoProxyId\n    case .oauth:\n      return raw\n    case .api:\n      return raw\n    case .legacyBuiltin(let builtin):\n      if let auth = authProvider(for: builtin) {\n        return oauth(auth, accountId: nil)\n      }\n      return nil\n    case .legacyReroute(let label):\n      if let resolved = resolveAPIProviderId(\n        byLabel: label,\n        registryProviders: registryProviders\n      ) {\n        return api(resolved)\n      }\n      return nil\n    case .unknown(let value):\n      if let match = registryProviders.first(where: { $0.id == value }) {\n        return api(match.id)\n      }\n      if let match = registryProviders.first(where: {\n        providerDisplayName($0).localizedCaseInsensitiveCompare(value) == .orderedSame\n      }) {\n        return api(match.id)\n      }\n      return nil\n    }\n  }\n\n  static func authProvider(for builtin: LocalServerBuiltInProvider) -> LocalAuthProvider? {\n    switch builtin {\n    case .openai:\n      return .codex\n    case .anthropic:\n      return .claude\n    case .gemini:\n      return .gemini\n    case .antigravity:\n      return .antigravity\n    case .qwen:\n      return .qwen\n    }\n  }\n\n  static func resolveAPIProviderId(\n    byLabel label: String,\n    registryProviders: [ProvidersRegistryService.Provider]\n  ) -> String? {\n    let normalized = label.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    guard !normalized.isEmpty else { return nil }\n    if let match = registryProviders.first(where: {\n      providerDisplayName($0).lowercased() == normalized\n    }) {\n      return match.id\n    }\n    if let match = registryProviders.first(where: { $0.id.lowercased() == normalized }) {\n      return match.id\n    }\n    return nil\n  }\n\n  static func providerDisplayName(_ provider: ProvidersRegistryService.Provider) -> String {\n    let name = provider.name?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    return name.isEmpty ? provider.id : name\n  }\n}\n"
  },
  {
    "path": "models/UpdateViewModel.swift",
    "content": "import AppKit\nimport Foundation\n\n@MainActor\nfinal class UpdateViewModel: ObservableObject {\n  @Published private(set) var state: UpdateService.UpdateState = .idle\n  @Published private(set) var isDownloading = false\n  @Published var showInstallInstructions = false\n  @Published var lastError: String?\n  @Published private(set) var lastCheckedAt: Date?\n\n  let installInstructions = \"Download completed. Open the DMG and drag CodMate into Applications.\"\n\n  private let service: UpdateService\n  private var checkTask: Task<Void, Never>?\n  private var downloadTask: Task<Void, Never>?\n\n  init(service: UpdateService = .shared) {\n    self.service = service\n  }\n\n  func loadCached() {\n    checkTask?.cancel()\n    checkTask = Task { [weak self] in\n      guard let self else { return }\n      if let cached = await service.cachedInfo() {\n        state = serviceAvailability(for: cached)\n      }\n      lastCheckedAt = await service.lastCheckedAt()\n    }\n  }\n\n  func checkIfNeeded(trigger: UpdateService.CheckTrigger) {\n    checkTask?.cancel()\n    checkTask = Task { [weak self] in\n      guard let self else { return }\n      state = .checking\n      let result = await service.checkIfNeeded(trigger: trigger)\n      state = result\n      lastCheckedAt = await service.lastCheckedAt()\n    }\n  }\n\n  func checkNow() {\n    checkTask?.cancel()\n    state = .checking\n    checkTask = Task { [weak self] in\n      guard let self else { return }\n      let result = await service.checkNow()\n      state = result\n      lastCheckedAt = await service.lastCheckedAt()\n    }\n  }\n\n  func downloadIfNeeded() {\n    guard case .updateAvailable(let info) = state else { return }\n    downloadTask?.cancel()\n    isDownloading = true\n    lastError = nil\n    downloadTask = Task { [weak self] in\n      guard let self else { return }\n      do {\n        let targetURL = try await downloadAsset(info: info)\n        NSWorkspace.shared.open(targetURL)\n        showInstallInstructions = true\n      } catch {\n        lastError = error.localizedDescription\n      }\n      isDownloading = false\n    }\n  }\n\n  private func serviceAvailability(for info: UpdateService.UpdateInfo) -> UpdateService.UpdateState {\n    let current = Bundle.main.infoDictionary?[\"CFBundleShortVersionString\"] as? String ?? \"0\"\n    if let currentVersion = Version(current), let latestVersion = Version(info.latestVersion), latestVersion <= currentVersion {\n      return .upToDate(current: current, latest: info.latestVersion)\n    }\n    return .updateAvailable(info)\n  }\n\n  private func downloadAsset(info: UpdateService.UpdateInfo) async throws -> URL {\n    let (tempURL, _) = try await URLSession.shared.download(from: info.assetURL)\n    let baseName = info.assetName\n    let targetDir: URL\n    if AppSandbox.isEnabled {\n      targetDir = FileManager.default.temporaryDirectory\n    } else {\n      let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first\n      targetDir = downloads ?? FileManager.default.temporaryDirectory\n    }\n    var targetURL = targetDir.appendingPathComponent(baseName)\n    if FileManager.default.fileExists(atPath: targetURL.path) {\n      let stamp = ISO8601DateFormatter().string(from: Date()).replacingOccurrences(of: \":\", with: \"-\")\n      targetURL = targetDir.appendingPathComponent(\"\\(stamp)-\\(baseName)\")\n    }\n    try FileManager.default.moveItem(at: tempURL, to: targetURL)\n    return targetURL\n  }\n}\n"
  },
  {
    "path": "models/UsageProviderSnapshot.swift",
    "content": "import Foundation\n\nenum UsageProviderKind: String, CaseIterable, Identifiable {\n  case codex\n  case claude\n  case gemini\n\n  var id: String { rawValue }\n\n  var displayName: String {\n    switch self {\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude\"\n    case .gemini: return \"Gemini\"\n    }\n  }\n\n  var accentColorName: String {\n    switch self {\n    case .codex: return \"accentColor\"\n    case .claude: return \"purple\"\n    case .gemini: return \"teal\"\n    }\n  }\n\n  var baseKind: SessionSource.Kind {\n    switch self {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n\n}\n\npublic struct UsageMetricSnapshot: Identifiable, Equatable {\n  public enum Kind { case context, fiveHour, weekly, sessionExpiry, quota, snapshot }\n\n  public enum HealthState {\n    case healthy   // Usage is slower than time progress (blue)\n    case warning   // Usage is faster than time progress (orange)\n    case unknown   // Cannot determine (no time cycle or insufficient data)\n  }\n\n  public let id = UUID()\n  public let kind: Kind\n  public let label: String\n  public let usageText: String?\n  public let percentText: String?\n  public let progress: Double?\n  public let resetDate: Date?\n  public let fallbackWindowMinutes: Int?\n\n  fileprivate var priorityDate: Date? { resetDate }\n\n  /// Calculate health state by comparing usage progress vs time progress\n  public func healthState(relativeTo now: Date = Date()) -> HealthState {\n    // Only applicable to time-based metrics\n    guard kind == .fiveHour || kind == .weekly else {\n      return .unknown  // context, snapshot, etc. have no time cycle\n    }\n\n    // Need complete data to calculate\n    guard let remainingPercent = progress,\n          let resetDate = resetDate,\n          let windowMinutes = fallbackWindowMinutes,\n          resetDate > now else {\n      return .unknown\n    }\n\n    // Calculate total cycle duration in seconds\n    let totalDuration = Double(windowMinutes) * 60.0\n\n    // Infer cycle start time by subtracting total duration from reset time\n    let cycleStart = resetDate.addingTimeInterval(-totalDuration)\n\n    // Sanity check: cycle should have already started\n    guard cycleStart <= now else {\n      return .unknown  // Anomaly: cycle starts in the future\n    }\n\n    // Calculate time progress (how much of the cycle has elapsed)\n    let elapsed = now.timeIntervalSince(cycleStart)\n    let timeProgress = elapsed / totalDuration  // 0..1\n\n    // Calculate usage progress (how much quota has been consumed)\n    let usageProgress = 1.0 - remainingPercent  // 0..1\n\n    // Compare: if usage is slower than time → healthy\n    //          if usage is faster than time → warning\n    return usageProgress < timeProgress ? .healthy : .warning\n  }\n}\n\nenum UsageProviderOrigin: String, Equatable {\n  case builtin\n  case thirdParty\n}\n\nstruct UsageProviderSnapshot: Identifiable, Equatable {\n  enum Availability { case ready, empty, comingSoon }\n  enum Action: Hashable {\n    case refresh\n    case authorizeKeychain\n  }\n\n  let id = UUID()\n  let provider: UsageProviderKind\n  let title: String\n  /// Optional short badge shown as a superscript next to the provider title (e.g., \"Pro\", \"Plus\").\n  let titleBadge: String?\n  let availability: Availability\n  let metrics: [UsageMetricSnapshot]\n  let updatedAt: Date?\n  let statusMessage: String?\n  let requiresReauth: Bool  // True when user needs to re-authenticate\n  let origin: UsageProviderOrigin\n  let action: Action?\n\n  init(\n    provider: UsageProviderKind,\n    title: String,\n    titleBadge: String? = nil,\n    availability: Availability,\n    metrics: [UsageMetricSnapshot],\n    updatedAt: Date?,\n    statusMessage: String? = nil,\n    requiresReauth: Bool = false,\n    origin: UsageProviderOrigin = .builtin,\n    action: Action? = nil\n  ) {\n    self.provider = provider\n    self.title = title\n    self.titleBadge = titleBadge\n    self.availability = availability\n    self.metrics = metrics\n    self.updatedAt = updatedAt\n    self.statusMessage = statusMessage\n    self.requiresReauth = requiresReauth\n    self.origin = origin\n    self.action = action\n  }\n\n  func urgentMetric(relativeTo now: Date = Date()) -> UsageMetricSnapshot? {\n    let candidates = metrics.filter { $0.kind != .snapshot && $0.kind != .context }\n    guard !candidates.isEmpty else { return nil }\n\n    // Step 1: If any limit is depleted (≤0.1%), prioritize the one with longest reset time\n    // This ensures we show the most restrictive bottleneck\n    let depleted = candidates.filter { ($0.progress ?? 1) <= 0.001 }\n    if !depleted.isEmpty {\n      return depleted.max(by: { a, b in\n        let aReset = a.resetDate?.timeIntervalSince(now) ?? 0\n        let bReset = b.resetDate?.timeIntervalSince(now) ?? 0\n        return aReset < bReset\n      })\n    }\n\n    // Step 2: Filter out metrics that reset very soon (<5 minutes)\n    // They're not representative of the stable state\n    let significant = candidates.filter { metric in\n      guard let reset = metric.resetDate else { return true }\n      let remaining = reset.timeIntervalSince(now)\n      return remaining > 5 * 60 || remaining <= 0\n    }\n\n    // Step 3: Calculate urgency score and return the most urgent\n    // Urgency = (consumption %) × log(1 + reset hours)\n    // Higher score = more urgent = should be displayed\n    return significant.max(by: { a, b in\n      urgencyScore(for: a, relativeTo: now) < urgencyScore(for: b, relativeTo: now)\n    })\n  }\n\n  private func urgencyScore(for metric: UsageMetricSnapshot, relativeTo now: Date) -> Double {\n    // Calculate consumption (0..1, where 1 = fully consumed)\n    let consumed = 1.0 - (metric.progress ?? 0)\n\n    // Calculate reset time in minutes\n    let resetMinutes: Double\n    if let reset = metric.resetDate {\n      resetMinutes = max(0, reset.timeIntervalSince(now) / 60)\n    } else if let fallback = metric.fallbackWindowMinutes {\n      resetMinutes = Double(fallback)\n    } else {\n      resetMinutes = 0\n    }\n\n    // Urgency score = consumption × log(1 + reset hours)\n    // The log ensures diminishing importance for longer times\n    // e.g., 10min→1h matters more than 1day→2days\n    let resetHours = resetMinutes / 60.0\n    return consumed * log(1.0 + resetHours)\n  }\n\n  static func placeholder(\n    _ provider: UsageProviderKind,\n    message: String,\n    action: Action? = .refresh\n  ) -> UsageProviderSnapshot {\n    UsageProviderSnapshot(\n      provider: provider,\n      title: provider.displayName,\n      titleBadge: nil,\n      availability: .comingSoon,\n      metrics: [],\n      updatedAt: nil,\n      statusMessage: message,\n      origin: .builtin,\n      action: action\n    )\n  }\n}\n"
  },
  {
    "path": "models/WarpTitleBuilder.swift",
    "content": "import Foundation\n\nenum WarpTitleBuilder {\n    private static let dashCharacterSet = CharacterSet(charactersIn: \"-\")\n    private static let timestampFormatter: DateFormatter = {\n        let fmt = DateFormatter()\n        fmt.dateFormat = \"yyyyMMddHHmm\"\n        fmt.locale = Locale(identifier: \"en_US_POSIX\")\n        fmt.timeZone = TimeZone.current\n        return fmt\n    }()\n\n    static func token(from raw: String?) -> String? {\n        guard let raw,\n              !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        else { return nil }\n        var resultScalars: [Character] = []\n        var lastWasDash = false\n        for scalar in raw.lowercased().unicodeScalars {\n            if CharacterSet.alphanumerics.contains(scalar) {\n                resultScalars.append(Character(scalar))\n                lastWasDash = false\n            } else if scalar == \"-\" || scalar == \"_\" {\n                resultScalars.append(\"-\")\n                lastWasDash = true\n            } else if !lastWasDash {\n                resultScalars.append(\"-\")\n                lastWasDash = true\n            }\n        }\n        let result = String(resultScalars).trimmingCharacters(in: dashCharacterSet)\n        return result.isEmpty ? nil : result\n    }\n\n    static func timestampString(_ date: Date = Date()) -> String {\n        timestampFormatter.string(from: date)\n    }\n\n    static func newSessionLabel(\n        scope: String?,\n        task: String?,\n        extras: [String] = [],\n        date: Date = Date()\n    ) -> String {\n        var tokens: [String] = [timestampString(date)]\n        if let scopeToken = token(from: scope) { tokens.append(scopeToken) }\n        if let taskToken = token(from: task) { tokens.append(taskToken) }\n        for raw in extras {\n            if let tokenized = token(from: raw) {\n                tokens.append(tokenized)\n            }\n        }\n        return tokens.joined(separator: \"-\")\n    }\n}\n"
  },
  {
    "path": "models/WizardConversationViewModel.swift",
    "content": "import Foundation\n\n@MainActor\nfinal class WizardConversationViewModel<Draft: Codable>: ObservableObject {\n  @Published var messages: [WizardMessage] = []\n  @Published var inputText: String = \"\"\n  @Published var isRunning: Bool = false\n  @Published var runEvents: [WizardRunEvent] = []\n  @Published var draft: Draft? = nil\n  @Published var draftTimestamp: Date? = nil\n  @Published var questions: [String] = []\n  @Published var warnings: [String] = []\n  @Published var errorMessage: String? = nil\n  @Published var selectedProvider: SessionSource.Kind\n\n  let feature: WizardFeature\n  private let preferences: SessionPreferencesStore\n  private let runner = InternalSkillRunner()\n  private let summaryBuilder: (Draft) -> [String]\n\n  var availableProviders: [SessionSource.Kind] {\n    SessionSource.Kind.allCases.filter { preferences.isCLIEnabled($0) }\n  }\n\n  init(\n    feature: WizardFeature,\n    preferences: SessionPreferencesStore,\n    summaryBuilder: @escaping (Draft) -> [String]\n  ) {\n    self.feature = feature\n    self.preferences = preferences\n    self.summaryBuilder = summaryBuilder\n    let fallback = WizardConversationViewModel.defaultProvider(preferences: preferences)\n    let saved = SessionSource.Kind(rawValue: preferences.wizardPreferredProvider)\n    self.selectedProvider = saved ?? fallback\n  }\n\n  func sendMessage() {\n    let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return }\n    messages.append(WizardMessage(role: .user, text: trimmed))\n    inputText = \"\"\n    Task { await runSkill() }\n  }\n\n  func runSkill() async {\n    errorMessage = nil\n    questions = []\n    draft = nil\n    draftTimestamp = nil\n    warnings = []\n    runEvents = []\n    isRunning = true\n\n    appendEvent(\"Preparing skill input\")\n    appendEvent(\"Launching \\(selectedProvider.displayName) CLI\")\n\n    do {\n      let executable = preferences.preferredExecutablePath(for: selectedProvider)\n      let result = try await runner.run(\n        feature: feature,\n        provider: selectedProvider,\n        conversation: messages,\n        defaultExecutable: executable,\n        progress: { [weak self] event in\n          self?.appendEvent(event)\n        }\n      )\n      appendEvent(\"Parsing result\")\n      isRunning = false\n\n      let raw = result.outputText.trimmingCharacters(in: .whitespacesAndNewlines)\n      if let envelope: WizardDraftEnvelope<Draft> = WizardResponseParser.decodeEnvelope(raw) {\n        handleEnvelope(envelope)\n      } else if let draft: Draft = WizardResponseParser.decode(raw) {\n        self.draft = draft\n        self.draftTimestamp = Date()\n      } else {\n        errorMessage = \"Failed to parse skill output.\"\n      }\n      preferences.wizardPreferredProvider = selectedProvider.rawValue\n    } catch {\n      isRunning = false\n      errorMessage = error.localizedDescription\n    }\n  }\n\n  func draftSummaryLines() -> [String] {\n    guard let draft else { return [] }\n    return summaryBuilder(draft)\n  }\n\n  private func handleEnvelope(_ envelope: WizardDraftEnvelope<Draft>) {\n    warnings = envelope.warnings ?? []\n    if envelope.mode == .question {\n      let qs = envelope.questions ?? []\n      questions = qs\n      if !qs.isEmpty {\n        messages.append(WizardMessage(role: .assistant, text: qs.joined(separator: \"\\n\")))\n      }\n      return\n    }\n    if let draft = envelope.draft {\n      self.draft = draft\n      self.draftTimestamp = Date()\n    }\n  }\n\n  private func appendEvent(_ message: String) {\n    runEvents.append(WizardRunEvent(message: message, kind: .status))\n  }\n\n  private func appendEvent(_ event: WizardRunEvent) {\n    runEvents.append(event)\n  }\n\n  private static func defaultProvider(preferences: SessionPreferencesStore) -> SessionSource.Kind {\n    let candidates: [SessionSource.Kind] = [.codex, .claude, .gemini]\n    if let found = candidates.first(where: { preferences.isCLIEnabled($0) }) {\n      return found\n    }\n    return .codex\n  }\n}\n"
  },
  {
    "path": "models/WizardGuard.swift",
    "content": "import Foundation\n\n@MainActor\nfinal class WizardGuard: ObservableObject {\n  @Published var isActive: Bool = false\n}\n"
  },
  {
    "path": "models/WizardModels.swift",
    "content": "import Foundation\n\nenum WizardFeature: String, Codable, CaseIterable, Sendable {\n  case hooks\n  case commands\n  case mcp\n  case skills\n\n  var displayName: String {\n    switch self {\n    case .hooks: return \"Hooks\"\n    case .commands: return \"Commands\"\n    case .mcp: return \"MCP Servers\"\n    case .skills: return \"Skills\"\n    }\n  }\n}\n\nenum WizardRole: String, Codable, Sendable {\n  case system\n  case user\n  case assistant\n}\n\nstruct WizardMessage: Identifiable, Codable, Hashable, Sendable {\n  var id: UUID\n  var role: WizardRole\n  var text: String\n  var createdAt: Date\n  var draftJSON: String?\n\n  init(\n    id: UUID = UUID(),\n    role: WizardRole,\n    text: String,\n    createdAt: Date = Date(),\n    draftJSON: String? = nil\n  ) {\n    self.id = id\n    self.role = role\n    self.text = text\n    self.createdAt = createdAt\n    self.draftJSON = draftJSON\n  }\n}\n\nenum WizardOutputMode: String, Codable, Sendable {\n  case question\n  case draft\n}\n\nstruct WizardDraftEnvelope<Draft: Codable & Sendable>: Codable, Sendable {\n  var mode: WizardOutputMode\n  var questions: [String]?\n  var draft: Draft?\n  var warnings: [String]?\n  var notes: [String]?\n}\n\nstruct HookWizardDraft: Codable, Hashable, Sendable {\n  var name: String?\n  var description: String?\n  var event: String\n  var matcher: String?\n  var targets: HookTargets?\n  var commands: [HookCommand]\n  var warnings: [String]?\n  var notes: [String]?\n}\n\nstruct CommandWizardDraft: Codable, Hashable, Sendable {\n  var name: String\n  var description: String\n  var prompt: String\n  var argumentHint: String?\n  var model: String?\n  var allowedTools: [String]?\n  var tags: [String]\n  var targets: CommandTargets?\n  var warnings: [String]?\n  var notes: [String]?\n}\n\nstruct MCPWizardDraft: Codable, Hashable, Sendable {\n  var name: String\n  var kind: MCPServerKind\n  var command: String?\n  var args: [String]?\n  var env: [String: String]?\n  var url: String?\n  var headers: [String: String]?\n  var description: String?\n  var targets: MCPServerTargets?\n  var warnings: [String]?\n  var notes: [String]?\n\n  private enum CodingKeys: String, CodingKey {\n    case name\n    case kind\n    case command\n    case args\n    case env\n    case url\n    case headers\n    case description\n    case targets\n    case warnings\n    case notes\n  }\n\n  private struct KeyValuePair: Codable, Hashable {\n    var key: String\n    var value: String\n  }\n\n  init(\n    name: String,\n    kind: MCPServerKind,\n    command: String? = nil,\n    args: [String]? = nil,\n    env: [String: String]? = nil,\n    url: String? = nil,\n    headers: [String: String]? = nil,\n    description: String? = nil,\n    targets: MCPServerTargets? = nil,\n    warnings: [String]? = nil,\n    notes: [String]? = nil\n  ) {\n    self.name = name\n    self.kind = kind\n    self.command = command\n    self.args = args\n    self.env = env\n    self.url = url\n    self.headers = headers\n    self.description = description\n    self.targets = targets\n    self.warnings = warnings\n    self.notes = notes\n  }\n\n  init(from decoder: Decoder) throws {\n    let container = try decoder.container(keyedBy: CodingKeys.self)\n    name = try container.decode(String.self, forKey: .name)\n    kind = try container.decode(MCPServerKind.self, forKey: .kind)\n    command = try container.decodeIfPresent(String.self, forKey: .command)\n    args = try container.decodeIfPresent([String].self, forKey: .args)\n    url = try container.decodeIfPresent(String.self, forKey: .url)\n    description = try container.decodeIfPresent(String.self, forKey: .description)\n    targets = try container.decodeIfPresent(MCPServerTargets.self, forKey: .targets)\n    warnings = try container.decodeIfPresent([String].self, forKey: .warnings)\n    notes = try container.decodeIfPresent([String].self, forKey: .notes)\n\n    if let dict = try? container.decodeIfPresent([String: String].self, forKey: .env) {\n      env = dict\n    } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .env) {\n      env = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) })\n    } else {\n      env = nil\n    }\n\n    if let dict = try? container.decodeIfPresent([String: String].self, forKey: .headers) {\n      headers = dict\n    } else if let pairs = try? container.decodeIfPresent([KeyValuePair].self, forKey: .headers) {\n      headers = Dictionary(uniqueKeysWithValues: pairs.map { ($0.key, $0.value) })\n    } else {\n      headers = nil\n    }\n  }\n\n  func encode(to encoder: Encoder) throws {\n    var container = encoder.container(keyedBy: CodingKeys.self)\n    try container.encode(name, forKey: .name)\n    try container.encode(kind, forKey: .kind)\n    try container.encodeIfPresent(command, forKey: .command)\n    try container.encodeIfPresent(args, forKey: .args)\n    try container.encodeIfPresent(env, forKey: .env)\n    try container.encodeIfPresent(url, forKey: .url)\n    try container.encodeIfPresent(headers, forKey: .headers)\n    try container.encodeIfPresent(description, forKey: .description)\n    try container.encodeIfPresent(targets, forKey: .targets)\n    try container.encodeIfPresent(warnings, forKey: .warnings)\n    try container.encodeIfPresent(notes, forKey: .notes)\n  }\n}\n\nstruct SkillWizardExample: Codable, Hashable, Sendable {\n  var title: String\n  var user: String\n  var assistant: String\n}\n\nstruct SkillWizardDraft: Codable, Hashable, Sendable {\n  var id: String\n  var name: String\n  var description: String\n  var summary: String?\n  var tags: [String]\n  var overview: String\n  var instructions: [String]\n  var examples: [SkillWizardExample]\n  var notes: [String]\n  var targets: MCPServerTargets?\n  var warnings: [String]?\n}\n\nstruct WizardRunEvent: Identifiable, Hashable, Sendable {\n  enum Kind: String, Codable, Sendable {\n    case status\n    case stdout\n    case stderr\n  }\n\n  var id: UUID = UUID()\n  var message: String\n  var kind: Kind = .status\n  var timestamp: Date = Date()\n}\n"
  },
  {
    "path": "notify/NotifyMain.swift",
    "content": "import Foundation\n\n@main\nstruct CodMateNotifyCLI {\n    static func main() {\n        do {\n            try run()\n        } catch {\n            fputs(\"codmate-notify: \\(error.localizedDescription)\\n\", stderr)\n            exit(1)\n        }\n    }\n\n    private static func run() throws {\n        let args = CommandLine.arguments.dropFirst()\n        guard !args.isEmpty else { return }\n\n        var payloadArg: String?\n        var selfTest = false\n\n        for arg in args {\n            if arg == \"--self-test\" {\n                selfTest = true\n                continue\n            }\n            if payloadArg == nil {\n                payloadArg = arg\n            }\n        }\n\n        let request: NotificationRequest\n        if let payloadArg, let parsed = NotificationRequest(jsonString: payloadArg) {\n            request = parsed\n        } else if selfTest {\n            request = NotificationRequest(\n                event: .test,\n                title: \"CodMate\",\n                body: \"Codex notifications self-test\",\n                threadId: \"codex-test\"\n            )\n        } else {\n            return\n        }\n\n        guard request.event != .ignored else { return }\n        try dispatch(request: request)\n\n        if selfTest {\n            print(\"__CODMATE_NOTIFIED__\")\n        }\n    }\n\n    private static func dispatch(request: NotificationRequest) throws {\n        guard let url = request.makeURL() else {\n            throw NotifyError.urlEncodingFailed\n        }\n        // 使用 -j (隐藏启动) 而不是 -g (后台启动) 来防止 SwiftUI WindowGroup 自动创建新窗口\n        // -j 参数确保应用在后台处理 URL 而不激活或显示窗口\n        if try !runOpen(arguments: [\"-b\", bundleIdentifier, \"-j\", url.absoluteString]) {\n            if try !runOpen(arguments: [\"-j\", url.absoluteString]) {\n                throw NotifyError.openFailed(code: 1)\n            }\n        }\n    }\n\n    private static let bundleIdentifier = \"ai.umate.codmate\"\n\n    @discardableResult\n    private static func runOpen(arguments: [String]) throws -> Bool {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n        process.arguments = arguments\n        try process.run()\n        process.waitUntilExit()\n        return process.terminationStatus == 0\n    }\n}\n\nprivate enum NotifyError: LocalizedError {\n    case urlEncodingFailed\n    case openFailed(code: Int32)\n\n    var errorDescription: String? {\n        switch self {\n        case .urlEncodingFailed:\n            return \"Failed to encode notification URL.\"\n        case .openFailed(let code):\n            return \"Unable to dispatch codmate:// URL (open exited with \\(code)).\"\n        }\n    }\n}\n\nprivate struct NotificationRequest {\n    enum Event: String {\n        case turnComplete = \"turncomplete\"\n        case test\n        case ignored\n    }\n\n    let event: Event\n    let title: String\n    let body: String\n    let threadId: String?\n\n    init?(jsonString: String) {\n        guard let data = jsonString.data(using: .utf8) else { return nil }\n        guard let payload = (try? JSONSerialization.jsonObject(with: data, options: [])) as? [String: Any] else {\n            let snippet = NotificationRequest.snippet(from: jsonString)\n            self.init(event: .turnComplete, title: \"Codex\", body: snippet, threadId: \"codex-generic\")\n            return\n        }\n\n        let normalizedEvent = NotificationRequest.normalizedEvent(in: payload)\n        guard NotificationRequest.allowedEvents.contains(normalizedEvent) else {\n            self.init(event: .ignored, title: \"\", body: \"\", threadId: nil)\n            return\n        }\n\n        let message = NotificationRequest.message(from: payload)\n        let thread = NotificationRequest.threadId(from: payload)\n        self.init(event: .turnComplete, title: \"Codex\", body: message, threadId: thread)\n    }\n\n    init(event: Event, title: String, body: String, threadId: String?) {\n        self.event = event\n        self.title = title\n        self.body = body\n        self.threadId = threadId\n    }\n\n    func makeURL() -> URL? {\n        var components = URLComponents()\n        components.scheme = \"codmate\"\n        components.host = \"notify\"\n        var query: [URLQueryItem] = [\n            URLQueryItem(name: \"source\", value: \"codex\"),\n            URLQueryItem(name: \"event\", value: event.rawValue)\n        ]\n        if let titleData = title.data(using: .utf8)?.base64EncodedString() {\n            query.append(URLQueryItem(name: \"title64\", value: titleData))\n        }\n        if let bodyData = body.data(using: .utf8)?.base64EncodedString() {\n            query.append(URLQueryItem(name: \"body64\", value: bodyData))\n        }\n        if let threadId, !threadId.isEmpty {\n            query.append(URLQueryItem(name: \"thread\", value: threadId))\n        }\n        components.queryItems = query\n        return components.url\n    }\n\n    private static func normalizedEvent(in payload: [String: Any]) -> String {\n        let rawEvent =\n            (payload[\"type\"] as? String)\n            ?? (payload[\"event\"] as? String)\n            ?? \"\"\n        let allowedCharacters = CharacterSet.alphanumerics\n        let filtered = rawEvent.unicodeScalars.filter { allowedCharacters.contains($0) }\n        return String(filtered).lowercased()\n    }\n\n    private static func message(from payload: [String: Any]) -> String {\n        let candidates = [\n            \"last-assistant-message\",\n            \"assistant\",\n            \"message\"\n        ]\n        for key in candidates {\n            if let value = payload[key] as? String, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                return NotificationRequest.coalesce(text: value)\n            }\n        }\n        return \"Codex turn complete\"\n    }\n\n    private static func threadId(from payload: [String: Any]) -> String {\n        if let thread = payload[\"thread-id\"] as? String, !thread.isEmpty {\n            return \"codex-\\(thread)\"\n        }\n        if let session = payload[\"session-id\"] as? String, !session.isEmpty {\n            return \"codex-\\(session)\"\n        }\n        return \"codex-thread\"\n    }\n\n    private static func snippet(from raw: String) -> String {\n        return coalesce(text: raw)\n    }\n\n    private static func coalesce(text: String) -> String {\n        let collapsed = text.replacingOccurrences(of: \"\\\\s+\", with: \" \", options: .regularExpression)\n        let trimmed = collapsed.trimmingCharacters(in: .whitespacesAndNewlines)\n        if trimmed.count <= 240 { return trimmed }\n        let endIndex = trimmed.index(trimmed.startIndex, offsetBy: 240)\n        return String(trimmed[..<endIndex])\n    }\n\n    private static let allowedEvents: Set<String> = [\n        \"agentturncomplete\",\n        \"turncomplete\",\n        \"agentcompleted\",\n        \"agentdone\",\n        \"runcomplete\",\n        \"rundone\",\n        \"sessioncomplete\",\n        \"completed\"\n    ]\n}\nprivate let bundleIdentifier = \"ai.umate.codmate\"\n"
  },
  {
    "path": "payload/commands/index.json",
    "content": "[]\n"
  },
  {
    "path": "payload/hook-events.json",
    "content": "{\n  \"events\": [\n    {\n      \"name\": \"Setup\",\n      \"description\": \"Load context and configure the environment during repository initialization or maintenance.\",\n      \"providers\": [\"claude\"],\n      \"supportsMatcher\": false\n    },\n    {\n      \"name\": \"SessionStart\",\n      \"description\": \"Runs when a session starts.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"startup\", \"description\": \"Session starts fresh.\", \"providers\": [\"gemini\"] },\n        { \"value\": \"resume\", \"description\": \"Session resumes from history.\", \"providers\": [\"gemini\"] },\n        { \"value\": \"clear\", \"description\": \"Session is cleared and restarted.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"UserPromptSubmit\",\n      \"description\": \"Runs when the user submits a prompt.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"aliases\": { \"gemini\": \"BeforeAgent\" },\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"PreToolUse\",\n      \"description\": \"Runs before a tool is called.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"aliases\": { \"gemini\": \"BeforeTool\" },\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"Bash\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Edit\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Read\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write|Edit\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Notebook.*\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] },\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] },\n        { \"value\": \"write_.*\", \"description\": \"Regex example.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"PermissionRequest\",\n      \"description\": \"Runs when a tool permission is requested.\",\n      \"providers\": [\"claude\"],\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"Bash\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Edit\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Read\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write|Edit\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Notebook.*\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] }\n      ]\n    },\n    {\n      \"name\": \"PostToolUse\",\n      \"description\": \"Runs after a tool call succeeds.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"aliases\": { \"gemini\": \"AfterTool\" },\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"Bash\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Edit\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Read\", \"description\": \"Tool name.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Write|Edit\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] },\n        { \"value\": \"Notebook.*\", \"description\": \"Regex example.\", \"providers\": [\"claude\"] },\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] },\n        { \"value\": \"write_.*\", \"description\": \"Regex example.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"PostToolUseFailure\",\n      \"description\": \"Runs after a tool call fails.\",\n      \"providers\": [\"claude\"],\n      \"supportsMatcher\": false\n    },\n    {\n      \"name\": \"SubagentStart\",\n      \"description\": \"Runs when a subagent (Task tool call) starts.\",\n      \"providers\": [\"claude\"],\n      \"supportsMatcher\": false\n    },\n    {\n      \"name\": \"SubagentStop\",\n      \"description\": \"Runs when a subagent (Task tool call) finishes.\",\n      \"providers\": [\"claude\"],\n      \"supportsMatcher\": false,\n      \"note\": \"Prompt-based hooks are supported for this event.\"\n    },\n    {\n      \"name\": \"Stop\",\n      \"description\": \"Runs when the assistant finishes responding.\",\n      \"providers\": [\"claude\", \"gemini\", \"codex\"],\n      \"aliases\": { \"gemini\": \"AfterAgent\" },\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] }\n      ],\n      \"note\": \"Prompt-based hooks are supported for this event.\"\n    },\n    {\n      \"name\": \"PreCompact\",\n      \"description\": \"Runs before context compaction.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"aliases\": { \"gemini\": \"PreCompress\" },\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"SessionEnd\",\n      \"description\": \"Runs when a session ends.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"exit\", \"description\": \"Session exits.\", \"providers\": [\"gemini\"] },\n        { \"value\": \"clear\", \"description\": \"Session is cleared.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"Notification\",\n      \"description\": \"Runs when the CLI raises a notification.\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"supportsMatcher\": true,\n      \"matchers\": [\n        { \"value\": \"*\", \"description\": \"Wildcard matcher.\", \"providers\": [\"gemini\"] }\n      ]\n    },\n    {\n      \"name\": \"BeforeModel\",\n      \"description\": \"Runs before a request is sent to the model.\",\n      \"providers\": [\"gemini\"],\n      \"supportsMatcher\": true\n    },\n    {\n      \"name\": \"AfterModel\",\n      \"description\": \"Runs after the model responds, before tool selection.\",\n      \"providers\": [\"gemini\"],\n      \"supportsMatcher\": true\n    },\n    {\n      \"name\": \"BeforeToolSelection\",\n      \"description\": \"Runs before tool selection.\",\n      \"providers\": [\"gemini\"],\n      \"supportsMatcher\": true\n    }\n  ]\n}\n"
  },
  {
    "path": "payload/hook-variables.json",
    "content": "{\n  \"variables\": [\n    {\n      \"name\": \"CLAUDE_PROJECT_DIR\",\n      \"kind\": \"env\",\n      \"description\": \"Project root directory\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Gemini alias\"\n    },\n    {\n      \"name\": \"CLAUDE_ENV_FILE\",\n      \"kind\": \"env\",\n      \"description\": \"Path to environment file\",\n      \"providers\": [\"claude\"],\n      \"note\": \"SessionStart/Setup\"\n    },\n    {\n      \"name\": \"GEMINI_PROJECT_DIR\",\n      \"kind\": \"env\",\n      \"description\": \"Project root directory\",\n      \"providers\": [\"gemini\"]\n    },\n    {\n      \"name\": \"GEMINI_SESSION_ID\",\n      \"kind\": \"env\",\n      \"description\": \"Session identifier\",\n      \"providers\": [\"gemini\"]\n    },\n    {\n      \"name\": \"GEMINI_CWD\",\n      \"kind\": \"env\",\n      \"description\": \"Current working directory\",\n      \"providers\": [\"gemini\"]\n    },\n    {\n      \"name\": \"session_id\",\n      \"kind\": \"stdin\",\n      \"description\": \"Session identifier\",\n      \"providers\": [\"claude\", \"gemini\"]\n    },\n    {\n      \"name\": \"transcript_path\",\n      \"kind\": \"stdin\",\n      \"description\": \"Transcript JSON path\",\n      \"providers\": [\"claude\", \"gemini\"]\n    },\n    {\n      \"name\": \"cwd\",\n      \"kind\": \"stdin\",\n      \"description\": \"Current working directory\",\n      \"providers\": [\"claude\", \"gemini\"]\n    },\n    {\n      \"name\": \"permission_mode\",\n      \"kind\": \"stdin\",\n      \"description\": \"Permission mode\",\n      \"providers\": [\"claude\"]\n    },\n    {\n      \"name\": \"hook_event_name\",\n      \"kind\": \"stdin\",\n      \"description\": \"Hook event name\",\n      \"providers\": [\"claude\", \"gemini\"]\n    },\n    {\n      \"name\": \"timestamp\",\n      \"kind\": \"stdin\",\n      \"description\": \"Event timestamp\",\n      \"providers\": [\"gemini\"]\n    },\n    {\n      \"name\": \"tool_name\",\n      \"kind\": \"stdin\",\n      \"description\": \"Tool name\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure · Gemini: BeforeTool/AfterTool\"\n    },\n    {\n      \"name\": \"tool_input\",\n      \"kind\": \"stdin\",\n      \"description\": \"Tool input JSON\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure · Gemini: BeforeTool/AfterTool\"\n    },\n    {\n      \"name\": \"tool_use_id\",\n      \"kind\": \"stdin\",\n      \"description\": \"Tool use identifier\",\n      \"providers\": [\"claude\"],\n      \"note\": \"PreToolUse/PermissionRequest/PostToolUse/PostToolUseFailure\"\n    },\n    {\n      \"name\": \"tool_response\",\n      \"kind\": \"stdin\",\n      \"description\": \"Tool response JSON\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: PostToolUse/PostToolUseFailure · Gemini: AfterTool\"\n    },\n    {\n      \"name\": \"mcp_context\",\n      \"kind\": \"stdin\",\n      \"description\": \"MCP context JSON\",\n      \"providers\": [\"gemini\"],\n      \"note\": \"BeforeTool/AfterTool\"\n    },\n    {\n      \"name\": \"prompt\",\n      \"kind\": \"stdin\",\n      \"description\": \"User prompt\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: UserPromptSubmit · Gemini: BeforeAgent/AfterAgent\"\n    },\n    {\n      \"name\": \"prompt_response\",\n      \"kind\": \"stdin\",\n      \"description\": \"Agent response\",\n      \"providers\": [\"gemini\"],\n      \"note\": \"AfterAgent\"\n    },\n    {\n      \"name\": \"stop_hook_active\",\n      \"kind\": \"stdin\",\n      \"description\": \"Stop hook state\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: Stop/SubagentStop · Gemini: AfterAgent\"\n    },\n    {\n      \"name\": \"agent_id\",\n      \"kind\": \"stdin\",\n      \"description\": \"Subagent identifier\",\n      \"providers\": [\"claude\"],\n      \"note\": \"SubagentStart/SubagentStop\"\n    },\n    {\n      \"name\": \"agent_transcript_path\",\n      \"kind\": \"stdin\",\n      \"description\": \"Subagent transcript JSON path\",\n      \"providers\": [\"claude\"],\n      \"note\": \"SubagentStop\"\n    },\n    {\n      \"name\": \"llm_request\",\n      \"kind\": \"stdin\",\n      \"description\": \"Model request JSON\",\n      \"providers\": [\"gemini\"],\n      \"note\": \"BeforeModel/BeforeToolSelection/AfterModel\"\n    },\n    {\n      \"name\": \"llm_response\",\n      \"kind\": \"stdin\",\n      \"description\": \"Model response JSON\",\n      \"providers\": [\"gemini\"],\n      \"note\": \"AfterModel\"\n    },\n    {\n      \"name\": \"message\",\n      \"kind\": \"stdin\",\n      \"description\": \"Notification message\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Notification\"\n    },\n    {\n      \"name\": \"notification_type\",\n      \"kind\": \"stdin\",\n      \"description\": \"Notification type\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Notification\"\n    },\n    {\n      \"name\": \"details\",\n      \"kind\": \"stdin\",\n      \"description\": \"Notification details JSON\",\n      \"providers\": [\"gemini\"],\n      \"note\": \"Notification\"\n    },\n    {\n      \"name\": \"trigger\",\n      \"kind\": \"stdin\",\n      \"description\": \"Compaction trigger\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"Claude: PreCompact/Setup · Gemini: PreCompress\"\n    },\n    {\n      \"name\": \"custom_instructions\",\n      \"kind\": \"stdin\",\n      \"description\": \"Custom instructions\",\n      \"providers\": [\"claude\"],\n      \"note\": \"PreCompact\"\n    },\n    {\n      \"name\": \"source\",\n      \"kind\": \"stdin\",\n      \"description\": \"Session start source\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"SessionStart\"\n    },\n    {\n      \"name\": \"model\",\n      \"kind\": \"stdin\",\n      \"description\": \"Model name\",\n      \"providers\": [\"claude\"],\n      \"note\": \"SessionStart\"\n    },\n    {\n      \"name\": \"agent_type\",\n      \"kind\": \"stdin\",\n      \"description\": \"Agent type\",\n      \"providers\": [\"claude\"],\n      \"note\": \"SessionStart/SubagentStart\"\n    },\n    {\n      \"name\": \"reason\",\n      \"kind\": \"stdin\",\n      \"description\": \"Session end reason\",\n      \"providers\": [\"claude\", \"gemini\"],\n      \"note\": \"SessionEnd\"\n    }\n  ]\n}\n"
  },
  {
    "path": "payload/internal-skills/commands-wizard/SKILL.md",
    "content": "---\nname: commands-wizard\ndescription: Generate CodMate slash command drafts from requirements.\nmetadata:\n  short-description: Generate command drafts in JSON.\n---\n\n# Commands Wizard\n\n## Overview\n\nGenerate a CodMate slash command draft based on user intent. Output only JSON that matches the schema.\n\n## Instructions\n\n1. Read the user's request and conversation context.\n2. Produce a concise command name, description, and prompt.\n3. If unclear, return mode \"question\" with follow-up questions.\n\n## Output\n\nReturn only JSON.\n"
  },
  {
    "path": "payload/internal-skills/commands-wizard/prompt.md",
    "content": "You are a CodMate internal wizard.\n\nUse the application language specified by the JSON payload fields:\n- appLanguage (BCP-47 code)\n- appLanguageName (English name of the language)\nAll user-facing text must use that language.\n\nFollow the user's request and conversation context.\nIf the request is unclear, set mode=\"question\" and ask concise follow-up questions.\nReturn only JSON that matches schema.json. Do not include markdown or extra text.\n"
  },
  {
    "path": "payload/internal-skills/commands-wizard/schema.json",
    "content": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"mode\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"question\",\n        \"draft\"\n      ]\n    },\n    \"questions\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"draft\": {\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"type\": \"string\"\n        },\n        \"prompt\": {\n          \"type\": \"string\"\n        },\n        \"argumentHint\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"model\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"allowedTools\": {\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"tags\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"targets\": {\n          \"type\": [\n            \"object\",\n            \"null\"\n          ],\n          \"properties\": {\n            \"codex\": {\n              \"type\": \"boolean\"\n            },\n            \"claude\": {\n              \"type\": \"boolean\"\n            },\n            \"gemini\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"additionalProperties\": false,\n          \"required\": [\n            \"codex\",\n            \"claude\",\n            \"gemini\"\n          ]\n        }\n      },\n      \"required\": [\n        \"name\",\n        \"description\",\n        \"prompt\",\n        \"tags\",\n        \"argumentHint\",\n        \"model\",\n        \"allowedTools\",\n        \"targets\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"warnings\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"notes\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"required\": [\n    \"mode\",\n    \"questions\",\n    \"draft\",\n    \"warnings\",\n    \"notes\"\n  ],\n  \"additionalProperties\": false\n}\n"
  },
  {
    "path": "payload/internal-skills/hooks-wizard/SKILL.md",
    "content": "---\nname: hooks-wizard\ndescription: Generate CodMate hook drafts from requirements.\nmetadata:\n  short-description: Generate hook configuration drafts in JSON.\n---\n\n# Hooks Wizard\n\n## Overview\n\nGenerate a CodMate Hook draft based on the user's requirement and provided event/variable catalogs. Output only JSON that matches the schema.\n\n## Instructions\n\n1. Read the user's request and any conversation context.\n2. Choose the most appropriate event and matcher from the catalog.\n3. Produce a Hook draft with one or more commands.\n4. If the requirement is unclear, return mode \"question\" with follow-up questions.\n\n## Output\n\nReturn only JSON.\n"
  },
  {
    "path": "payload/internal-skills/hooks-wizard/prompt.md",
    "content": "You are a CodMate internal wizard.\n\nUse the application language specified by the JSON payload fields:\n- appLanguage (BCP-47 code)\n- appLanguageName (English name of the language)\nAll user-facing text must use that language.\n\nFollow the user's request and any provided catalogs.\nIf the request is unclear, set mode=\"question\" and ask concise follow-up questions.\nReturn only JSON that matches schema.json. Do not include markdown or extra text.\n"
  },
  {
    "path": "payload/internal-skills/hooks-wizard/schema.json",
    "content": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"mode\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"question\",\n        \"draft\"\n      ]\n    },\n    \"questions\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"draft\": {\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"properties\": {\n        \"name\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"description\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"event\": {\n          \"type\": \"string\"\n        },\n        \"matcher\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"targets\": {\n          \"type\": [\n            \"object\",\n            \"null\"\n          ],\n          \"properties\": {\n            \"codex\": {\n              \"type\": \"boolean\"\n            },\n            \"claude\": {\n              \"type\": \"boolean\"\n            },\n            \"gemini\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"additionalProperties\": false,\n          \"required\": [\n            \"codex\",\n            \"claude\",\n            \"gemini\"\n          ]\n        },\n        \"commands\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"command\": {\n                \"type\": \"string\"\n              },\n              \"args\": {\n                \"type\": [\n                  \"array\",\n                  \"null\"\n                ],\n                \"items\": {\n                  \"type\": \"string\"\n                }\n              },\n              \"env\": {\n                \"type\": [\n                  \"array\",\n                  \"null\"\n                ],\n                \"items\": {\n                  \"type\": \"object\",\n                  \"properties\": {\n                    \"key\": {\n                      \"type\": \"string\"\n                    },\n                    \"value\": {\n                      \"type\": \"string\"\n                    }\n                  },\n                  \"required\": [\n                    \"key\",\n                    \"value\"\n                  ],\n                  \"additionalProperties\": false\n                }\n              },\n              \"timeoutMs\": {\n                \"type\": [\n                  \"number\",\n                  \"null\"\n                ]\n              }\n            },\n            \"required\": [\n              \"command\",\n              \"args\",\n              \"env\",\n              \"timeoutMs\"\n            ],\n            \"additionalProperties\": false\n          }\n        }\n      },\n      \"required\": [\n        \"event\",\n        \"commands\",\n        \"name\",\n        \"description\",\n        \"matcher\",\n        \"targets\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"warnings\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"notes\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"required\": [\n    \"mode\",\n    \"questions\",\n    \"draft\",\n    \"warnings\",\n    \"notes\"\n  ],\n  \"additionalProperties\": false\n}\n"
  },
  {
    "path": "payload/internal-skills/index.json",
    "content": "{\n  \"skills\": [\n    {\n      \"id\": \"hooks-wizard\",\n      \"feature\": \"hooks\",\n      \"title\": \"Hooks Wizard\",\n      \"description\": \"Generate CodMate hook drafts from requirements.\",\n      \"version\": \"1.0\",\n      \"invocations\": [\n        {\n          \"provider\": \"codex\",\n          \"args\": [\n            \"exec\",\n            \"--output-schema\",\n            \"{{schemaFile}}\",\n            \"--output-last-message\",\n            \"{{outputFile}}\",\n            \"--color\",\n            \"never\",\n            \"--skip-git-repo-check\",\n            \"-\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"file\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"claude\",\n          \"args\": [\n            \"-p\",\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"gemini\",\n          \"args\": [\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        }\n      ]\n    },\n    {\n      \"id\": \"commands-wizard\",\n      \"feature\": \"commands\",\n      \"title\": \"Commands Wizard\",\n      \"description\": \"Generate slash command drafts from requirements.\",\n      \"version\": \"1.0\",\n      \"invocations\": [\n        {\n          \"provider\": \"codex\",\n          \"args\": [\n            \"exec\",\n            \"--output-schema\",\n            \"{{schemaFile}}\",\n            \"--output-last-message\",\n            \"{{outputFile}}\",\n            \"--color\",\n            \"never\",\n            \"--skip-git-repo-check\",\n            \"-\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"file\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"claude\",\n          \"args\": [\n            \"-p\",\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"gemini\",\n          \"args\": [\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        }\n      ]\n    },\n    {\n      \"id\": \"mcp-wizard\",\n      \"feature\": \"mcp\",\n      \"title\": \"MCP Wizard\",\n      \"description\": \"Generate MCP server drafts from requirements.\",\n      \"version\": \"1.0\",\n      \"invocations\": [\n        {\n          \"provider\": \"codex\",\n          \"args\": [\n            \"exec\",\n            \"--output-schema\",\n            \"{{schemaFile}}\",\n            \"--output-last-message\",\n            \"{{outputFile}}\",\n            \"--color\",\n            \"never\",\n            \"--skip-git-repo-check\",\n            \"-\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"file\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"claude\",\n          \"args\": [\n            \"-p\",\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"gemini\",\n          \"args\": [\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        }\n      ]\n    },\n    {\n      \"id\": \"skills-wizard\",\n      \"feature\": \"skills\",\n      \"title\": \"Skills Wizard\",\n      \"description\": \"Generate CodMate skill drafts from requirements.\",\n      \"version\": \"1.0\",\n      \"invocations\": [\n        {\n          \"provider\": \"codex\",\n          \"args\": [\n            \"exec\",\n            \"--output-schema\",\n            \"{{schemaFile}}\",\n            \"--output-last-message\",\n            \"{{outputFile}}\",\n            \"--color\",\n            \"never\",\n            \"--skip-git-repo-check\",\n            \"-\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"file\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"claude\",\n          \"args\": [\n            \"-p\",\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        },\n        {\n          \"provider\": \"gemini\",\n          \"args\": [\n            \"--output-format\",\n            \"text\"\n          ],\n          \"inputMode\": \"stdin\",\n          \"outputMode\": \"stdout\",\n          \"timeoutSeconds\": 45\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "payload/internal-skills/mcp-wizard/SKILL.md",
    "content": "---\nname: mcp-wizard\ndescription: Generate MCP server drafts from requirements.\nmetadata:\n  short-description: Generate MCP server drafts in JSON.\n---\n\n# MCP Wizard\n\n## Overview\n\nGenerate a CodMate MCP server draft based on user intent. Output only JSON that matches the schema.\n\n## Instructions\n\n1. Determine server kind (stdio, sse, streamable_http).\n2. Provide command/args/env for stdio, or url/headers for network kinds.\n3. If unclear, return mode \"question\" with follow-up questions.\n\n## Output\n\nReturn only JSON.\n"
  },
  {
    "path": "payload/internal-skills/mcp-wizard/prompt.md",
    "content": "You are a CodMate internal wizard.\n\nUse the application language specified by the JSON payload fields:\n- appLanguage (BCP-47 code)\n- appLanguageName (English name of the language)\nAll user-facing text must use that language.\n\nThis wizard is primarily for discovery. Prefer MCP servers listed in official registries\nor well-known catalogs (for example, the official registry or mcp.so). If you cannot\nidentify an exact server or endpoint from the request, ask for clarification rather\nthan guessing.\nDo not invoke tools, shell commands, or web browsing. Use only the provided docs.\n\nIf the request is unclear, set mode=\"question\" and ask concise follow-up questions.\nReturn only JSON that matches schema.json. Do not include markdown or extra text.\n"
  },
  {
    "path": "payload/internal-skills/mcp-wizard/schema.json",
    "content": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"mode\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"question\",\n        \"draft\"\n      ]\n    },\n    \"questions\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"draft\": {\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"kind\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"stdio\",\n            \"sse\",\n            \"streamable_http\"\n          ]\n        },\n        \"command\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"args\": {\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"key\": {\n                \"type\": \"string\"\n              },\n              \"value\": {\n                \"type\": \"string\"\n              }\n            },\n            \"required\": [\n              \"key\",\n              \"value\"\n            ],\n            \"additionalProperties\": false\n          }\n        },\n        \"url\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"headers\": {\n          \"type\": [\n            \"array\",\n            \"null\"\n          ],\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"key\": {\n                \"type\": \"string\"\n              },\n              \"value\": {\n                \"type\": \"string\"\n              }\n            },\n            \"required\": [\n              \"key\",\n              \"value\"\n            ],\n            \"additionalProperties\": false\n          }\n        },\n        \"description\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"targets\": {\n          \"type\": [\n            \"object\",\n            \"null\"\n          ],\n          \"properties\": {\n            \"codex\": {\n              \"type\": \"boolean\"\n            },\n            \"claude\": {\n              \"type\": \"boolean\"\n            },\n            \"gemini\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"additionalProperties\": false,\n          \"required\": [\n            \"codex\",\n            \"claude\",\n            \"gemini\"\n          ]\n        }\n      },\n      \"required\": [\n        \"name\",\n        \"kind\",\n        \"command\",\n        \"args\",\n        \"env\",\n        \"url\",\n        \"headers\",\n        \"description\",\n        \"targets\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"warnings\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"notes\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"required\": [\n    \"mode\",\n    \"questions\",\n    \"draft\",\n    \"warnings\",\n    \"notes\"\n  ],\n  \"additionalProperties\": false\n}\n"
  },
  {
    "path": "payload/internal-skills/skills-wizard/SKILL.md",
    "content": "---\nname: skills-wizard\ndescription: Generate CodMate skill drafts from requirements.\nmetadata:\n  short-description: Generate skill drafts in JSON.\n---\n\n# Skills Wizard\n\n## Overview\n\nGenerate a CodMate skill draft based on user intent. Output only JSON that matches the schema.\n\n## Instructions\n\n1. Propose a skill id and name.\n2. Provide description, overview, instructions, examples, and notes.\n3. If unclear, return mode \"question\" with follow-up questions.\n\n## Output\n\nReturn only JSON.\n"
  },
  {
    "path": "payload/internal-skills/skills-wizard/prompt.md",
    "content": "You are a CodMate internal wizard.\n\nUse the application language specified by the JSON payload fields:\n- appLanguage (BCP-47 code)\n- appLanguageName (English name of the language)\nAll user-facing text must use that language.\n\nFollow the user's request and conversation context.\nIf the request is unclear, set mode=\"question\" and ask concise follow-up questions.\nReturn only JSON that matches schema.json. Do not include markdown or extra text.\n"
  },
  {
    "path": "payload/internal-skills/skills-wizard/schema.json",
    "content": "{\n  \"type\": \"object\",\n  \"properties\": {\n    \"mode\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"question\",\n        \"draft\"\n      ]\n    },\n    \"questions\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    },\n    \"draft\": {\n      \"type\": [\n        \"object\",\n        \"null\"\n      ],\n      \"properties\": {\n        \"id\": {\n          \"type\": \"string\"\n        },\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"description\": {\n          \"type\": \"string\"\n        },\n        \"summary\": {\n          \"type\": [\n            \"string\",\n            \"null\"\n          ]\n        },\n        \"tags\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"overview\": {\n          \"type\": \"string\"\n        },\n        \"instructions\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"examples\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"object\",\n            \"properties\": {\n              \"title\": {\n                \"type\": \"string\"\n              },\n              \"user\": {\n                \"type\": \"string\"\n              },\n              \"assistant\": {\n                \"type\": \"string\"\n              }\n            },\n            \"required\": [\n              \"title\",\n              \"user\",\n              \"assistant\"\n            ],\n            \"additionalProperties\": false\n          }\n        },\n        \"notes\": {\n          \"type\": \"array\",\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"targets\": {\n          \"type\": [\n            \"object\",\n            \"null\"\n          ],\n          \"properties\": {\n            \"codex\": {\n              \"type\": \"boolean\"\n            },\n            \"claude\": {\n              \"type\": \"boolean\"\n            },\n            \"gemini\": {\n              \"type\": \"boolean\"\n            }\n          },\n          \"additionalProperties\": false,\n          \"required\": [\n            \"codex\",\n            \"claude\",\n            \"gemini\"\n          ]\n        }\n      },\n      \"required\": [\n        \"id\",\n        \"name\",\n        \"description\",\n        \"tags\",\n        \"overview\",\n        \"instructions\",\n        \"examples\",\n        \"notes\",\n        \"summary\",\n        \"targets\"\n      ],\n      \"additionalProperties\": false\n    },\n    \"warnings\": {\n      \"type\": [\n        \"array\",\n        \"null\"\n      ],\n      \"items\": {\n        \"type\": \"string\"\n      }\n    }\n  },\n  \"required\": [\n    \"mode\",\n    \"questions\",\n    \"draft\",\n    \"warnings\"\n  ],\n  \"additionalProperties\": false\n}\n"
  },
  {
    "path": "payload/knowledge/wizard-docs.json",
    "content": "{\n  \"sources\": [\n    {\n      \"feature\": \"skills\",\n      \"provider\": \"codex\",\n      \"url\": \"https://developers.openai.com/codex/app-server#skills\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 168\n    },\n    {\n      \"feature\": \"skills\",\n      \"provider\": \"claude\",\n      \"url\": \"https://code.claude.com/docs/en/skills\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 168\n    },\n    {\n      \"feature\": \"skills\",\n      \"provider\": \"gemini\",\n      \"url\": \"https://geminicli.com/docs/cli/skills/\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 168\n    },\n    {\n      \"feature\": \"mcp\",\n      \"url\": \"https://modelcontextprotocol.io/registry/about\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 72\n    },\n    {\n      \"feature\": \"mcp\",\n      \"url\": \"https://registry.modelcontextprotocol.io/\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 24\n    },\n    {\n      \"feature\": \"mcp\",\n      \"url\": \"https://mcp.so/\",\n      \"maxChars\": 3000,\n      \"cacheTTLHours\": 24\n    }\n  ]\n}\n"
  },
  {
    "path": "payload/prompts/commit-message.md",
    "content": "You are a helpful assistant that writes Conventional Commits in imperative mood.\nTask: produce a high-quality commit message with:\n1) A concise subject line (type: scope? subject)\n2) A brief body (2-4 lines or bullets) explaining motivation and key changes\nConstraints: subject <= 80 chars; wrap body lines <= 72 chars; no trailing period in subject.\nConsider the staged diff below (may be truncated):\n"
  },
  {
    "path": "payload/prompts/task-title-and-description.md",
    "content": "You are a helpful assistant that generates concise titles and descriptions for coding project tasks based on their constituent sessions.\n\nTask: Analyze the session summaries below and produce:\n1) A short, descriptive title (3-6 words) capturing the overall task goal\n2) A brief description (2-3 sentences) summarizing the task scope and what has been accomplished\n\nGuidelines:\n- Title should represent the common theme or goal across all sessions\n- **Synthesize patterns**: identify the overarching objective that connects multiple sessions\n- Description should summarize what work has been done across the sessions\n- **Focus on outcomes**: capture what was built, fixed, or improved rather than implementation details\n- If sessions cover diverse topics, find the unifying theme (e.g., \"Feature implementation\" or \"Bug fixes\")\n- Use present tense for ongoing work, past tense for completed work\n- Keep language professional and concise\n\nExample input (session summaries):\n- Session 1: \"Implement session title generation\" - Added LLM-based automatic title generation for sessions.\n- Session 2: \"Add session comment editing UI\" - Created UI for editing session comments with real-time preview.\n- Session 3: \"Fix title generation performance\" - Optimized title generation to handle large sessions.\n\nExample output:\n```json\n{\n  \"title\": \"Implement session metadata features\",\n  \"description\": \"Built automatic title and comment generation for sessions using LLM. Added editing UI with real-time preview and optimized performance for large sessions.\"\n}\n```\n\nOutput format: Return ONLY a JSON object with this exact structure:\n```json\n{\n  \"title\": \"Your generated title here\",\n  \"description\": \"Your generated description here\"\n}\n```\n\nDo not include any other text, explanations, or formatting outside the JSON object.\n\nSession summaries:\n\n"
  },
  {
    "path": "payload/prompts/task-title-only.md",
    "content": "You are a helpful assistant that improves task titles and generates descriptions based on a brief task title and/or description.\n\nTask: Given a short task title and/or description, produce:\n1) An improved, more descriptive title (3-6 words) if needed, or keep the original if already clear\n2) A brief description (2-3 sentences) that expands on what this task might involve\n\nGuidelines:\n- Title should be clear, specific, and actionable\n- If the current title is already good, keep it as-is or make minor improvements\n- If current description exists, use it to inform and improve both title and description\n- Description should anticipate the likely scope and activities for this type of task\n- **Be specific but not prescriptive** - suggest what might be involved without assuming implementation details\n- Use present tense for ongoing work, future tense for planned work\n- Keep language professional and concise\n\nExample input 1 (title only):\nCurrent title: \"User auth\"\n\nExample output 1:\n```json\n{\n  \"title\": \"Implement user authentication\",\n  \"description\": \"Build user authentication system including login, signup, and session management. Will likely involve password hashing, JWT tokens or sessions, and protected routes.\"\n}\n```\n\nExample input 2 (title + description):\nCurrent title: \"Auth\"\nCurrent description: \"Need to add login and also remember user sessions\"\n\nExample output 2:\n```json\n{\n  \"title\": \"Implement authentication with session persistence\",\n  \"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.\"\n}\n```\n\nOutput format: Return ONLY a JSON object with this exact structure:\n```json\n{\n  \"title\": \"Your improved/generated title here\",\n  \"description\": \"Your generated description here\"\n}\n```\n\nDo not include any other text, explanations, or formatting outside the JSON object.\n\n"
  },
  {
    "path": "payload/prompts/title-and-comment.md",
    "content": "You are a helpful assistant that generates concise titles and descriptive comments for coding conversation sessions.\n\nTask: Analyze the conversation material below and produce:\n1) A short, descriptive title (3-6 words) capturing the main topic or goal\n2) A brief comment (2-3 sentences) summarizing what was discussed and accomplished\n\nGuidelines:\n- Title should be clear, specific, and actionable (e.g., \"Fix authentication bug in login flow\", \"Implement dark mode toggle\")\n- **Focus on the initial request/requirement** that started the conversation - this is the primary topic\n- Comment should highlight the key problem, solution, or outcome from that initial goal\n- **Avoid process noise**: skip implementation details, bug fixes, refactorings, or discussions that happened along the way\n- **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\n- Prioritize what was requested, not how it was achieved\n- Use present tense for ongoing work, past tense for completed tasks\n- Keep language professional and concise\n\nExample of good vs bad summaries:\n**Good**:\n- Title: \"Implement session title generation\"\n- Comment: \"Added automatic LLM-based title and comment generation for sessions. Also explored adding global status bar for debugging.\"\n\n**Bad**:\n- Title: \"Fix Levenshtein algorithm performance issue\"\n- Comment: \"Optimized deduplication from O(n²) to O(n), fixed MainActor deadlock, moved processing to background thread.\"\n(This focuses on implementation details rather than the user's original goal)\n\nOutput format: Return ONLY a JSON object with this exact structure:\n```json\n{\n  \"title\": \"Your generated title here\",\n  \"comment\": \"Your generated comment here\"\n}\n```\n\nDo not include any other text, explanations, or formatting outside the JSON object.\n\nConversation material:\n\n"
  },
  {
    "path": "payload/providers.json",
    "content": "{\n  \"version\" : 1,\n  \"providers\" : [\n    {\n      \"id\" : \"openrouter\",\n      \"name\" : \"OpenRouter\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"envKey\" : \"OPENROUTER_API_KEY\",\n      \"keyURL\" : \"https://openrouter.ai/keys\",\n      \"docsURL\" : \"https://openrouter.ai/docs\",\n      \"connectors\" : {\n        \"codex\" : {\n          \"baseURL\" : \"https://openrouter.ai/api\",\n          \"wireAPI\" : \"chat\",\n          \"envHttpHeaders\" : {\n            \"HTTP-Referer\" : \"OPENROUTER_REFERRER\",\n            \"X-Title\" : \"OPENROUTER_TITLE\"\n          }\n        },\n        \"claudeCode\" : {\n          \"baseURL\" : \"https://openrouter.ai/api\",\n          \"modelAliases\" : {\n            \"default\" : \"anthropic/claude-4.5-sonnet\",\n            \"haiku\" : \"anthropic/claude-4.5-haiku\",\n            \"opus\" : \"anthropic/claude-4.5-opus\"\n          },\n          \"envHttpHeaders\" : {\n            \"x-api-key\" : \"OPENROUTER_API_KEY\",\n            \"HTTP-Referer\" : \"OPENROUTER_REFERRER\",\n            \"X-Title\" : \"OPENROUTER_TITLE\"\n          }\n        }\n      },\n      \"catalog\" : {\n        \"models\" : [\n          { \"vendorModelId\" : \"openrouter/auto\", \"caps\" : { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"anthropic/claude-4.5-opus\", \"caps\" : { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"anthropic/claude-4.5-sonnet\", \"caps\" : { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"anthropic/claude-4.5-haiku\", \"caps\" : { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"anthropic/claude-4.1-opus\", \"caps\" : { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": true } }\n        ]\n      },\n      \"recommended\" : { \n        \"defaultModelFor\" : { \n          \"codex\" : \"openrouter/auto\",\n          \"claudeCode\" : \"anthropic/claude-4.5-sonnet\"\n        } \n      }\n    },\n    {\n      \"id\" : \"openai\",\n      \"name\" : \"OpenAI\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"envKey\" : \"OPENAI_API_KEY\",\n      \"keyURL\" : \"https://platform.openai.com/api-keys\",\n      \"docsURL\" : \"https://platform.openai.com/docs\",\n      \"connectors\" : {\n        \"codex\" : {\n          \"baseURL\" : \"https://api.openai.com/v1\",\n          \"wireAPI\" : \"chat\"\n        }\n      },\n      \"catalog\" : {\n        \"models\" : [\n          { \"vendorModelId\" : \"gpt-4o-mini\", \"caps\": { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"gpt-4.1-mini\", \"caps\": { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": true } }\n        ]\n      },\n      \"recommended\" : { \"defaultModelFor\" : { \"codex\" : \"gpt-4o-mini\" } }\n    },\n    {\n      \"envKey\" : \"K2_API_KEY\",\n      \"keyURL\" : \"https://platform.moonshot.cn/console/api-keys\",\n      \"docsURL\" : \"https://platform.moonshot.cn/docs\",\n      \"connectors\" : {\n        \"codex\" : {\n          \"wireAPI\" : \"chat\",\n          \"baseURL\" : \"https://api.moonshot.cn/v1\"\n        },\n        \"claudeCode\" : {\n          \"modelAliases\" : {\n            \"default\" : \"kimi-k2-0905-preview\"\n          },\n          \"baseURL\" : \"https://api.moonshot.cn/anthropic\"\n        }\n      },\n      \"name\" : \"Kimi\",\n      \"catalog\" : {\n        \"models\" : [\n          {\n            \"caps\" : {\n              \"reasoning\" : false,\n              \"tool_use\" : true,\n              \"long_context\" : true,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"kimi-k2-0905-preview\"\n          },\n          {\n            \"caps\" : {\n              \"reasoning\" : false,\n              \"tool_use\" : true,\n              \"long_context\" : true,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"kimi-k2-turbo-preview\"\n          },\n          {\n            \"caps\" : {\n              \"reasoning\" : true,\n              \"tool_use\" : true,\n              \"long_context\" : true,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"kimi-k2-thinking\"\n          }\n        ]\n      },\n      \"id\" : \"k2\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"recommended\" : {\n        \"defaultModelFor\" : {\n          \"claudeCode\" : \"kimi-k2-0905-preview\",\n          \"codex\" : \"kimi-k2-0905-preview\"\n        }\n      }\n    },\n    {\n      \"id\" : \"anthropic\",\n      \"name\" : \"Anthropic\",\n      \"class\" : \"anthropic\",\n      \"managedByCodMate\" : true,\n      \"envKey\" : \"ANTHROPIC_AUTH_TOKEN\",\n      \"keyURL\" : \"https://console.anthropic.com/settings/keys\",\n      \"docsURL\" : \"https://docs.anthropic.com/en/docs/about-claude/models\",\n      \"connectors\" : {\n        \"claudeCode\" : {\n          \"baseURL\" : \"https://api.anthropic.com\",\n          \"modelAliases\" : {\n            \"default\" : \"claude-sonnet-4-5\",\n            \"haiku\" : \"claude-haiku-4-5\"\n          }\n        }\n      },\n      \"catalog\" : {\n        \"models\" : [\n          { \"vendorModelId\" : \"claude-sonnet-4-5\", \"caps\": { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"claude-haiku-4-5\", \"caps\": { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": true } },\n          { \"vendorModelId\" : \"claude-opus-4-1\", \"caps\": { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": true } }\n        ]\n      },\n      \"recommended\" : { \"defaultModelFor\" : { \"claudeCode\" : \"claude-sonnet-4-5\" } }\n    },\n    {\n      \"envKey\" : \"ZHIPUAI_API_KEY\",\n      \"keyURL\" : \"https://open.bigmodel.cn/usercenter/apikeys\",\n      \"docsURL\" : \"https://open.bigmodel.cn/dev/api\",\n      \"connectors\" : {\n        \"claudeCode\" : {\n          \"modelAliases\" : {\n            \"default\" : \"glm-4.6\"\n          },\n          \"baseURL\" : \"https://open.bigmodel.cn/api/anthropic\"\n        },\n        \"codex\" : {\n          \"wireAPI\" : \"chat\",\n          \"baseURL\" : \"https://open.bigmodel.cn/api/paas/v4/\"\n        }\n      },\n      \"name\" : \"GLM\",\n      \"catalog\" : {\n        \"models\" : [\n          {\n            \"caps\" : {\n              \"reasoning\" : false,\n              \"tool_use\" : true,\n              \"long_context\" : false,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"glm-4.6\"\n          },\n          {\n            \"caps\" : {\n              \"reasoning\" : false,\n              \"tool_use\" : true,\n              \"long_context\" : false,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"glm-4.5\"\n          },\n          {\n            \"caps\" : {\n              \"reasoning\" : false,\n              \"tool_use\" : true,\n              \"long_context\" : false,\n              \"vision\" : false\n            },\n            \"vendorModelId\" : \"glm-4.5-air\"\n          }\n        ]\n      },\n      \"id\" : \"glm\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"recommended\" : {\n        \"defaultModelFor\" : {\n          \"claudeCode\" : \"glm-4.6\",\n          \"codex\" : \"glm-4.6\"\n        }\n      }\n    },\n    {\n      \"id\" : \"minimax\",\n      \"name\" : \"MiniMax\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"envKey\" : \"MINIMAX_API_KEY\",\n      \"keyURL\" : \"https://platform.minimaxi.com/user-center/basic-information/interface-key\",\n      \"docsURL\" : \"https://platform.minimaxi.com/docs/guides/models-intro\",\n      \"connectors\" : {\n        \"codex\" : {\n          \"baseURL\" : \"https://api.minimaxi.com/v1\",\n          \"wireAPI\" : \"chat\",\n          \"requestMaxRetries\" : 4,\n          \"streamMaxRetries\" : 10,\n          \"streamIdleTimeoutMs\" : 300000\n        },\n        \"claudeCode\" : {\n          \"baseURL\" : \"https://api.minimaxi.com/anthropic\",\n          \"modelAliases\" : {\n            \"default\" : \"MiniMax-M2\",\n            \"haiku\" : \"MiniMax-M2\"\n          }\n        }\n      },\n      \"catalog\" : {\n        \"models\" : [\n          { \"vendorModelId\" : \"MiniMax-M2\", \"caps\": { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": false } }\n        ]\n      },\n      \"recommended\" : {\n        \"defaultModelFor\" : {\n          \"codex\" : \"MiniMax-M2\",\n          \"claudeCode\" : \"MiniMax-M2\"\n        }\n      }\n    },\n    {\n      \"id\" : \"deepseek\",\n      \"name\" : \"DeepSeek\",\n      \"class\" : \"openai-compatible\",\n      \"managedByCodMate\" : true,\n      \"envKey\" : \"DEEPSEEK_API_KEY\",\n      \"keyURL\" : \"https://platform.deepseek.com/api_keys\",\n      \"docsURL\" : \"https://api-docs.deepseek.com/zh-cn/\",\n      \"connectors\" : {\n        \"codex\" : {\n          \"baseURL\" : \"https://api.deepseek.com/v1\",\n          \"wireAPI\" : \"chat\"\n        },\n        \"claudeCode\" : {\n          \"baseURL\" : \"https://api.deepseek.com/anthropic\",\n          \"modelAliases\" : {\n            \"default\" : \"deepseek-chat\",\n            \"haiku\" : \"deepseek-chat\"\n          },\n          \"envHttpHeaders\" : {\n            \"x-api-key\" : \"DEEPSEEK_API_KEY\"\n          }\n        }\n      },\n      \"catalog\" : {\n        \"models\" : [\n          { \"vendorModelId\" : \"deepseek-chat\", \"caps\": { \"reasoning\": false, \"tool_use\": true, \"long_context\": true, \"vision\": false } },\n          { \"vendorModelId\" : \"deepseek-reasoner\", \"caps\": { \"reasoning\": true, \"tool_use\": true, \"long_context\": true, \"vision\": false } }\n        ]\n      },\n      \"recommended\" : {\n        \"defaultModelFor\" : {\n          \"codex\" : \"deepseek-chat\",\n          \"claudeCode\" : \"deepseek-chat\"\n        }\n      }\n    }\n  ],\n  \"bindings\" : {\n    \"activeProvider\" : {\n\n    },\n    \"defaultModel\" : {\n      \"codex\" : \"gpt-5.2-codex\"\n    }\n  }\n}\n"
  },
  {
    "path": "payload/terminals.json",
    "content": "[\n  {\n    \"id\": \"iterm2\",\n    \"title\": \"iTerm2\",\n    \"bundleIdentifiers\": [\"com.googlecode.iterm2\"],\n    \"managedByCodMate\": true,\n    \"supportsCommand\": true,\n    \"supportsDirectory\": true\n  },\n  {\n    \"id\": \"warp\",\n    \"title\": \"Warp\",\n    \"bundleIdentifiers\": [\"dev.warp.Warp-Stable\", \"dev.warp.Warp\"],\n    \"managedByCodMate\": true,\n    \"commandStyle\": \"warp\",\n    \"supportsCommand\": false,\n    \"supportsDirectory\": true\n  },\n  {\n    \"id\": \"ghostty\",\n    \"title\": \"Ghostty\",\n    \"bundleIdentifiers\": [\"com.mitchellh.ghostty\"],\n    \"managedByCodMate\": true,\n    \"supportsCommand\": false,\n    \"supportsDirectory\": true\n  },\n  {\n    \"id\": \"kitty\",\n    \"title\": \"Kitty\",\n    \"bundleIdentifiers\": [\"net.kovidgoyal.kitty\"],\n    \"managedByCodMate\": true,\n    \"supportsCommand\": false,\n    \"supportsDirectory\": true\n  }\n]\n"
  },
  {
    "path": "scripts/BUILD.md",
    "content": "# CodMate Build Scripts (SwiftPM)\n\nThis directory contains scripts for building CodMate using SwiftPM and packaging a notarized DMG.\n\n## Quick Start\n\n### Build the .app bundle\n```bash\nVER=1.2.3 ./scripts/create-app-bundle.sh\n```\n\n### Build a Developer ID DMG (optional notarization)\n```bash\nVER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n```\n\n## Script Overview\n\n### 1) `create-app-bundle.sh`\n**Purpose**: Build a SwiftPM release binary, compile assets, and assemble a macOS .app bundle.\n\n**Outputs**:\n- `build/CodMate.app` (default, override with `APP_DIR`)\n\n**Notes**:\n- Compiles `assets/Assets.xcassets` with `xcrun actool` into `Assets.car` (includes AppIcon).\n- Copies bundled resources into `Contents/Resources`:\n  - `payload/providers.json`\n  - `payload/terminals.json`\n  - `PrivacyInfo.xcprivacy`\n  - `THIRD-PARTY-NOTICES.md`\n  - `codmate-notify` helper into `Contents/Resources/bin/`\n\n**Usage Examples**:\n```bash\nVER=1.2.3 ./scripts/create-app-bundle.sh\nARCH_MATRIX=\"arm64\" VER=1.2.3 ./scripts/create-app-bundle.sh\nAPP_DIR=build/CodMate.app VER=1.2.3 ./scripts/create-app-bundle.sh\n```\n\n---\n\n### 2) `macos-build-notarized-dmg.sh`\n**Purpose**: Build and optionally notarize a Developer ID DMG for direct distribution.\n\n**Output**: `.dmg` files per architecture (e.g., `codmate-arm64.dmg`)\n\n**Usage Examples**:\n```bash\nVER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n\n# Notarize with a keychain profile\nAPPLE_NOTARY_PROFILE=\"AC_PROFILE\" VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n\n# Notarize with Apple ID\nAPPLE_ID=\"your@apple.id\" \\\nAPPLE_PASSWORD=\"xxxx-xxxx-xxxx-xxxx\" \\\nTEAM_ID=\"YOURTEAMID\" \\\nVER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n```\n\n---\n\n### 3) `make notices`\n**Purpose**: Regenerate `THIRD-PARTY-NOTICES.md` from resolved dependencies.\n\n**Notes**:\n- Scans `Package.resolved` and local checkouts in `.build/checkouts`.\n- Fails if any dependency is missing a license file.\n\n---\n\n## Environment Variables\n\n### Common\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `VER` | _required_ | Marketing version (e.g., `1.2.3`) |\n| `BUILD_NUMBER_STRATEGY` | `date` | `date`, `git`, or `counter` |\n| `ARCH_MATRIX` | `arm64 x86_64` | Architectures to build |\n| `MIN_MACOS` | `13.5` | Minimum macOS version |\n| `BUILD_DIR` | `build` | Build workspace |\n| `APP_DIR` | `build/CodMate.app` | Output .app path |\n| `BUNDLE_ID` | `ai.umate.codmate` | Bundle identifier |\n| `OUTPUT_DIR` | `artifacts` | DMG output directory (e.g., `codmate-arm64.dmg`) |\n| `STRIP` | `1` | Set to `0` to disable binary stripping |\n| `STRIP_FLAGS` | `-x` | Flags passed to `strip` |\n\n### Signing / Notarization\n| Variable | Default | Description |\n|----------|---------|-------------|\n| `SIGNING_CERT` | `Developer ID Application` | Signing certificate name |\n| `SANDBOX` | `off` | `on` to apply `assets/CodMate.entitlements` |\n| `APPLE_NOTARY_PROFILE` | - | Keychain profile for notarization |\n| `APPLE_ID` / `APPLE_PASSWORD` / `TEAM_ID` | - | Apple ID credentials for notarization |\n\n---\n\n## Prerequisites\n- macOS 13.5+\n- Swift 6 toolchain\n- Xcode Command Line Tools (for `xcrun` + `actool`)\n- (Optional) `create-dmg` for a nicer DMG layout\n"
  },
  {
    "path": "scripts/build-libghostty-local.sh",
    "content": "#!/bin/bash\nset -euo pipefail\n\n# Build libghostty using local Ghostty repository\n# Usage: ./scripts/build-libghostty-local.sh [arch]\n#   - arch: target architecture (aarch64|x86_64, default: build both)\n\nROOT_DIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nVENDOR_DIR=\"${ROOT_DIR}/ghostty/Vendor\"\nGHOSTTY_DIR=\"/Volumes/External/GitHub/ghostty\"\n\nREQUESTED_ARCH=\"${1:-}\"\n\necho \"Building libghostty from local repo...\"\necho \"Ghostty dir: ${GHOSTTY_DIR}\"\n\n# Check if Ghostty directory exists\nif [ ! -d \"${GHOSTTY_DIR}\" ]; then\n    echo \"Error: Ghostty directory not found at ${GHOSTTY_DIR}\" >&2\n    exit 1\nfi\n\n# Get current commit\ncd \"${GHOSTTY_DIR}\"\nREF=\"$(git rev-parse HEAD)\"\necho \"Using Ghostty commit: ${REF}\"\n\n# Setup temp dir for patches\nWORKDIR=\"$(mktemp -d)\"\ntrap 'rm -rf \"${WORKDIR}\"' EXIT\n\n# Copy Ghostty to temp dir (to avoid modifying original)\necho \"Copying Ghostty to temp build directory...\"\ncp -R \"${GHOSTTY_DIR}\" \"${WORKDIR}/ghostty\"\ncd \"${WORKDIR}/ghostty\"\n\n# Patch build.zig to install libs on macOS\nperl -0pi -e 's/if \\(!config\\.target\\.result\\.os\\.tag\\.isDarwin\\(\\)\\) \\{/if (true) {/' \"${WORKDIR}/ghostty/build.zig\"\n\n# Patch to link Metal frameworks\nperl -0pi -e 's/lib\\.linkFramework\\(\"IOSurface\"\\);/lib.linkFramework(\"IOSurface\");\\n    lib.linkFramework(\"Metal\");\\n    lib.linkFramework(\"MetalKit\");/g' \"${WORKDIR}/ghostty/pkg/macos/build.zig\"\nperl -0pi -e 's/module\\.linkFramework\\(\"IOSurface\", \\.\\{\\}\\);/module.linkFramework(\"IOSurface\", .{});\\n        module.linkFramework(\"Metal\", .{});\\n        module.linkFramework(\"MetalKit\", .{});/g' \"${WORKDIR}/ghostty/pkg/macos/build.zig\"\n\n# Patch bundle ID to use CodMate's\nsed -i '' 's/com\\.mitchellh\\.ghostty/ai.umate.codmate/g' \"${WORKDIR}/ghostty/src/build_config.zig\"\n\nZIG_FLAGS=(\n    -Dapp-runtime=none\n    -Demit-xcframework=false\n    -Demit-macos-app=false\n    -Demit-exe=false\n    -Demit-docs=false\n    -Demit-webdata=false\n    -Demit-helpgen=false\n    -Demit-terminfo=true\n    -Demit-termcap=false\n    -Demit-themes=false\n    -Doptimize=ReleaseFast\n    -Dstrip\n)\n\nbuild_arch() {\n    local arch=\"$1\"\n    local outdir=\"${WORKDIR}/zig-out-${arch}\"\n    echo \"Building for ${arch}...\" >&2\n    (cd \"${WORKDIR}/ghostty\" && zig build \"${ZIG_FLAGS[@]}\" -Dtarget=\"${arch}-macos\" -p \"${outdir}\")\n    if [ ! -f \"${outdir}/lib/libghostty.a\" ]; then\n        echo \"Error: build failed - ${outdir}/lib/libghostty.a not found\" >&2\n        exit 1\n    fi\n    \n    # Copy architecture-specific library to Vendor/lib/{arch}/\n    local arch_dir=\"${VENDOR_DIR}/lib/${arch}\"\n    mkdir -p \"${arch_dir}\"\n    cp \"${outdir}/lib/libghostty.a\" \"${arch_dir}/libghostty.a\"\n    \n    # Strip debug symbols from the static library to reduce size\n    # Note: This removes DWARF debug info but keeps symbol table for linking\n    if command -v strip >/dev/null 2>&1; then\n        # Use -S to strip only debug symbols, keeping symbol table for linking\n        strip -S \"${arch_dir}/libghostty.a\" 2>/dev/null || true\n        echo \"Stripped debug symbols from ${arch} library\"\n    fi\n    \n    echo \"Copied ${arch} library to ${arch_dir}/libghostty.a\"\n    echo \"${outdir}/lib/libghostty.a\"\n}\n\n# Determine which architectures to build\nARCHES=()\nif [ -z \"${REQUESTED_ARCH}\" ]; then\n    # Build both architectures\n    ARCHES=(aarch64 x86_64)\nelif [ \"${REQUESTED_ARCH}\" = \"aarch64\" ] || [ \"${REQUESTED_ARCH}\" = \"arm64\" ]; then\n    ARCHES=(aarch64)\nelif [ \"${REQUESTED_ARCH}\" = \"x86_64\" ]; then\n    ARCHES=(x86_64)\nelse\n    echo \"Error: Invalid architecture '${REQUESTED_ARCH}'. Use 'aarch64' or 'x86_64'.\" >&2\n    exit 1\nfi\n\n# Build each requested architecture\nmkdir -p \"${VENDOR_DIR}/lib\" \"${VENDOR_DIR}/include\"\nfor arch in \"${ARCHES[@]}\"; do\n    build_arch \"${arch}\"\ndone\n\n# Copy headers (preserve module.modulemap which is custom)\nif [ -d \"${WORKDIR}/ghostty/include\" ]; then\n    rsync -a --exclude='module.modulemap' \"${WORKDIR}/ghostty/include/\" \"${VENDOR_DIR}/include/\"\nfi\n\n# Record version\nprintf \"%s\\n\" \"${REF}\" > \"${VENDOR_DIR}/VERSION\"\n\necho \"Done: Built ${#ARCHES[@]} architecture(s)\"\nfor arch in \"${ARCHES[@]}\"; do\n    local arch_lib=\"${VENDOR_DIR}/lib/${arch}/libghostty.a\"\n    if [ -f \"${arch_lib}\" ]; then\n        echo \"  ${arch}: $(lipo -info \"${arch_lib}\")\"\n    fi\ndone\n"
  },
  {
    "path": "scripts/create-app-bundle.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\nROOT_DIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nAPP_NAME=\"CodMate\"\nBUILD_DIR=\"${BUILD_DIR:-$ROOT_DIR/build}\"\nAPP_DIR=\"${APP_DIR:-$BUILD_DIR/CodMate.app}\"\nBIN_DIR=\"$BUILD_DIR/bin\"\n\nARCH_MATRIX=( ${ARCH_MATRIX:-arm64 x86_64} )\nSWIFT_CONFIG=\"${SWIFT_CONFIG:-release}\"\n\nBUNDLE_ID=\"${BUNDLE_ID:-ai.umate.codmate}\"\nMIN_MACOS=\"${MIN_MACOS:-13.5}\"\nVER=\"${VER:-}\"\nBUILD_NUMBER_STRATEGY=\"${BUILD_NUMBER_STRATEGY:-date}\"\nBUILD_NUMBER=\"${BUILD_NUMBER:-}\"\n\ncompute_build_number() {\n  case \"$BUILD_NUMBER_STRATEGY\" in\n    date) date +%Y%m%d%H%M ;;\n    git) (cd \"$ROOT_DIR\" && git rev-list --count HEAD 2>/dev/null) || echo 1 ;;\n    counter)\n      local f=\"${BUILD_COUNTER_FILE:-$BUILD_DIR/build-number}\"\n      mkdir -p \"$(dirname \"$f\")\"\n      local n=0\n      if [[ -f \"$f\" ]]; then n=$(cat \"$f\" 2>/dev/null || echo 0); fi\n      n=$((n+1))\n      echo \"$n\" > \"$f\"\n      echo \"$n\" ;;\n    *) date +%Y%m%d%H%M ;;\n  esac\n}\n\nif [[ -z \"$VER\" ]]; then\n  echo \"[error] VER is required. Example: VER=1.2.3 ./scripts/create-app-bundle.sh\" >&2\n  exit 1\nfi\n\nBASE_VERSION=\"$VER\"\n\nif [[ -z \"$BUILD_NUMBER\" ]]; then\n  BUILD_NUMBER=\"$(compute_build_number)\"\nfi\nDISPLAY_VERSION=\"${BASE_VERSION}+${BUILD_NUMBER}\"\n\nSWIFT_FLAGS=(\n  -Xswiftc -DSYSTEM_PACKAGE_DARWIN\n  -Xswiftc -DSUBPROCESS_ASYNCIO_DISPATCH\n  -Xswiftc -enable-experimental-feature\n  -Xswiftc LifetimeDependence\n  -Xswiftc -enable-experimental-feature\n  -Xswiftc NonescapableTypes\n)\n\nSTRIP=\"${STRIP:-1}\"\nSTRIP_FLAGS=\"${STRIP_FLAGS:--x}\"\n\nif [[ -n \"${EXTRA_SWIFT_FLAGS:-}\" ]]; then\n  # shellcheck disable=SC2206\n  EXTRA_FLAGS=( ${EXTRA_SWIFT_FLAGS} )\n  SWIFT_FLAGS+=(\"${EXTRA_FLAGS[@]}\")\nfi\n\nmkdir -p \"$BUILD_DIR\" \"$BIN_DIR\"\n\nCODMATE_BINS=()\nNOTIFY_BINS=()\n\n# Note: SwiftPM's --arch flag automatically creates architecture-specific build directories\n# (e.g., .build/arm64-apple-macosx/ and .build/x86_64-apple-macosx/)\n# Each architecture's dependencies are compiled separately, ensuring no cross-architecture contamination.\n\nfor arch in \"${ARCH_MATRIX[@]}\"; do\n  # Map SwiftPM arch names to libghostty arch names\n  GHOSTTY_ARCH=\"\"\n  case \"$arch\" in\n    arm64) GHOSTTY_ARCH=\"aarch64\" ;;\n    x86_64) GHOSTTY_ARCH=\"x86_64\" ;;\n    *) GHOSTTY_ARCH=\"$arch\" ;;\n  esac\n  \n  # Setup architecture-specific libghostty library for linking\n  VENDOR_LIB_DIR=\"$ROOT_DIR/ghostty/Vendor/lib\"\n  ARCH_LIB=\"$VENDOR_LIB_DIR/$GHOSTTY_ARCH/libghostty.a\"\n  LINK_LIB=\"$VENDOR_LIB_DIR/libghostty.a\"\n  \n  if [ -f \"$ARCH_LIB\" ]; then\n    # Copy architecture-specific library to the link location\n    cp -f \"$ARCH_LIB\" \"$LINK_LIB\"\n    echo \"[libghostty] Using $GHOSTTY_ARCH library for $arch build\"\n  elif [ -f \"$LINK_LIB\" ]; then\n    echo \"[libghostty] Using existing library at $LINK_LIB (may be wrong architecture)\"\n  else\n    echo \"[warn] libghostty.a not found at $ARCH_LIB or $LINK_LIB\" >&2\n    echo \"[warn] Build may fail. Run ./scripts/build-libghostty-local.sh $GHOSTTY_ARCH first\" >&2\n  fi\n  \n  echo \"[build] swift build -c $SWIFT_CONFIG --arch $arch\"\n  swift build -c \"$SWIFT_CONFIG\" --arch \"$arch\" \"${SWIFT_FLAGS[@]}\"\n  BIN_PATH=\"$(swift build -c \"$SWIFT_CONFIG\" --arch \"$arch\" --show-bin-path)\"\n\n  CODMATE_BIN=\"$BIN_PATH/CodMate\"\n  NOTIFY_BIN=\"$BIN_PATH/notify\"\n\n  if [[ ! -f \"$CODMATE_BIN\" ]]; then\n    echo \"[error] CodMate binary missing at $CODMATE_BIN\" >&2\n    exit 1\n  fi\n  if [[ ! -f \"$NOTIFY_BIN\" ]]; then\n    echo \"[info] notify binary missing; building product explicitly\"\n    swift build -c \"$SWIFT_CONFIG\" --arch \"$arch\" \"${SWIFT_FLAGS[@]}\" --product notify\n    BIN_PATH=\"$(swift build -c \"$SWIFT_CONFIG\" --arch \"$arch\" --show-bin-path)\"\n    NOTIFY_BIN=\"$BIN_PATH/notify\"\n    if [[ ! -f \"$NOTIFY_BIN\" ]]; then\n      echo \"[error] notify binary missing at $NOTIFY_BIN\" >&2\n      exit 1\n    fi\n  fi\n\n  # Verify binary architectures match expected arch (ensures no cross-architecture contamination)\n  if command -v lipo >/dev/null 2>&1; then\n    for BIN_TO_CHECK in \"$CODMATE_BIN\" \"$NOTIFY_BIN\"; do\n      if [[ -f \"$BIN_TO_CHECK\" ]]; then\n        BIN_ARCHS=$(lipo -info \"$BIN_TO_CHECK\" 2>/dev/null | sed 's/.*: //' || echo \"\")\n        if [[ -n \"$BIN_ARCHS\" ]]; then\n          if [[ \"$BIN_ARCHS\" != *\"$arch\"* ]]; then\n            echo \"[error] Binary $(basename \"$BIN_TO_CHECK\") architecture mismatch: expected $arch, got $BIN_ARCHS\" >&2\n            exit 1\n          fi\n          # Check if binary contains multiple architectures (should only have one)\n          ARCH_COUNT=$(echo \"$BIN_ARCHS\" | wc -w | tr -d ' ')\n          if [[ \"$ARCH_COUNT\" -gt 1 ]]; then\n            echo \"[error] Binary $(basename \"$BIN_TO_CHECK\") contains multiple architectures ($BIN_ARCHS), expected only $arch\" >&2\n            exit 1\n          fi\n          echo \"[verify] $(basename \"$BIN_TO_CHECK\") architecture: $BIN_ARCHS (expected: $arch)\"\n        fi\n      fi\n    done\n  fi\n\n  CODMATE_BINS+=(\"$CODMATE_BIN\")\n  NOTIFY_BINS+=(\"$NOTIFY_BIN\")\n  echo \"[ok] Built for $arch\"\n  echo \"      CodMate: $CODMATE_BIN\"\n  echo \"      notify:  $NOTIFY_BIN\"\n  echo \"\"\ndone\n\nif [[ ${#ARCH_MATRIX[@]} -eq 1 ]]; then\n  cp -f \"${CODMATE_BINS[0]}\" \"$BIN_DIR/CodMate\"\n  cp -f \"${NOTIFY_BINS[0]}\" \"$BIN_DIR/notify\"\n  ARCH_SUFFIX=\"${ARCH_MATRIX[0]}\"\nelse\n  ARCH_SUFFIX=\"universal\"\n  lipo -create \"${CODMATE_BINS[@]}\" -output \"$BIN_DIR/CodMate\"\n  lipo -create \"${NOTIFY_BINS[@]}\" -output \"$BIN_DIR/notify\"\nfi\n\nchmod +x \"$BIN_DIR/CodMate\" \"$BIN_DIR/notify\"\n\nif [[ \"$STRIP\" == \"1\" ]]; then\n  if command -v strip >/dev/null 2>&1; then\n    echo \"[strip] Stripping binaries ($STRIP_FLAGS)\"\n    strip $STRIP_FLAGS \"$BIN_DIR/CodMate\" \"$BIN_DIR/notify\" || true\n  else\n    echo \"[warn] strip not found; skipping binary strip\"\n  fi\nfi\n\necho \"[bundle] Building $APP_NAME.app ($DISPLAY_VERSION, $ARCH_SUFFIX)\"\nrm -rf \"$APP_DIR\"\nmkdir -p \"$APP_DIR/Contents/MacOS\" \"$APP_DIR/Contents/Resources/bin\"\n\ncp -f \"$BIN_DIR/CodMate\" \"$APP_DIR/Contents/MacOS/CodMate\"\ncp -f \"$BIN_DIR/notify\" \"$APP_DIR/Contents/Resources/bin/codmate-notify\"\nchmod +x \"$APP_DIR/Contents/MacOS/CodMate\" \"$APP_DIR/Contents/Resources/bin/codmate-notify\"\n\necho -n \"APPL????\" > \"$APP_DIR/Contents/PkgInfo\"\n\nINFO_SRC=\"$ROOT_DIR/assets/Info.plist\"\nINFO_DST=\"$APP_DIR/Contents/Info.plist\"\nif [[ ! -f \"$INFO_SRC\" ]]; then\n  echo \"[error] Info.plist not found at $INFO_SRC\" >&2\n  exit 1\nfi\ncp -f \"$INFO_SRC\" \"$INFO_DST\"\n\n/usr/libexec/PlistBuddy -c \"Set :CFBundleIdentifier $BUNDLE_ID\" \"$INFO_DST\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleShortVersionString $BASE_VERSION\" \"$INFO_DST\"\n/usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $BUILD_NUMBER\" \"$INFO_DST\"\n/usr/libexec/PlistBuddy -c \"Set :LSMinimumSystemVersion $MIN_MACOS\" \"$INFO_DST\"\n\nGIT_TAG=\"${GIT_TAG:-$(cd \"$ROOT_DIR\" && git describe --tags --abbrev=0 2>/dev/null || true)}\"\nGIT_COMMIT=\"${GIT_COMMIT:-$(cd \"$ROOT_DIR\" && git rev-parse --short HEAD 2>/dev/null || true)}\"\nGIT_DIRTY=\"${GIT_DIRTY:-}\"\nif [[ -z \"$GIT_DIRTY\" ]]; then\n  if (cd \"$ROOT_DIR\" && git diff --quiet --ignore-submodules --); then\n    GIT_DIRTY=\"0\"\n  else\n    GIT_DIRTY=\"1\"\n  fi\nfi\nplutil -replace CodMateGitTag -string \"$GIT_TAG\" \"$INFO_DST\"\nplutil -replace CodMateGitCommit -string \"$GIT_COMMIT\" \"$INFO_DST\"\nplutil -replace CodMateGitDirty -string \"$GIT_DIRTY\" \"$INFO_DST\"\n\nRESOURCES_DIR=\"$APP_DIR/Contents/Resources\"\n\nif [[ -d \"$ROOT_DIR/assets/Assets.xcassets\" ]]; then\n  if ! command -v xcrun >/dev/null 2>&1; then\n    echo \"[error] xcrun not found. Install Xcode Command Line Tools.\" >&2\n    exit 1\n  fi\n  echo \"[assets] Compiling asset catalog\"\n  xcrun actool \\\n    \"$ROOT_DIR/assets/Assets.xcassets\" \\\n    --compile \"$RESOURCES_DIR\" \\\n    --platform macosx \\\n    --minimum-deployment-target \"$MIN_MACOS\" \\\n    --app-icon AppIcon \\\n    --output-partial-info-plist \"$BUILD_DIR/asset-info.plist\" \\\n    --notices --warnings\nfi\n\nif [[ -f \"$ROOT_DIR/payload/providers.json\" ]]; then\n  cp -f \"$ROOT_DIR/payload/providers.json\" \"$RESOURCES_DIR/providers.json\"\nfi\nif [[ -f \"$ROOT_DIR/payload/terminals.json\" ]]; then\n  cp -f \"$ROOT_DIR/payload/terminals.json\" \"$RESOURCES_DIR/terminals.json\"\nfi\nif [[ -f \"$ROOT_DIR/payload/hook-variables.json\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -f \"$ROOT_DIR/payload/hook-variables.json\" \"$RESOURCES_DIR/payload/hook-variables.json\"\nfi\nif [[ -f \"$ROOT_DIR/payload/hook-events.json\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -f \"$ROOT_DIR/payload/hook-events.json\" \"$RESOURCES_DIR/payload/hook-events.json\"\nfi\nif [[ -d \"$ROOT_DIR/payload/commands\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -R \"$ROOT_DIR/payload/commands\" \"$RESOURCES_DIR/payload/commands\"\nfi\nif [[ -d \"$ROOT_DIR/payload/prompts\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -R \"$ROOT_DIR/payload/prompts\" \"$RESOURCES_DIR/payload/prompts\"\nfi\nif [[ -d \"$ROOT_DIR/payload/internal-skills\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -R \"$ROOT_DIR/payload/internal-skills\" \"$RESOURCES_DIR/payload/internal-skills\"\nfi\nif [[ -d \"$ROOT_DIR/payload/knowledge\" ]]; then\n  mkdir -p \"$RESOURCES_DIR/payload\"\n  cp -R \"$ROOT_DIR/payload/knowledge\" \"$RESOURCES_DIR/payload/knowledge\"\nfi\nif [[ -f \"$ROOT_DIR/PrivacyInfo.xcprivacy\" ]]; then\n  cp -f \"$ROOT_DIR/PrivacyInfo.xcprivacy\" \"$RESOURCES_DIR/PrivacyInfo.xcprivacy\"\nfi\nif [[ -f \"$ROOT_DIR/THIRD-PARTY-NOTICES.md\" ]]; then\n  cp -f \"$ROOT_DIR/THIRD-PARTY-NOTICES.md\" \"$RESOURCES_DIR/THIRD-PARTY-NOTICES.md\"\nfi\n\nif [[ \"${SIGN_ADHOC:-}\" == \"1\" ]]; then\n  echo \"[sign] Ad-hoc signing for local run (Notify Entitlements)\"\n  ENTITLEMENTS=\"$ROOT_DIR/assets/CodMate-Notify.entitlements\"\n  \n  # Sign inner binaries first\n  if [[ -f \"$APP_DIR/Contents/Resources/bin/codmate-notify\" ]]; then\n    codesign --force --sign - --entitlements \"$ENTITLEMENTS\" --timestamp=none \\\n      \"$APP_DIR/Contents/Resources/bin/codmate-notify\"\n  fi\n  \n  codesign --force --sign - --entitlements \"$ENTITLEMENTS\" --timestamp=none \\\n    \"$APP_DIR/Contents/MacOS/CodMate\"\n    \n  # Sign the bundle\n  codesign --force --sign - --entitlements \"$ENTITLEMENTS\" --timestamp=none \\\n    \"$APP_DIR\"\nfi\n\necho \"[ok] App bundle ready at $APP_DIR\"\n"
  },
  {
    "path": "scripts/gen-third-party-notices.py",
    "content": "#!/usr/bin/env python3\nimport json\nimport os\nimport subprocess\nimport sys\n\n\nROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), \"..\"))\nRESOLVED_PATH = os.path.join(ROOT, \"Package.resolved\")\nOUTPUT_PATH = os.path.join(ROOT, \"THIRD-PARTY-NOTICES.md\")\n\nLICENSE_FILES = [\n    \"LICENSE\",\n    \"LICENSE.txt\",\n    \"LICENSE.md\",\n    \"COPYING\",\n    \"COPYING.txt\",\n    \"COPYING.md\",\n    \"LICENCE\",\n    \"LICENCE.txt\",\n    \"LICENCE.md\",\n]\nNOTICE_FILES = [\"NOTICE\", \"NOTICE.txt\", \"NOTICE.md\"]\n\n\ndef run_git(args, cwd):\n    try:\n        result = subprocess.run(\n            [\"git\"] + args, cwd=cwd, check=True, capture_output=True, text=True\n        )\n        return result.stdout.strip()\n    except Exception:\n        return \"\"\n\n\ndef repo_url_for_path(path):\n    url = run_git([\"config\", \"--get\", \"remote.origin.url\"], cwd=path)\n    return url if url else None\n\n\ndef read_file(path):\n    with open(path, \"r\", encoding=\"utf-8\", errors=\"ignore\") as f:\n        return f.read().strip()\n\n\ndef pick_first_existing(base_dir, names):\n    for name in names:\n        candidate = os.path.join(base_dir, name)\n        if os.path.isfile(candidate):\n            return candidate\n    return None\n\n\ndef checkout_dir_for_pin(identity, location):\n    candidates = []\n    if identity:\n        candidates.append(os.path.join(ROOT, \".build\", \"checkouts\", identity))\n    if location:\n        base = os.path.basename(location.rstrip(\"/\"))\n        if base.endswith(\".git\"):\n            base = base[: -len(\".git\")]\n        candidates.append(os.path.join(ROOT, \".build\", \"checkouts\", base))\n    for c in candidates:\n        if os.path.isdir(c):\n            return c\n    return None\n\n\ndef version_label(state):\n    if not state:\n        return \"unknown\"\n    if \"version\" in state:\n        return state[\"version\"]\n    if \"branch\" in state and \"revision\" in state:\n        return f'{state[\"branch\"]}@{state[\"revision\"][:7]}'\n    if \"revision\" in state:\n        return state[\"revision\"][:7]\n    return \"unknown\"\n\n\ndef load_pins():\n    if not os.path.isfile(RESOLVED_PATH):\n        print(\"ERROR: Package.resolved not found.\", file=sys.stderr)\n        sys.exit(1)\n    with open(RESOLVED_PATH, \"r\", encoding=\"utf-8\") as f:\n        data = json.load(f)\n    return data.get(\"pins\", [])\n\n\ndef main():\n    pins = load_pins()\n    entries = []\n\n    for pin in pins:\n        identity = pin.get(\"identity\", \"\")\n        location = pin.get(\"location\", \"\")\n        state = pin.get(\"state\", {})\n        entries.append(\n            {\n                \"name\": identity,\n                \"repo\": location,\n                \"version\": version_label(state),\n                \"path\": checkout_dir_for_pin(identity, location),\n            }\n        )\n\n    # Local dependency: SwiftTerm\n    swiftterm_path = os.path.join(ROOT, \"SwiftTerm\")\n    if os.path.isdir(swiftterm_path):\n        entries.append(\n            {\n                \"name\": \"SwiftTerm\",\n                \"repo\": repo_url_for_path(swiftterm_path) or \"https://github.com/migueldeicaza/SwiftTerm\",\n                \"version\": run_git([\"describe\", \"--tags\", \"--abbrev=0\"], cwd=swiftterm_path)\n                or run_git([\"rev-parse\", \"--short\", \"HEAD\"], cwd=swiftterm_path)\n                or \"local\",\n                \"path\": swiftterm_path,\n            }\n        )\n\n    # Deduplicate by name (keep first occurrence)\n    seen = set()\n    unique_entries = []\n    for e in entries:\n        key = e[\"name\"].lower()\n        if key in seen:\n            continue\n        seen.add(key)\n        unique_entries.append(e)\n\n    unique_entries.sort(key=lambda x: x[\"name\"].lower())\n\n    missing = []\n    sections = []\n    for e in unique_entries:\n        name = e[\"name\"] or \"unknown\"\n        repo = e[\"repo\"] or \"unknown\"\n        version = e[\"version\"] or \"unknown\"\n        path = e[\"path\"]\n\n        license_path = None\n        notice_path = None\n        if path and os.path.isdir(path):\n            license_path = pick_first_existing(path, LICENSE_FILES)\n            notice_path = pick_first_existing(path, NOTICE_FILES)\n        if not license_path:\n            missing.append(name)\n\n        header = [f\"{name} ({version})\", f\"Repository: {repo}\"]\n        if license_path:\n            header.append(f\"License file: {os.path.basename(license_path)}\")\n        else:\n            header.append(\"License file: NOT FOUND\")\n\n        body = []\n        if license_path:\n            body.append(read_file(license_path))\n        if notice_path:\n            body.append(\"\")\n            body.append(f\"NOTICE ({os.path.basename(notice_path)})\")\n            body.append(read_file(notice_path))\n\n        sections.append(\"\\n\".join(header + [\"\"] + body).strip())\n\n    out = [\n        \"Third-Party Notices\",\n        \"\",\n        \"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.\",\n        \"\",\n        \"If you distribute CodMate binaries, keep this file together with `LICENSE`.\",\n        \"\",\n        \"---\",\n        \"\",\n    ]\n    out.append(\"\\n\\n---\\n\\n\".join(sections))\n    content = \"\\n\".join(out).strip() + \"\\n\"\n\n    if missing:\n        print(\"ERROR: Missing license files for:\", \", \".join(sorted(missing)), file=sys.stderr)\n        print(\"Hint: run `swift package resolve` and retry.\", file=sys.stderr)\n        sys.exit(1)\n\n    with open(OUTPUT_PATH, \"w\", encoding=\"utf-8\") as f:\n        f.write(content)\n\n    print(f\"[ok] Updated {OUTPUT_PATH}\")\n\n\nif __name__ == \"__main__\":\n    main()\n"
  },
  {
    "path": "scripts/macos-build-notarized-dmg.sh",
    "content": "#!/usr/bin/env bash\n\nset -euo pipefail\n\n# CodMate macOS notarized DMG builder (SwiftPM)\n# - Builds app bundle via scripts/create-app-bundle.sh\n# - Signs app (Developer ID)\n# - Creates DMG\n# - Notarizes + staples (optional)\n#\n# Usage:\n#   VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\n#\n# Optional overrides:\n#   ARCH_MATRIX=\"arm64 x86_64\"\n#   OUTPUT_DIR=artifacts\n#   SIGNING_CERT=\"Developer ID Application\"\n#   SANDBOX=on|off (default: off)\n#   SKIP_NOTARIZATION=1\n#   APPLE_NOTARY_PROFILE=\"AC_PROFILE\"\n#   APPLE_ID / APPLE_PASSWORD / TEAM_ID\n\nROOT_DIR=\"$(cd \"$(dirname \"$0\")/..\" && pwd)\"\nBUILD_DIR=\"${BUILD_DIR:-$ROOT_DIR/build}\"\nOUTPUT_DIR=\"${OUTPUT_DIR:-$ROOT_DIR/artifacts}\"\nAPP_NAME=\"CodMate\"\nAPP_DIR=\"${APP_DIR:-$BUILD_DIR/CodMate.app}\"\nENTITLEMENTS_PATH=\"${ENTITLEMENTS_PATH:-$ROOT_DIR/assets/CodMate.entitlements}\"\nARCH_MATRIX=( ${ARCH_MATRIX:-arm64 x86_64} )\nMIN_MACOS=\"${MIN_MACOS:-13.5}\"\nBUNDLE_ID=\"${BUNDLE_ID:-ai.umate.codmate}\"\n\nmkdir -p \"$BUILD_DIR\" \"$OUTPUT_DIR\"\n\n# Load .env without overriding explicitly exported vars\nENV_FILE=\"$ROOT_DIR/.env\"\nif [[ -f \"$ENV_FILE\" ]]; then\n  while IFS='=' read -r k v; do\n    [[ -z \"${k// /}\" ]] && continue\n    [[ \"$k\" =~ ^# ]] && continue\n    case \"$k\" in\n      APPLE_SIGNING_IDENTITY|APPLE_ID|APPLE_PASSWORD|APPLE_TEAM_ID)\n        if [[ -z \"${!k:-}\" ]]; then\n          v=\"${v%\\r}\"; v=\"${v%\\n}\"; v=\"${v%\\\"}\"; v=\"${v#\\\"}\"\n          export \"$k=$v\"\n        fi\n        ;;\n      *) ;;\n    esac\n  done < \"$ENV_FILE\"\nfi\n\nVER=\"${VER:-}\"\nif [[ -z \"$VER\" ]]; then\n  echo \"[error] VER is required. Example: VER=1.2.3 ./scripts/macos-build-notarized-dmg.sh\" >&2\n  exit 1\nfi\n\nBUILD_NUMBER_STRATEGY=\"${BUILD_NUMBER_STRATEGY:-date}\"\ncompute_build_number() {\n  case \"$BUILD_NUMBER_STRATEGY\" in\n    date) date +%Y%m%d%H%M ;;\n    git) (cd \"$ROOT_DIR\" && git rev-list --count HEAD 2>/dev/null) || echo 1 ;;\n    counter)\n      local f=\"${BUILD_COUNTER_FILE:-$BUILD_DIR/build-number}\"\n      mkdir -p \"$(dirname \"$f\")\"\n      local n=0\n      if [[ -f \"$f\" ]]; then n=$(cat \"$f\" 2>/dev/null || echo 0); fi\n      n=$((n+1))\n      echo \"$n\" > \"$f\"\n      echo \"$n\" ;;\n    *) date +%Y%m%d%H%M ;;\n  esac\n}\n\nBUILD_NUMBER=\"${BUILD_NUMBER:-$(compute_build_number)}\"\nDISPLAY_VERSION=\"${VER}+${BUILD_NUMBER}\"\n\nSANDBOX=\"${SANDBOX:-off}\"\n\nTEAM_ID=\"${TEAM_ID:-${APPLE_TEAM_ID:-}}\"\nSIGNING_CERT=\"${SIGNING_CERT:-${APPLE_SIGNING_IDENTITY:-}}\"\nif [[ -z \"$SIGNING_CERT\" ]]; then\n  SIGNING_CERT=\"Developer ID Application\"\nfi\n\nCODESIGN_IDENTITY=\"\"\nif security find-identity -v -p codesigning | grep -q \"$SIGNING_CERT\"; then\n  CODESIGN_IDENTITY=\"$(security find-identity -v -p codesigning | grep \"$SIGNING_CERT\" | head -1 | sed 's/.*\"\\(.*\\)\".*/\\1/')\"\nelse\n  echo \"[warn] Signing identity not found ($SIGNING_CERT). Falling back to ad-hoc signature.\"\nfi\n\nunset ENTITLEMENTS_ARG\nif [[ \"$SANDBOX\" == \"on\" ]]; then\n  ENTITLEMENTS_ARG=(--entitlements \"$ENTITLEMENTS_PATH\")\nfi\n\nNOTARY_MODE=\"${NOTARY_MODE:-auto}\"\nif [[ \"${SKIP_NOTARIZATION:-}\" == \"1\" || \"$NOTARY_MODE\" == \"none\" ]]; then\n  NOTARY_MODE=\"none\"\nelif [[ \"$NOTARY_MODE\" == \"profile\" ]]; then\n  if [[ -z \"${APPLE_NOTARY_PROFILE:-}\" ]]; then\n    echo \"[error] NOTARY_MODE=profile requires APPLE_NOTARY_PROFILE\" >&2\n    exit 1\n  fi\nelif [[ \"$NOTARY_MODE\" == \"apple\" ]]; then\n  if [[ -z \"${APPLE_ID:-}\" || -z \"${APPLE_PASSWORD:-}\" || -z \"$TEAM_ID\" ]]; then\n    echo \"[error] NOTARY_MODE=apple requires APPLE_ID, APPLE_PASSWORD, and TEAM_ID\" >&2\n    exit 1\n  fi\nelse\n  NOTARY_MODE=\"none\"\n  if [[ -n \"${APPLE_NOTARY_PROFILE:-}\" ]]; then\n    NOTARY_MODE=\"profile\"\n  elif [[ -n \"${APPLE_ID:-}\" && -n \"${APPLE_PASSWORD:-}\" && -n \"$TEAM_ID\" ]]; then\n    NOTARY_MODE=\"apple\"\n  fi\nfi\n\nif [[ \"$NOTARY_MODE\" != \"none\" && -z \"$CODESIGN_IDENTITY\" ]]; then\n  echo \"[warn] Notarization requires a Developer ID signing identity. Disabling notarization.\" >&2\n  NOTARY_MODE=\"none\"\nfi\n\necho \"==========================================\"\necho \"  CodMate - Developer ID DMG (SwiftPM)\"\necho \"==========================================\"\necho \"Version: $DISPLAY_VERSION\"\necho \"Architectures: ${ARCH_MATRIX[*]}\"\necho \"Output: $OUTPUT_DIR\"\necho \"SANDBOX: $SANDBOX\"\necho \"==========================================\"\n\nbuild_dmg_for_arch() {\n  local arch=\"$1\"\n  local arch_app_dir=\"$APP_DIR\"\n  local arch_suffix=\"$arch\"\n  local dmg_name=\"codmate-${arch_suffix}.dmg\"\n  local dmg_path=\"$OUTPUT_DIR/$dmg_name\"\n  local stage_dir=\"$BUILD_DIR/.stage-dmg-${arch_suffix}\"\n  local bundle_name=\"CodMate.app\"\n\n  if [[ ${#ARCH_MATRIX[@]} -gt 1 ]]; then\n    arch_app_dir=\"$BUILD_DIR/CodMate-${arch_suffix}.app\"\n  fi\n\n  echo \"[build] Building app bundle for $arch_suffix\"\n  VER=\"$VER\" \\\n  BUILD_NUMBER=\"$BUILD_NUMBER\" \\\n  ARCH_MATRIX=\"$arch\" \\\n  APP_DIR=\"$arch_app_dir\" \\\n  BUILD_DIR=\"$BUILD_DIR\" \\\n  MIN_MACOS=\"$MIN_MACOS\" \\\n  BUNDLE_ID=\"$BUNDLE_ID\" \\\n  \"$ROOT_DIR/scripts/create-app-bundle.sh\"\n\n  if [[ ! -d \"$arch_app_dir\" ]]; then\n    echo \"[error] App bundle not found at $arch_app_dir\" >&2\n    exit 1\n  fi\n\n  if [[ -n \"$CODESIGN_IDENTITY\" ]]; then\n    echo \"[sign] Signing with: $CODESIGN_IDENTITY\"\n    xattr -cr \"$arch_app_dir\"\n\n    if [[ -f \"$arch_app_dir/Contents/Resources/bin/codmate-notify\" ]]; then\n      codesign --force --sign \"$CODESIGN_IDENTITY\" --options runtime --timestamp \\\n        ${ENTITLEMENTS_ARG[@]+\"${ENTITLEMENTS_ARG[@]}\"} \\\n        \"$arch_app_dir/Contents/Resources/bin/codmate-notify\"\n    fi\n\n    codesign --force --sign \"$CODESIGN_IDENTITY\" --options runtime --timestamp \\\n      ${ENTITLEMENTS_ARG[@]+\"${ENTITLEMENTS_ARG[@]}\"} \\\n      \"$arch_app_dir/Contents/MacOS/CodMate\"\n\n    codesign --force --sign \"$CODESIGN_IDENTITY\" --options runtime --timestamp \\\n      ${ENTITLEMENTS_ARG[@]+\"${ENTITLEMENTS_ARG[@]}\"} \\\n      \"$arch_app_dir\"\n\n    codesign --verify --deep --strict --verbose=2 \"$arch_app_dir\"\n  else\n    echo \"[warn] No signing identity found. Using ad-hoc signature.\"\n    codesign --force --deep --sign - \"$arch_app_dir\"\n  fi\n\n  rm -rf \"$stage_dir\"\n  mkdir -p \"$stage_dir\"\n  cp -R \"$arch_app_dir\" \"$stage_dir/$bundle_name\"\n  ln -s /Applications \"$stage_dir/Applications\"\n\n  if command -v create-dmg >/dev/null 2>&1; then\n    echo \"[dmg] Using create-dmg\"\n    if (cd \"$stage_dir\" && create-dmg \\\n      --volname \"$APP_NAME\" \\\n      --window-pos 200 120 \\\n      --window-size 600 400 \\\n      --icon-size 100 \\\n      --icon \"$bundle_name\" 175 120 \\\n      --hide-extension \"$bundle_name\" \\\n      --app-drop-link 425 120 \\\n      \"$dmg_path\" \\\n      \"$bundle_name\"); then\n      :\n    else\n      echo \"[warn] create-dmg failed; falling back to hdiutil\"\n      hdiutil create -volname \"$APP_NAME\" -srcfolder \"$stage_dir\" -ov -format UDZO -imagekey zlib-level=9 \"$dmg_path\"\n    fi\n  else\n    echo \"[dmg] Using hdiutil\"\n    hdiutil create -volname \"$APP_NAME\" -srcfolder \"$stage_dir\" -ov -format UDZO -imagekey zlib-level=9 \"$dmg_path\"\n  fi\n\n  rm -rf \"$stage_dir\"\n\n  if [[ ! -f \"$dmg_path\" ]]; then\n    echo \"[error] DMG not created: $dmg_path\" >&2\n    exit 1\n  fi\n\n  local notarized=0\n  case \"$NOTARY_MODE\" in\n    profile)\n      echo \"[notary] Submitting with profile ${APPLE_NOTARY_PROFILE:-}\"\n      notarized=1\n      xcrun notarytool submit \"$dmg_path\" --keychain-profile \"${APPLE_NOTARY_PROFILE:-}\" --wait\n      xcrun stapler staple \"$dmg_path\" || true\n      xcrun stapler staple \"$arch_app_dir\" || true\n      ;;\n    apple)\n      echo \"[notary] Submitting with Apple ID\"\n      notarized=1\n      xcrun notarytool submit \"$dmg_path\" \\\n        --apple-id \"${APPLE_ID:-}\" \\\n        --team-id \"$TEAM_ID\" \\\n        --password \"${APPLE_PASSWORD:-}\" \\\n        --wait\n      xcrun stapler staple \"$dmg_path\" || true\n      xcrun stapler staple \"$arch_app_dir\" || true\n      ;;\n    *)\n      echo \"[notary] Skipping notarization (credentials not provided)\"\n      ;;\n  esac\n\n  if [[ \"$notarized\" == \"1\" ]]; then\n    echo \"[verify] Validating notarization\"\n    xcrun stapler validate \"$dmg_path\"\n    xcrun stapler validate \"$arch_app_dir\"\n  fi\n\n  echo \"[ok] DMG ready: $dmg_path\"\n}\n\nif [[ ${#ARCH_MATRIX[@]} -eq 1 ]]; then\n  build_dmg_for_arch \"${ARCH_MATRIX[0]}\"\nelse\n  for arch in \"${ARCH_MATRIX[@]}\"; do\n    build_dmg_for_arch \"$arch\"\n  done\nfi\n"
  },
  {
    "path": "scripts/test-commands-sync.sh",
    "content": "#!/bin/bash\n\n# Test script for Commands management system\n# This script validates the commands sync functionality\n\nset -e\n\necho \"🧪 Testing Commands Management System\"\necho \"=======================================\"\necho \"\"\n\n# Colors for output\nGREEN='\\033[0;32m'\nBLUE='\\033[0;34m'\nYELLOW='\\033[1;33m'\nRED='\\033[0;31m'\nNC='\\033[0m' # No Color\n\n# Test directories\nCODMATE_DIR=\"$HOME/.codmate\"\nCLAUDE_DIR=\"$HOME/.claude/commands\"\nCODEX_DIR=\"$HOME/.codex/prompts\"\nGEMINI_DIR=\"$HOME/.gemini/commands\"\n\n# Step 1: Check configuration file\necho -e \"${BLUE}Step 1: Checking configuration file${NC}\"\nif [ -f \"$CODMATE_DIR/commands.json\" ]; then\n    echo -e \"${GREEN}✓ Configuration file exists${NC}\"\n    COMMAND_COUNT=$(cat \"$CODMATE_DIR/commands.json\" | grep -c '\"id\"' || echo \"0\")\n    echo -e \"  Found $COMMAND_COUNT commands\"\nelse\n    echo -e \"${RED}✗ Configuration file not found${NC}\"\n    exit 1\nfi\necho \"\"\n\n# Step 2: Verify JSON structure\necho -e \"${BLUE}Step 2: Validating JSON structure${NC}\"\nif command -v jq &> /dev/null; then\n    if jq empty \"$CODMATE_DIR/commands.json\" 2>/dev/null; then\n        echo -e \"${GREEN}✓ Valid JSON format${NC}\"\n    else\n        echo -e \"${RED}✗ Invalid JSON format${NC}\"\n        exit 1\n    fi\nelse\n    echo -e \"${YELLOW}⚠ jq not installed, skipping JSON validation${NC}\"\nfi\necho \"\"\n\n# Step 3: Check command fields\necho -e \"${BLUE}Step 3: Checking command fields${NC}\"\nif command -v jq &> /dev/null; then\n    # Check first command has required fields\n    FIRST_CMD=$(jq '.[0]' \"$CODMATE_DIR/commands.json\")\n    REQUIRED_FIELDS=(\"id\" \"name\" \"description\" \"prompt\" \"targets\" \"isEnabled\" \"source\" \"installedAt\")\n\n    for field in \"${REQUIRED_FIELDS[@]}\"; do\n        if echo \"$FIRST_CMD\" | jq -e \"has(\\\"$field\\\")\" > /dev/null; then\n            echo -e \"${GREEN}✓ Field '$field' present${NC}\"\n        else\n            echo -e \"${RED}✗ Field '$field' missing${NC}\"\n            exit 1\n        fi\n    done\nelse\n    echo -e \"${YELLOW}⚠ jq not installed, skipping field validation${NC}\"\nfi\necho \"\"\n\n# Step 4: Manual sync test (simulated)\necho -e \"${BLUE}Step 4: Testing sync directories${NC}\"\n\n# Create test directories\nmkdir -p \"$CLAUDE_DIR\"\nmkdir -p \"$CODEX_DIR\"\nmkdir -p \"$GEMINI_DIR\"\n\necho -e \"${GREEN}✓ Sync directories created/verified${NC}\"\necho \"  Claude Code: $CLAUDE_DIR\"\necho \"  Codex CLI:   $CODEX_DIR\"\necho \"  Gemini CLI:  $GEMINI_DIR\"\necho \"\"\n\n# Step 5: Check for existing synced commands (if any)\necho -e \"${BLUE}Step 5: Checking for synced commands${NC}\"\n\nCLAUDE_COUNT=$(find \"$CLAUDE_DIR\" -name \"*.md\" 2>/dev/null | wc -l | tr -d ' ')\nCODEX_COUNT=$(find \"$CODEX_DIR\" -name \"*.md\" 2>/dev/null | wc -l | tr -d ' ')\nGEMINI_COUNT=$(find \"$GEMINI_DIR\" -name \"*.toml\" 2>/dev/null | wc -l | tr -d ' ')\n\necho -e \"  Claude Code commands: $CLAUDE_COUNT .md files\"\necho -e \"  Codex CLI commands:   $CODEX_COUNT .md files\"\necho -e \"  Gemini CLI commands:  $GEMINI_COUNT .toml files\"\necho \"\"\n\n# Step 6: Display sample command\necho -e \"${BLUE}Step 6: Sample command preview${NC}\"\nif command -v jq &> /dev/null; then\n    echo \"First command:\"\n    jq '.[0] | {id, name, description, targets}' \"$CODMATE_DIR/commands.json\"\nelse\n    echo \"Install 'jq' to see command preview\"\nfi\necho \"\"\n\n# Step 7: Instructions\necho -e \"${BLUE}Step 7: Next steps${NC}\"\necho \"To enable automatic sync:\"\necho \"  1. Launch CodMate application\"\necho \"  2. Go to Settings → Extensions → Commands\"\necho \"  3. Click 'Sync Now' button\"\necho \"\"\necho \"To verify sync manually, check these directories:\"\necho \"  - $CLAUDE_DIR\"\necho \"  - $CODEX_DIR\"\necho \"  - $GEMINI_DIR\"\necho \"\"\n\n# Summary\necho -e \"${GREEN}✓ All basic tests passed!${NC}\"\necho \"\"\necho \"Commands system is ready for use.\"\necho \"Run CodMate and navigate to Settings → Extensions → Commands to manage your commands.\"\n"
  },
  {
    "path": "services/AppLogger.swift",
    "content": "import Foundation\nimport os.log\nimport OSLog\n\n/// Unified logging system that outputs to both console (for `make debug` mode)\n/// and the Status Bar UI for in-app visibility.\n@MainActor\nfinal class AppLogger {\n  static let shared = AppLogger()\n\n  private let subsystem = Bundle.main.bundleIdentifier ?? \"com.codmate\"\n  private var loggers: [String: Logger] = [:]\n\n  private init() {}\n\n  private func logger(for category: String) -> Logger {\n    if let existing = loggers[category] {\n      return existing\n    }\n    let logger = Logger(subsystem: subsystem, category: category)\n    loggers[category] = logger\n    return logger\n  }\n\n  // MARK: - Public API\n\n  func info(_ message: String, source: String? = nil) {\n    log(message, level: .info, source: source)\n  }\n\n  func success(_ message: String, source: String? = nil) {\n    log(message, level: .success, source: source)\n  }\n\n  func warning(_ message: String, source: String? = nil) {\n    log(message, level: .warning, source: source)\n  }\n\n  func error(_ message: String, source: String? = nil) {\n    log(message, level: .error, source: source)\n  }\n\n  func log(_ message: String, level: StatusBarLogLevel = .info, source: String? = nil) {\n    let category = source ?? \"App\"\n\n    // Output to console for `make debug` mode\n    let prefix: String\n    switch level {\n    case .info: prefix = \"ℹ️\"\n    case .success: prefix = \"✅\"\n    case .warning: prefix = \"⚠️\"\n    case .error: prefix = \"❌\"\n    }\n    let osLog = logger(for: category)\n    switch level {\n    case .info:\n      osLog.info(\"[\\(category)] \\(message)\")\n    case .success:\n      osLog.info(\"[\\(category)] \\(message)\")\n    case .warning:\n      osLog.warning(\"[\\(category)] \\(message)\")\n    case .error:\n      osLog.error(\"[\\(category)] \\(message)\")\n    }\n\n    // Also print to stderr for immediate visibility in debug console\n    #if DEBUG\n    NSLog(\"%@ [%@] %@\", prefix, category, message)\n    #endif\n\n    // Post to Status Bar for in-app visibility\n    StatusBarLogStore.shared.post(message, level: level, source: source)\n  }\n\n  // MARK: - Task tracking\n\n  func beginTask(_ message: String, source: String? = nil) -> String {\n    let category = source ?? \"App\"\n    #if DEBUG\n    NSLog(\"🔄 [%@] %@\", category, message)\n    #endif\n    logger(for: category).info(\"[\\(category)] \\(message)\")\n    return StatusBarLogStore.shared.beginTask(message, level: .info, source: source)\n  }\n\n  func endTask(_ token: String, message: String? = nil, level: StatusBarLogLevel = .success, source: String? = nil) {\n    if let message {\n      let category = source ?? \"App\"\n      let prefix: String\n      switch level {\n      case .info: prefix = \"ℹ️\"\n      case .success: prefix = \"✅\"\n      case .warning: prefix = \"⚠️\"\n      case .error: prefix = \"❌\"\n      }\n      #if DEBUG\n      NSLog(\"%@ [%@] %@\", prefix, category, message)\n      #endif\n    }\n    StatusBarLogStore.shared.endTask(token, message: message, level: level, source: source)\n  }\n}\n\n// MARK: - Convenience global functions\n\n@MainActor\nfunc logInfo(_ message: String, source: String? = nil) {\n  AppLogger.shared.info(message, source: source)\n}\n\n@MainActor\nfunc logSuccess(_ message: String, source: String? = nil) {\n  AppLogger.shared.success(message, source: source)\n}\n\n@MainActor\nfunc logWarning(_ message: String, source: String? = nil) {\n  AppLogger.shared.warning(message, source: source)\n}\n\n@MainActor\nfunc logError(_ message: String, source: String? = nil) {\n  AppLogger.shared.error(message, source: source)\n}\n"
  },
  {
    "path": "services/AuthorizationHub.swift",
    "content": "import AppKit\nimport Foundation\n\n/// Centralized authorization manager for security-scoped access.\n/// Wraps SecurityScopedBookmarks and provides consistent prompts for common operations.\n@MainActor\nfinal class AuthorizationHub {\n    static let shared = AuthorizationHub()\n\n    enum Purpose: String {\n        case gitReviewRepo = \"Git Review\"\n        case cliConsoleCwd = \"CLI Console Working Directory\"\n        case generalAccess  = \"File Access\"\n    }\n\n    private init() {}\n\n    var sandboxOn: Bool { SecurityScopedBookmarks.shared.isSandboxed }\n\n    /// Returns true if access can be started immediately without prompting (or sandbox is off).\n    /// When true, this also starts the security-scoped access session.\n    func canAccessNow(directory: URL) -> Bool {\n        guard sandboxOn else { return true }\n        return SecurityScopedBookmarks.shared.startAccessDynamic(for: directory)\n    }\n\n    /// Ensure access to a directory. If a dynamic bookmark exists, starts access and returns.\n    /// Otherwise prompts user to authorize the directory (or a parent) via NSOpenPanel.\n    ///\n    /// - Parameters:\n    ///   - directory: The target directory to access.\n    ///   - purpose:   A short label for the prompt UI.\n    ///   - message:   Optional message; a sensible default is shown when nil.\n    func ensureDirectoryAccessOrPrompt(directory: URL, purpose: Purpose, message: String? = nil) {\n        guard sandboxOn else { return } // Non-sandboxed builds don't need bookmarks\n        \n        // Try to start access with existing bookmark first\n        if SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) {\n            print(\"[AuthorizationHub] Successfully started access for: \\(directory.path)\")\n            return\n        }\n        \n        print(\"[AuthorizationHub] No existing bookmark for: \\(directory.path), prompting user...\")\n        \n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.directoryURL = directory\n        let defaultMsg = \"Authorize this folder for \\(purpose.rawValue)\"\n        panel.message = message ?? defaultMsg\n        panel.prompt = \"Authorize\"\n        \n        panel.begin { response in\n            if response == .OK, let url = panel.url {\n                print(\"[AuthorizationHub] User authorized: \\(url.path)\")\n                SecurityScopedBookmarks.shared.saveDynamic(url: url)\n                \n                // Immediately start accessing the authorized directory\n                let success = SecurityScopedBookmarks.shared.startAccessDynamic(for: url)\n                print(\"[AuthorizationHub] Start access after authorization: \\(success)\")\n                \n                // Also try to start access for the originally requested directory\n                // (in case user selected a parent directory)\n                if url.path != directory.path {\n                    let originalSuccess = SecurityScopedBookmarks.shared.startAccessDynamic(for: directory)\n                    print(\"[AuthorizationHub] Start access for original directory: \\(originalSuccess)\")\n                }\n                \n                NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil)\n            } else {\n                print(\"[AuthorizationHub] User cancelled authorization\")\n            }\n        }\n    }\n    \n    /// Request authorization and wait for result synchronously (blocks current thread)\n    /// Use this when you need to ensure access before proceeding\n    func ensureDirectoryAccessOrPromptSync(directory: URL, purpose: Purpose, message: String? = nil) -> Bool {\n        guard sandboxOn else { return true }\n        \n        // Try existing bookmark first\n        if SecurityScopedBookmarks.shared.startAccessDynamic(for: directory) {\n            return true\n        }\n        \n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.directoryURL = directory\n        let defaultMsg = \"Authorize this folder for \\(purpose.rawValue)\"\n        panel.message = message ?? defaultMsg\n        panel.prompt = \"Authorize\"\n        \n        let response = panel.runModal()\n        guard response == .OK, let url = panel.url else {\n            return false\n        }\n        \n        SecurityScopedBookmarks.shared.saveDynamic(url: url)\n        let success = SecurityScopedBookmarks.shared.startAccessDynamic(for: url)\n        \n        // Also try original directory if different\n        if url.path != directory.path {\n            _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: directory)\n        }\n        \n        NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil)\n        return success\n    }\n}\n"
  },
  {
    "path": "services/BrowserCookies/ChromeCookieImporter.swift",
    "content": "import CommonCrypto\nimport Foundation\nimport Security\nimport SQLite3\n\n/// Reads cookies from Chromium-based browsers' SQLite databases (Chrome, Brave, Edge, etc.)\n///\n/// Chrome stores cookie values in an SQLite DB, and most values are encrypted (`encrypted_value` starts\n/// with `v10` on macOS). Decryption uses the \"Chrome Safe Storage\" password from the macOS Keychain and\n/// AES-CBC + PBKDF2.\nenum ChromeCookieImporter {\n  private static let chromeSafeStorageKeyLock = NSLock()\n  private nonisolated(unsafe) static var cachedChromeSafeStorageKey: Data?\n\n  enum ImportError: LocalizedError {\n    case cookieDBNotFound(path: String)\n    case keychainDenied\n    case sqliteFailed(message: String)\n\n    var errorDescription: String? {\n      switch self {\n      case let .cookieDBNotFound(path): \"Chrome Cookies DB not found at \\(path).\"\n      case .keychainDenied: \"macOS Keychain denied access to Chrome Safe Storage.\"\n      case let .sqliteFailed(message): \"Failed to read Chrome cookies: \\(message)\"\n      }\n    }\n  }\n\n  /// Extracts Claude sessionKey from Chrome cookies\n  /// - Returns: sessionKey value if found, nil otherwise\n  /// - Throws: ImportError if cookie database cannot be read\n  static func extractClaudeSessionKey() throws -> String? {\n    let roots = candidateHomes().map { home in\n      home.appendingPathComponent(\"Library/Application Support/Google/Chrome\")\n    }\n\n    var candidates: [URL] = []\n    for root in roots {\n      candidates.append(contentsOf: chromeProfileCookieDBs(root: root).map(\\.cookiesDB))\n    }\n\n    if candidates.isEmpty {\n      let display = roots.map(\\.path).joined(separator: \" • \")\n      throw ImportError.cookieDBNotFound(path: display)\n    }\n\n    let chromeKey = try chromeSafeStorageKey()\n    for dbURL in candidates {\n      guard FileManager.default.fileExists(atPath: dbURL.path) else { continue }\n      let cookies = try readCookiesFromLockedChromeDB(\n        sourceDB: dbURL,\n        key: chromeKey,\n        matchingDomains: [\"claude.ai\"]\n      )\n      if let sessionKey = cookies.first(where: { $0.name == \"sessionKey\" })?.value {\n        return sessionKey\n      }\n    }\n\n    return nil\n  }\n\n  // MARK: - DB copy helper\n\n  private static func readCookiesFromLockedChromeDB(\n    sourceDB: URL,\n    key: Data,\n    matchingDomains: [String]\n  ) throws -> [CookieRecord] {\n    // Chrome keeps the DB locked; copy the DB (and wal/shm when present) to a temp folder before reading\n    let tempDir =\n      FileManager.default.temporaryDirectory\n      .appendingPathComponent(\n        \"codmate-chrome-cookies-\\(UUID().uuidString)\", isDirectory: true)\n    try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)\n\n    let copiedDB = tempDir.appendingPathComponent(\"Cookies\")\n    try FileManager.default.copyItem(at: sourceDB, to: copiedDB)\n\n    for suffix in [\"-wal\", \"-shm\"] {\n      let src = URL(fileURLWithPath: sourceDB.path + suffix)\n      if FileManager.default.fileExists(atPath: src.path) {\n        let dst = URL(fileURLWithPath: copiedDB.path + suffix)\n        try? FileManager.default.copyItem(at: src, to: dst)\n      }\n    }\n\n    defer { try? FileManager.default.removeItem(at: tempDir) }\n\n    return try readCookies(fromDB: copiedDB.path, key: key, matchingDomains: matchingDomains)\n  }\n\n  // MARK: - SQLite read\n\n  private static func readCookies(\n    fromDB path: String,\n    key: Data,\n    matchingDomains: [String]\n  ) throws -> [CookieRecord] {\n    var db: OpaquePointer?\n    if sqlite3_open_v2(path, &db, SQLITE_OPEN_READONLY, nil) != SQLITE_OK {\n      throw ImportError.sqliteFailed(message: String(cString: sqlite3_errmsg(db)))\n    }\n    defer { sqlite3_close(db) }\n\n    // Build WHERE clause dynamically for the given domains\n    let conditions = matchingDomains.map { \"host_key LIKE '%\\($0)%'\" }.joined(separator: \" OR \")\n    let sql = \"\"\"\n      SELECT host_key, name, path, expires_utc, is_secure, is_httponly, value, encrypted_value\n      FROM cookies\n      WHERE \\(conditions)\n      \"\"\"\n\n    var stmt: OpaquePointer?\n    if sqlite3_prepare_v2(db, sql, -1, &stmt, nil) != SQLITE_OK {\n      throw ImportError.sqliteFailed(message: String(cString: sqlite3_errmsg(db)))\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    var out: [CookieRecord] = []\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      let domain = String(cString: sqlite3_column_text(stmt, 0))\n      let name = String(cString: sqlite3_column_text(stmt, 1))\n      let path = String(cString: sqlite3_column_text(stmt, 2))\n      let expires = sqlite3_column_int64(stmt, 3)\n      let isSecure = sqlite3_column_int(stmt, 4) != 0\n      let isHTTPOnly = sqlite3_column_int(stmt, 5) != 0\n\n      let plain = readTextColumn(stmt, index: 6)\n      let enc = readBlobColumn(stmt, index: 7)\n\n      let value: String\n      if let plain, !plain.isEmpty {\n        value = plain\n      } else if let enc, !enc.isEmpty, let decrypted = decryptChromiumValue(enc, key: key) {\n        value = decrypted\n      } else {\n        continue\n      }\n\n      let normalizedDomain =\n        domain.hasPrefix(\".\") ? String(domain.dropFirst()) : domain\n\n      out.append(\n        CookieRecord(\n          domain: normalizedDomain,\n          name: name,\n          path: path,\n          value: value,\n          expires: Date(timeIntervalSince1970: TimeInterval(expires)),\n          isSecure: isSecure,\n          isHTTPOnly: isHTTPOnly\n        ))\n    }\n    return out\n  }\n\n  private static func readTextColumn(_ stmt: OpaquePointer?, index: Int32) -> String? {\n    guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil }\n    guard let c = sqlite3_column_text(stmt, index) else { return nil }\n    return String(cString: c)\n  }\n\n  private static func readBlobColumn(_ stmt: OpaquePointer?, index: Int32) -> Data? {\n    guard sqlite3_column_type(stmt, index) != SQLITE_NULL else { return nil }\n    guard let bytes = sqlite3_column_blob(stmt, index) else { return nil }\n    let count = Int(sqlite3_column_bytes(stmt, index))\n    return Data(bytes: bytes, count: count)\n  }\n\n  // MARK: - Keychain + PBKDF2\n\n  private static func chromeSafeStorageKey() throws -> Data {\n    chromeSafeStorageKeyLock.lock()\n    if let cached = cachedChromeSafeStorageKey {\n      chromeSafeStorageKeyLock.unlock()\n      return cached\n    }\n    chromeSafeStorageKeyLock.unlock()\n\n    // Prefer the main Chrome label; fall back to common Chromium forks\n    let labels: [(service: String, account: String)] = [\n      (\"Chrome Safe Storage\", \"Chrome\"),\n      (\"Chromium Safe Storage\", \"Chromium\"),\n      (\"Brave Safe Storage\", \"Brave\"),\n      (\"Microsoft Edge Safe Storage\", \"Microsoft Edge\"),\n      (\"Vivaldi Safe Storage\", \"Vivaldi\"),\n    ]\n\n    var password: String?\n    for label in labels {\n      if let p = findGenericPassword(service: label.service, account: label.account) {\n        password = p\n        break\n      }\n    }\n    guard let password else { throw ImportError.keychainDenied }\n\n    // Chromium macOS key derivation: PBKDF2-HMAC-SHA1 with salt \"saltysalt\", 1003 iterations, key length 16\n    let salt = Data(\"saltysalt\".utf8)\n    var key = Data(count: kCCKeySizeAES128)\n    let keyLength = key.count\n    let result = key.withUnsafeMutableBytes { keyBytes in\n      password.utf8CString.withUnsafeBytes { passBytes in\n        salt.withUnsafeBytes { saltBytes in\n          CCKeyDerivationPBKDF(\n            CCPBKDFAlgorithm(kCCPBKDF2),\n            passBytes.bindMemory(to: Int8.self).baseAddress,\n            passBytes.count - 1,\n            saltBytes.bindMemory(to: UInt8.self).baseAddress,\n            salt.count,\n            CCPseudoRandomAlgorithm(kCCPRFHmacAlgSHA1),\n            1003,\n            keyBytes.bindMemory(to: UInt8.self).baseAddress,\n            keyLength\n          )\n        }\n      }\n    }\n    guard result == kCCSuccess else {\n      throw ImportError.keychainDenied\n    }\n\n    chromeSafeStorageKeyLock.lock()\n    cachedChromeSafeStorageKey = key\n    chromeSafeStorageKeyLock.unlock()\n    return key\n  }\n\n  // Exposed for tests\n  static func decryptChromiumValue(_ encryptedValue: Data, key: Data) -> String? {\n    // macOS Chrome cookies typically have `v10` prefix and AES-CBC payload\n    guard encryptedValue.count > 3 else { return nil }\n    let prefix = encryptedValue.prefix(3)\n    let prefixString = String(data: prefix, encoding: .utf8)\n    let payload = encryptedValue.dropFirst(3)\n\n    guard prefixString == \"v10\" else {\n      return nil\n    }\n\n    let iv = Data(repeating: 0x20, count: kCCBlockSizeAES128)  // 16 spaces\n    var out = Data(count: payload.count + kCCBlockSizeAES128)\n    var outLength: size_t = 0\n    let outCapacity = out.count\n\n    let status = out.withUnsafeMutableBytes { outBytes in\n      payload.withUnsafeBytes { inBytes in\n        key.withUnsafeBytes { keyBytes in\n          iv.withUnsafeBytes { ivBytes in\n            CCCrypt(\n              CCOperation(kCCDecrypt),\n              CCAlgorithm(kCCAlgorithmAES),\n              CCOptions(kCCOptionPKCS7Padding),\n              keyBytes.baseAddress,\n              key.count,\n              ivBytes.baseAddress,\n              inBytes.baseAddress,\n              payload.count,\n              outBytes.baseAddress,\n              outCapacity,\n              &outLength\n            )\n          }\n        }\n      }\n    }\n    guard status == kCCSuccess else { return nil }\n    out.count = outLength\n\n    // Chromium's macOS cookie encryption prefixes 32 bytes of non-UTF8 data before the actual cookie value\n    let candidate = out.count > 32 ? out.dropFirst(32) : out[...]\n    if let decoded = String(data: Data(candidate), encoding: .utf8) {\n      return cleanValue(decoded)\n    }\n    if let decoded = String(data: out, encoding: .utf8) {\n      return cleanValue(decoded)\n    }\n    return nil\n  }\n\n  private static func cleanValue(_ value: String) -> String {\n    // Strip leading control chars\n    var i = value.startIndex\n    while i < value.endIndex, value[i].unicodeScalars.allSatisfy({ $0.value < 0x20 }) {\n      i = value.index(after: i)\n    }\n    return String(value[i...])\n  }\n\n  private static func findGenericPassword(service: String, account: String) -> String? {\n    let query: [CFString: Any] = [\n      kSecClass: kSecClassGenericPassword,\n      kSecAttrService: service,\n      kSecAttrAccount: account,\n      kSecMatchLimit: kSecMatchLimitOne,\n      kSecReturnData: true,\n    ]\n\n    var result: CFTypeRef?\n    let status = SecItemCopyMatching(query as CFDictionary, &result)\n    guard status == errSecSuccess else { return nil }\n    guard let data = result as? Data else { return nil }\n    return String(data: data, encoding: .utf8)\n  }\n\n  // MARK: - File paths\n\n  private struct ChromeProfileCandidate {\n    let label: String\n    let cookiesDB: URL\n  }\n\n  private static func chromeProfileCookieDBs(root: URL) -> [ChromeProfileCandidate] {\n    var out: [ChromeProfileCandidate] = []\n\n    // Default profile\n    let defaultDB = root.appendingPathComponent(\"Default/Cookies\")\n    if FileManager.default.fileExists(atPath: defaultDB.path) {\n      out.append(ChromeProfileCandidate(label: \"Default\", cookiesDB: defaultDB))\n    }\n\n    // Numbered profiles: Profile 1, Profile 2, etc.\n    for i in 1...10 {\n      let profileDB = root.appendingPathComponent(\"Profile \\(i)/Cookies\")\n      if FileManager.default.fileExists(atPath: profileDB.path) {\n        out.append(ChromeProfileCandidate(label: \"Profile \\(i)\", cookiesDB: profileDB))\n      }\n    }\n\n    return out\n  }\n\n  private static func candidateHomes() -> [URL] {\n    var homes: [URL] = []\n    homes.append(FileManager.default.homeDirectoryForCurrentUser)\n    if let userHome = NSHomeDirectoryForUser(NSUserName()) {\n      homes.append(URL(fileURLWithPath: userHome))\n    }\n    if let envHome = ProcessInfo.processInfo.environment[\"HOME\"], !envHome.isEmpty {\n      homes.append(URL(fileURLWithPath: envHome))\n    }\n    // De-dup by path while keeping ordering\n    var seen = Set<String>()\n    return homes.filter { home in\n      let path = home.path\n      guard !seen.contains(path) else { return false }\n      seen.insert(path)\n      return true\n    }\n  }\n}\n"
  },
  {
    "path": "services/BrowserCookies/CookieRecord.swift",
    "content": "import Foundation\n\n/// Represents a cookie record extracted from browser storage\nstruct CookieRecord: Sendable {\n  let domain: String\n  let name: String\n  let path: String\n  let value: String\n  let expires: Date?\n  let isSecure: Bool\n  let isHTTPOnly: Bool\n}\n"
  },
  {
    "path": "services/BrowserCookies/DataReader.swift",
    "content": "import Foundation\n\n/// Helper class for reading binary data with support for different endianness\nfinal class DataReader {\n  let data: Data\n  private(set) var offset: Int\n\n  init(_ data: Data, offset: Int = 0) {\n    self.data = data\n    self.offset = offset\n  }\n\n  /// Read count bytes as ASCII string\n  func readASCII(count: Int) -> String? {\n    let d = self.read(count)\n    return String(data: d, encoding: .ascii)\n  }\n\n  /// Read count bytes and advance offset\n  func read(_ count: Int) -> Data {\n    let end = min(self.offset + count, self.data.count)\n    let slice = self.data[self.offset..<end]\n    self.offset = end\n    return Data(slice)\n  }\n\n  /// Read UInt32 in Big-Endian format (used by Safari cookie file header)\n  func readUInt32BE() -> UInt32 {\n    let d = self.read(4)\n    return d.withUnsafeBytes { $0.load(as: UInt32.self).bigEndian }\n  }\n\n  /// Read UInt32 in Little-Endian format (used by Safari cookie pages/records)\n  func readUInt32LE() -> UInt32 {\n    let d = self.read(4)\n    return d.withUnsafeBytes { $0.load(as: UInt32.self).littleEndian }\n  }\n\n  /// Read Double in Little-Endian format (used for timestamp fields)\n  func readDoubleLE() -> Double {\n    let d = self.read(8)\n    let raw = d.withUnsafeBytes { $0.load(as: UInt64.self).littleEndian }\n    return Double(bitPattern: raw)\n  }\n}\n"
  },
  {
    "path": "services/BrowserCookies/SafariCookieImporter.swift",
    "content": "import Foundation\n\n/// Reads cookies from Safari's `Cookies.binarycookies` file (macOS).\n///\n/// This is a best-effort parser for the documented `binarycookies` format:\n/// file header is big-endian; cookie pages and records are little-endian.\nenum SafariCookieImporter {\n  enum ImportError: LocalizedError {\n    case cookieFileNotFound\n    case cookieFileNotReadable(path: String)\n    case invalidFile\n\n    var errorDescription: String? {\n      switch self {\n      case .cookieFileNotFound:\n        \"Safari cookie file not found.\"\n      case let .cookieFileNotReadable(path):\n        \"Safari cookie file exists but is not readable (\\(path)). CodMate needs Full Disk Access to read Safari cookies.\"\n      case .invalidFile:\n        \"Safari cookie file is invalid.\"\n      }\n    }\n  }\n\n  /// Extracts Claude sessionKey from Safari cookies\n  /// - Returns: sessionKey value if found, nil otherwise\n  /// - Throws: ImportError if cookie file cannot be read\n  static func extractClaudeSessionKey() throws -> String? {\n    let cookies = try loadCookies(matchingDomains: [\"claude.ai\"])\n    return cookies.first(where: { $0.name == \"sessionKey\" })?.value\n  }\n\n  /// Loads cookies from Safari matching the given domains\n  static func loadCookies(\n    matchingDomains domains: [String],\n    logger: ((String) -> Void)? = nil\n  ) throws -> [CookieRecord] {\n    let candidates = candidateCookieFiles()\n    var lastNoPermission: String?\n    var lastReadError: String?\n\n    for url in candidates {\n      do {\n        let size =\n          (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? NSNumber)?\n          .intValue\n        logger?(\"[SafariCookie] Trying \\(url.path) (\\(size ?? -1) bytes)\")\n        let data = try Data(contentsOf: url)\n        let records = try parseBinaryCookies(data: data)\n        return records.filter { record in\n          let d = record.domain.lowercased()\n          return domains.contains { d.contains($0.lowercased()) }\n        }\n      } catch let error as CocoaError where error.code == .fileReadNoPermission {\n        lastNoPermission = url.path\n        logger?(\"[SafariCookie] Permission denied for \\(url.path)\")\n        continue\n      } catch {\n        lastReadError = \"\\(url.path): \\(error.localizedDescription)\"\n        logger?(\"[SafariCookie] Failed to read \\(url.path): \\(error.localizedDescription)\")\n        continue\n      }\n    }\n\n    if let lastNoPermission {\n      throw ImportError.cookieFileNotReadable(path: lastNoPermission)\n    }\n    if let lastReadError {\n      logger?(\"[SafariCookie] Last error: \\(lastReadError)\")\n    }\n    throw ImportError.cookieFileNotFound\n  }\n\n  // MARK: - BinaryCookies parsing\n\n  private static func parseBinaryCookies(data: Data) throws -> [CookieRecord] {\n    let reader = DataReader(data)\n    guard reader.readASCII(count: 4) == \"cook\" else { throw ImportError.invalidFile }\n    let pageCount = Int(reader.readUInt32BE())\n    guard pageCount >= 0 else { throw ImportError.invalidFile }\n\n    var pageSizes: [Int] = []\n    pageSizes.reserveCapacity(pageCount)\n    for _ in 0..<pageCount {\n      pageSizes.append(Int(reader.readUInt32BE()))\n    }\n\n    var records: [CookieRecord] = []\n    var offset = reader.offset\n    for size in pageSizes {\n      guard offset + size <= data.count else { throw ImportError.invalidFile }\n      let pageData = data.subdata(in: offset..<(offset + size))\n      records.append(contentsOf: parsePage(data: pageData))\n      offset += size\n    }\n    return records\n  }\n\n  private static func parsePage(data: Data) -> [CookieRecord] {\n    let r = DataReader(data)\n    _ = r.readUInt32LE()  // page header\n    let cookieCount = Int(r.readUInt32LE())\n    if cookieCount <= 0 { return [] }\n\n    var cookieOffsets: [Int] = []\n    cookieOffsets.reserveCapacity(cookieCount)\n    for _ in 0..<cookieCount {\n      cookieOffsets.append(Int(r.readUInt32LE()))\n    }\n\n    return cookieOffsets.compactMap { offset in\n      guard offset >= 0, offset + 56 <= data.count else { return nil }\n      return parseCookieRecord(data: data, offset: offset)\n    }\n  }\n\n  private static func parseCookieRecord(data: Data, offset: Int) -> CookieRecord? {\n    let r = DataReader(data, offset: offset)\n    let size = Int(r.readUInt32LE())\n    guard size > 0, offset + size <= data.count else { return nil }\n\n    _ = r.readUInt32LE()  // unknown\n    let flags = r.readUInt32LE()\n    _ = r.readUInt32LE()  // unknown\n\n    let urlOffset = Int(r.readUInt32LE())\n    let nameOffset = Int(r.readUInt32LE())\n    let pathOffset = Int(r.readUInt32LE())\n    let valueOffset = Int(r.readUInt32LE())\n    _ = r.readUInt32LE()  // commentOffset\n    _ = r.readUInt32LE()  // commentURL\n\n    let expiresRef = r.readDoubleLE()\n    _ = r.readDoubleLE()  // creation\n\n    let domain = readCString(data: data, base: offset, offset: urlOffset) ?? \"\"\n    let name = readCString(data: data, base: offset, offset: nameOffset) ?? \"\"\n    let path = readCString(data: data, base: offset, offset: pathOffset) ?? \"/\"\n    let value = readCString(data: data, base: offset, offset: valueOffset) ?? \"\"\n\n    if domain.isEmpty || name.isEmpty { return nil }\n\n    let isSecure = (flags & 0x1) != 0\n    let isHTTPOnly = (flags & 0x4) != 0\n    let expires =\n      expiresRef > 0 ? Date(timeIntervalSinceReferenceDate: expiresRef) : nil\n\n    return CookieRecord(\n      domain: normalizeDomain(domain),\n      name: name,\n      path: path,\n      value: value,\n      expires: expires,\n      isSecure: isSecure,\n      isHTTPOnly: isHTTPOnly\n    )\n  }\n\n  private static func readCString(data: Data, base: Int, offset: Int) -> String? {\n    let start = base + offset\n    guard start >= 0, start < data.count else { return nil }\n    let end = data[start...].firstIndex(of: 0) ?? data.count\n    guard end > start else { return nil }\n    return String(data: data.subdata(in: start..<end), encoding: .utf8)\n  }\n\n  private static func normalizeDomain(_ raw: String) -> String {\n    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n    if trimmed.hasPrefix(\".\") { return String(trimmed.dropFirst()) }\n    return trimmed\n  }\n\n  // MARK: - File paths\n\n  private static func candidateCookieFiles() -> [URL] {\n    let homes = candidateHomes()\n    var urls: [URL] = []\n    urls.reserveCapacity(homes.count * 2)\n    for home in homes {\n      urls.append(home.appendingPathComponent(\"Library/Cookies/Cookies.binarycookies\"))\n      urls.append(\n        home.appendingPathComponent(\n          \"Library/Containers/com.apple.Safari/Data/Library/Cookies/Cookies.binarycookies\"))\n    }\n    // De-dup by path while keeping ordering (homeDirectoryForCurrentUser first)\n    var seen = Set<String>()\n    return urls.filter { url in\n      let path = url.path\n      guard !seen.contains(path) else { return false }\n      seen.insert(path)\n      return true\n    }\n  }\n\n  private static func candidateHomes() -> [URL] {\n    var homes: [URL] = []\n    homes.append(FileManager.default.homeDirectoryForCurrentUser)\n    if let userHome = NSHomeDirectoryForUser(NSUserName()) {\n      homes.append(URL(fileURLWithPath: userHome))\n    }\n    if let envHome = ProcessInfo.processInfo.environment[\"HOME\"], !envHome.isEmpty {\n      homes.append(URL(fileURLWithPath: envHome))\n    }\n    // De-dup by path while keeping ordering\n    var seen = Set<String>()\n    return homes.filter { home in\n      let path = home.path\n      guard !seen.contains(path) else { return false }\n      seen.insert(path)\n      return true\n    }\n  }\n}\n"
  },
  {
    "path": "services/CLIProxyBridge.swift",
    "content": "import Foundation\nimport Network\nimport SwiftUI\n\n/// A lightweight TCP proxy that forwards requests to CLIProxyAPI while\n/// ensuring fresh connections by forcing \"Connection: close\" on all requests.\n///\n/// Architecture:\n///   Client (Cursor/VSCode) → CLIProxyBridge (User Port) → CLIProxyAPI (Internal Port)\n@MainActor\nfinal class CLIProxyBridge: ObservableObject {\n    \n    // MARK: - Properties\n    \n    private var listener: NWListener? \n    private let stateQueue = DispatchQueue(label: \"io.codmate.proxy-bridge-state\")\n    \n    /// The port this proxy listens on (user-facing port)\n    /// IMPORTANT: This should NOT have a default value. Always call configure() before start().\n    @Published private(set) var listenPort: UInt16 = 0\n\n    /// The port CLIProxyAPI runs on (internal port)\n    /// IMPORTANT: This should NOT have a default value. Always call configure() before start().\n    @Published private(set) var targetPort: UInt16 = 0\n    \n    /// Target host (always localhost)\n    private let targetHost = \"127.0.0.1\"\n    \n    /// Whether the proxy bridge is currently running\n    @Published private(set) var isRunning = false\n    \n    /// Last error message\n    @Published private(set) var lastError: String?\n    \n    /// Statistics: total requests forwarded\n    @Published private(set) var totalRequests: Int = 0\n    \n    /// Statistics: active connections count\n    @Published private(set) var activeConnections: Int = 0\n    \n    // MARK: - Configuration\n    \n    /// Configure the proxy ports\n    func configure(listenPort: UInt16, targetPort: UInt16) {\n        self.listenPort = listenPort\n        self.targetPort = targetPort\n    }\n    \n    /// Calculate internal port from user port (offset by 10000)\n    static func internalPort(from userPort: UInt16) -> UInt16 {\n        let preferredPort = UInt32(userPort) + 10000\n        if preferredPort <= 65535 {\n            return UInt16(preferredPort)\n        }\n        // Fallback: use modular offset within high port range (49152-65535)\n        let highPortBase: UInt16 = 49152\n        let offset = userPort % 1000\n        return highPortBase + offset\n    }\n    \n    // MARK: - Lifecycle\n    \n    func start() {\n        guard !isRunning else { return }\n        lastError = nil\n        \n        do {\n            let parameters = NWParameters.tcp\n            parameters.allowLocalEndpointReuse = true\n            \n            guard let port = NWEndpoint.Port(rawValue: listenPort) else {\n                lastError = \"Invalid port: \\(listenPort)\"\n                return\n            }\n            \n            listener = try NWListener(using: parameters, on: port)\n            \n            listener?.stateUpdateHandler = { [weak self] state in\n                Task { @MainActor [weak self] in\n                    self?.handleListenerState(state)\n                }\n            }\n            \n            listener?.newConnectionHandler = { [weak self] connection in\n                Task { @MainActor [weak self] in\n                    self?.handleNewConnection(connection)\n                }\n            }\n            \n            listener?.start(queue: .global(qos: .userInitiated))\n            \n        } catch {\n            lastError = error.localizedDescription\n        }\n    }\n    \n    func stop() {\n        stateQueue.sync {\n            listener?.cancel()\n            listener = nil\n        }\n        isRunning = false\n    }\n    \n    // MARK: - State Handling\n    \n    private func handleListenerState(_ state: NWListener.State) {\n        switch state {\n        case .ready:\n            isRunning = true\n        case .failed(let error):\n            isRunning = false\n            lastError = error.localizedDescription\n        case .cancelled:\n            isRunning = false\n        default:\n            break\n        }\n    }\n    \n    // MARK: - Connection Handling\n    \n    private func handleNewConnection(_ connection: NWConnection) {\n        activeConnections += 1\n        totalRequests += 1\n        \n        let connectionId = totalRequests\n        let startTime = Date()\n        \n        connection.stateUpdateHandler = { [weak self] state in\n            if case .cancelled = state {\n                Task { @MainActor [weak self] in self?.activeConnections -= 1 }\n            } else if case .failed = state {\n                Task { @MainActor [weak self] in self?.activeConnections -= 1 }\n            }\n        }\n        \n        connection.start(queue: .global(qos: .userInitiated))\n        \n        receiveRequest(\n            from: connection,\n            connectionId: connectionId,\n            startTime: startTime,\n            accumulatedData: Data()\n        )\n    }\n    \n    // MARK: - Request Processing\n    \n    private nonisolated func receiveRequest(\n        from connection: NWConnection,\n        connectionId: Int,\n        startTime: Date,\n        accumulatedData: Data\n    ) {\n        connection.receive(minimumIncompleteLength: 1, maximumLength: 1048576) { [weak self] data, _, isComplete, error in\n            guard let self = self else { return }\n            \n            if error != nil {\n                connection.cancel()\n                return\n            }\n            \n            guard let data = data, !data.isEmpty else {\n                if isComplete { connection.cancel() }\n                return\n            }\n            \n            var newData = accumulatedData\n            newData.append(data)\n            \n            // Simple HTTP header parsing to find double CRLF\n            if let requestString = String(data: newData, encoding: .utf8),\n               let headerEndRange = requestString.range(of: \"\\r\\n\\r\\n\") {\n                \n                let headerEndIndex = requestString.distance(from: requestString.startIndex, to: headerEndRange.upperBound)\n                \n                // Content-Length check\n                let headerPart = String(requestString.prefix(headerEndIndex))\n                if let lenLine = headerPart.components(separatedBy: \"\\r\\n\").first(where: { $0.lowercased().hasPrefix(\"content-length:\") }),\n                   let lenVal = Int(lenLine.components(separatedBy: \":\")[1].trimmingCharacters(in: .whitespaces)) {\n                    \n                    let bodyLen = newData.count - headerEndIndex\n                    if bodyLen < lenVal {\n                        // Need more data\n                        self.receiveRequest(from: connection, connectionId: connectionId, startTime: startTime, accumulatedData: newData)\n                        return\n                    }\n                }\n                \n                self.processRequest(data: newData, connection: connection, connectionId: connectionId)\n                \n            } else if !isComplete {\n                self.receiveRequest(from: connection, connectionId: connectionId, startTime: startTime, accumulatedData: newData)\n            } else {\n                // Malformed or incomplete\n                self.processRequest(data: newData, connection: connection, connectionId: connectionId)\n            }\n        }\n    }\n    \n    private nonisolated func processRequest(data: Data, connection: NWConnection, connectionId: Int) {\n        guard let requestString = String(data: data, encoding: .utf8) else {\n            connection.cancel()\n            return\n        }\n        \n        let lines = requestString.components(separatedBy: \"\\r\\n\")\n        guard let requestLine = lines.first else {\n            connection.cancel()\n            return\n        }\n        \n        let parts = requestLine.components(separatedBy: \" \")\n        guard parts.count >= 3 else {\n            connection.cancel()\n            return\n        }\n        \n        let method = parts[0]\n        let path = parts[1]\n        let version = parts[2]\n        \n        // Parse Headers\n        var headers: [(String, String)] = []\n        for line in lines.dropFirst() {\n            if line.isEmpty { break }\n            guard let idx = line.firstIndex(of: \":\") else { continue }\n            let k = String(line[..<idx]).trimmingCharacters(in: .whitespaces)\n            let v = String(line[line.index(after: idx)...]).trimmingCharacters(in: .whitespaces)\n            headers.append((k, v))\n        }\n        \n        // Extract Body\n        var body = \"\"\n        if let bodyRange = requestString.range(of: \"\\r\\n\\r\\n\") {\n            body = String(requestString[bodyRange.upperBound...])\n        }\n        \n        Task { @MainActor [weak self] in\n            guard let self = self else { return }\n            let tPort = self.targetPort\n            let tHost = self.targetHost\n            \n            self.forwardRequest(\n                method: method,\n                path: path,\n                version: version,\n                headers: headers,\n                body: body,\n                originalConnection: connection,\n                targetPort: tPort,\n                targetHost: tHost\n            )\n        }\n    }\n    \n    private nonisolated func forwardRequest(\n        method: String,\n        path: String,\n        version: String,\n        headers: [(String, String)],\n        body: String,\n        originalConnection: NWConnection,\n        targetPort: UInt16,\n        targetHost: String\n    ) {\n        guard let port = NWEndpoint.Port(rawValue: targetPort) else {\n            originalConnection.cancel()\n            return\n        }\n        \n        let endpoint = NWEndpoint.hostPort(host: NWEndpoint.Host(targetHost), port: port)\n        let targetConnection = NWConnection(to: endpoint, using: .tcp)\n        \n        targetConnection.stateUpdateHandler = { state in\n            switch state {\n            case .ready:\n                var req = \"\\(method) \\(path) \\(version)\\r\\n\"\n                let skip = Set([\"connection\", \"content-length\", \"host\", \"transfer-encoding\"])\n                for (k, v) in headers {\n                    if !skip.contains(k.lowercased()) {\n                        req += \"\\(k): \\(v)\\r\\n\"\n                    }\n                }\n                \n                req += \"Host: \\(targetHost):\\(targetPort)\\r\\n\"\n                req += \"Connection: close\\r\\n\"\n                req += \"Content-Length: \\(body.utf8.count)\\r\\n\\r\\n\"\n                req += body\n                \n                if let d = req.data(using: .utf8) {\n                    targetConnection.send(content: d, completion: .contentProcessed { error in\n                        if error == nil {\n                            self.receiveResponse(from: targetConnection, to: originalConnection)\n                        } else {\n                            targetConnection.cancel()\n                            originalConnection.cancel()\n                        }\n                    })\n                }\n            case .failed, .cancelled:\n                // Only cancel if it's an error state or explicit cancel;\n                // .waiting etc. should just wait.\n                if case .failed = state {\n                    targetConnection.cancel()\n                    originalConnection.cancel()\n                } else if case .cancelled = state {\n                    originalConnection.cancel()\n                }\n            default: break\n            }\n        }\n        \n        targetConnection.start(queue: .global(qos: .userInitiated))\n    }\n    \n    private nonisolated func receiveResponse(from target: NWConnection, to source: NWConnection) {\n        target.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in\n            guard let self = self else { return }\n            \n            if error != nil {\n                target.cancel()\n                source.cancel()\n                return\n            }\n            \n            if let data = data, !data.isEmpty {\n                source.send(content: data, completion: .contentProcessed { error in\n                    if error != nil {\n                         // log?\n                    }\n                    if isComplete {\n                        target.cancel()\n                        source.send(content: nil, isComplete: true, completion: .contentProcessed({ _ in source.cancel() }))\n                    } else {\n                        self.receiveResponse(from: target, to: source)\n                    }\n                })\n            } else if isComplete {\n                target.cancel()\n                source.send(content: nil, isComplete: true, completion: .contentProcessed({ _ in source.cancel() }))\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "services/CLIProxyService.swift",
    "content": "import Foundation\nimport AppKit\nimport SwiftUI\n\n/// Manages the CLIProxyAPI binary process and configuration.\n/// Integrates the CLI Proxy capability into CodMate.\n@MainActor\nfinal class CLIProxyService: ObservableObject {\n    static let shared = CLIProxyService()\n\n    // MARK: - Properties\n\n    @Published var isRunning = false\n    @Published var isInstalling = false\n    @Published var installProgress: Double = 0\n    @Published var lastError: String?\n    @Published var loginPrompt: LoginPrompt?\n    @Published var binarySource: BinarySource = .none\n    @Published var detectedBinaryPath: String?\n    @Published var conflictWarning: String?\n\n    struct LoginPrompt: Identifiable, Equatable {\n        let id = UUID()\n        let provider: LocalAuthProvider\n        let message: String\n    }\n\n    struct OAuthAccount: Identifiable, Equatable, Hashable {\n        let id: String\n        let provider: LocalAuthProvider\n        let email: String?\n        let filename: String\n        let filePath: String\n    }\n\n    struct LocalModelList: Decodable {\n        let data: [LocalModel]\n    }\n\n    struct LocalModel: Codable, Hashable {\n        let id: String\n        let owned_by: String?\n        let provider: String?\n        let source: String?\n\n        enum CodingKeys: String, CodingKey {\n            case id\n            case owned_by\n            case provider\n            case source\n        }\n    }\n\n    enum BinarySource: String, Equatable {\n        case none\n        case homebrew\n        case codmate\n        case other\n    }\n\n    // Log streaming\n    @Published var logs: String = \"\"\n    private var outputPipe: Pipe?\n    private var errorPipe: Pipe?\n\n    var port: UInt16 {\n        let p = UserDefaults.standard.integer(forKey: \"codmate.localserver.port\")\n        return p > 0 ? UInt16(p) : Self.defaultPort\n    }\n\n    private var process: Process?\n    private var loginProcess: Process?\n    private var loginInputPipe: Pipe?\n    private var loginProvider: LocalAuthProvider?\n    private var loginCancellationRequested = false\n    private var openedLoginURL: URL?\n    private let proxyBridge = CLIProxyBridge()\n\n    // Paths\n    private let binaryPath: String\n    private let configPath: String\n    private let authDir: String\n    private let managementKey: String\n    private var brewCommandPath: String?\n\n    // Default port configuration (nonisolated because it's a constant that can be safely accessed from any context)\n    nonisolated static let defaultPort: UInt16 = 8317\n\n    private static let publicAPIKeyDefaultsKey = \"CLIProxyPublicAPIKey\"\n    private static let publicAPIKeyPrefix = \"cm\"\n    private static let publicAPIKeyLength = 36\n    private static let localModelsCacheKey = \"CLIProxyLocalModelsCache\"\n    private static let localModelsCacheTimestampKey = \"CLIProxyLocalModelsCacheTimestamp\"\n    private static let localModelsCacheTTL: TimeInterval = 300\n\n    private var cachedLocalModels: [LocalModel] = []\n    private var cachedLocalModelsTimestamp: Date?\n\n    // Cache for model -> provider name mapping (built from config.yaml)\n    // This compensates for CLIProxyAPI not setting provider field correctly\n    private var modelToProviderNameCache: [String: String] = [:]\n\n    // Constants\n    private static let githubRepo = \"router-for-me/CLIProxyAPIPlus\"\n    private static let binaryName = \"CLIProxyAPI\"\n\n    private var internalPort: UInt16 {\n        CLIProxyBridge.internalPort(from: port)\n    }\n\n    init() {\n        // Setup paths in Application Support (for binary only)\n        let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!\n        let codMateDir = appSupport.appendingPathComponent(\"CodMate\")\n        let binDir = codMateDir.appendingPathComponent(\"bin\", isDirectory: true)\n        try? FileManager.default.createDirectory(at: binDir, withIntermediateDirectories: true)\n\n        let homeDir = FileManager.default.homeDirectoryForCurrentUser\n        // Config and auth now live in ~/.codmate/cliproxyapi for easier management\n        let cliproxyapiDir = homeDir.appendingPathComponent(\".codmate/cliproxyapi\", isDirectory: true)\n        try? FileManager.default.createDirectory(at: cliproxyapiDir, withIntermediateDirectories: true)\n\n        self.binaryPath = binDir.appendingPathComponent(Self.binaryName).path\n        self.configPath = cliproxyapiDir.appendingPathComponent(\"config.yaml\").path\n        self.authDir = homeDir.appendingPathComponent(\".codmate/auth\").path\n\n        // Persistent Management Key\n        if let savedKey = UserDefaults.standard.string(forKey: \"CLIProxyManagementKey\") {\n            self.managementKey = savedKey\n        } else {\n            self.managementKey = UUID().uuidString\n            UserDefaults.standard.set(self.managementKey, forKey: \"CLIProxyManagementKey\")\n        }\n\n        try? FileManager.default.createDirectory(atPath: authDir, withIntermediateDirectories: true)\n        ensureConfigExists()\n\n        // Perform initial detection\n        performInitialDetection()\n    }\n\n    // MARK: - Binary Detection\n\n    private func performInitialDetection() {\n        let path = CLIEnvironment.resolvedPATHForCLI()\n\n        // First, check if CodMate's own installation exists\n        if FileManager.default.fileExists(atPath: binaryPath) {\n            detectedBinaryPath = binaryPath\n            binarySource = .codmate\n            appendLog(\"Using CodMate's built-in installation at: \\(binaryPath)\\n\")\n            return\n        }\n\n        // Then, detect cliproxyapi binary in PATH\n        guard let detectedPath = CLIEnvironment.resolveExecutablePath(\"cliproxyapi\", path: path) else {\n            binarySource = .none\n            detectedBinaryPath = nil\n            appendLog(\"No cliproxyapi binary detected in PATH or CodMate installation.\\n\")\n            return\n        }\n\n        // Verify the detected path actually exists\n        guard FileManager.default.fileExists(atPath: detectedPath) else {\n            // Path was found in PATH but file doesn't exist (likely uninstalled)\n            binarySource = .none\n            detectedBinaryPath = nil\n            appendLog(\"cliproxyapi found in PATH but file does not exist. Using CodMate installation path.\\n\")\n            return\n        }\n\n        detectedBinaryPath = detectedPath\n        appendLog(\"Detected cliproxyapi at: \\(detectedPath)\\n\")\n\n        // Check if it's a Homebrew installation\n        if isHomebrewPath(detectedPath) {\n            // Check if brew command is available\n            if let brewPath = detectBrewCommand() {\n                brewCommandPath = brewPath\n                binarySource = .homebrew\n                appendLog(\"Homebrew installation detected. Using brew services for management.\\n\")\n            } else {\n                // Path matches Homebrew but brew command not found\n                binarySource = .other\n                conflictWarning = \"cliproxyapi found at Homebrew path but brew command not available. Please install Homebrew or use CodMate's built-in management.\"\n                appendLog(\"Warning: Homebrew path detected but brew command not found.\\n\", isError: true)\n            }\n        } else {\n            // Other installation path\n            binarySource = .other\n            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.\"\n            appendLog(\"Warning: Non-standard installation path detected. Potential conflicts may occur.\\n\", isError: true)\n\n            // Check for port conflicts\n            checkPortConflicts()\n        }\n    }\n\n    private func isHomebrewPath(_ path: String) -> Bool {\n        path == \"/opt/homebrew/bin/cliproxyapi\" ||\n        path == \"/usr/local/bin/cliproxyapi\"\n    }\n\n    private func detectBrewCommand() -> String? {\n        let path = CLIEnvironment.resolvedPATHForCLI()\n        return CLIEnvironment.resolveExecutablePath(\"brew\", path: path)\n    }\n\n    private func checkPortConflicts() {\n        // Check if ports are in use\n        let portsToCheck = [port, internalPort]\n        var conflicts: [UInt16] = []\n\n        for portToCheck in portsToCheck {\n            let task = Process()\n            task.executableURL = URL(fileURLWithPath: \"/usr/sbin/lsof\")\n            task.arguments = [\"-ti\", \"tcp:\\(portToCheck)\"]\n\n            let pipe = Pipe()\n            task.standardOutput = pipe\n\n            try? task.run()\n            task.waitUntilExit()\n\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            if let output = String(data: data, encoding: .utf8),\n               !output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                conflicts.append(portToCheck)\n            }\n        }\n\n        if !conflicts.isEmpty {\n            let portsStr = conflicts.map { String($0) }.joined(separator: \", \")\n            appendLog(\"Warning: Port(s) \\(portsStr) may be in use by another process.\\n\", isError: true)\n        }\n    }\n\n    var resolvedBinaryPath: String {\n        switch binarySource {\n        case .homebrew, .other:\n            // If detected path exists, use it; otherwise fall back to CodMate path\n            if let detected = detectedBinaryPath, FileManager.default.fileExists(atPath: detected) {\n                return detected\n            }\n            return binaryPath\n        case .codmate:\n            return binaryPath\n        case .none:\n            return binaryPath\n        }\n    }\n\n    var isBinaryInstalled: Bool {\n        switch binarySource {\n        case .homebrew, .other:\n            // Verify the detected path actually exists\n            if let detected = detectedBinaryPath {\n                return FileManager.default.fileExists(atPath: detected)\n            }\n            return false\n        case .codmate:\n            return FileManager.default.fileExists(atPath: binaryPath)\n        case .none:\n            // Even if source is none, check if CodMate has installed it\n            return FileManager.default.fileExists(atPath: binaryPath)\n        }\n    }\n\n    // MARK: - Process Management\n\n    func start() async throws {\n        guard isBinaryInstalled else {\n            appendLog(\"Binary not found. Please install it first.\\n\", isError: true)\n            throw ServiceError.binaryNotFound\n        }\n\n        guard !isRunning else {\n            appendLog(\"Service is already running.\\n\")\n            return\n        }\n\n        lastError = nil\n\n        // Sync third-party providers only on initial startup (when config doesn't exist)\n        // During restart, we rely on the existing config that was already synced\n        if !FileManager.default.fileExists(atPath: configPath) {\n            let enabledProviderIds = loadEnabledAPIKeyProviders()\n            await syncThirdPartyProviders(enabledProviderIds: enabledProviderIds)\n        }\n\n        // Use Homebrew services if Homebrew installation detected\n        if binarySource == .homebrew, brewCommandPath != nil {\n            try await brewServicesStart()\n            return\n        }\n\n        // Cleanup old processes\n        cleanupOrphanProcesses()\n\n        // Update config with correct internal port (since we use bridge mode)\n        updateConfigPort(internalPort)\n\n        // --- Diagnostic Section ---\n        let execPath = resolvedBinaryPath\n        appendLog(\"Inspecting binary at \\(execPath)...\\n\")\n        let fileOutput = runShell(command: \"/usr/bin/file\", args: [execPath])\n        appendLog(\"-> File type: \\(fileOutput.trimmingCharacters(in: .whitespacesAndNewlines))\\n\")\n\n        let lsOutput = runShell(command: \"/bin/ls\", args: [\"-l\", execPath])\n        appendLog(\"-> Permissions: \\(lsOutput.trimmingCharacters(in: .whitespacesAndNewlines))\\n\")\n        // --- End Diagnostic Section ---\n\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: execPath)\n\n        // Use CodMate's config path for non-Homebrew installations\n        process.arguments = [\"-config\", configPath]\n        process.currentDirectoryURL = URL(fileURLWithPath: execPath).deletingLastPathComponent()\n\n        // Environment\n        var env = ProcessInfo.processInfo.environment\n        env[\"TERM\"] = \"xterm-256color\"\n        process.environment = env\n\n        // Log Capture\n        let out = Pipe()\n        let err = Pipe()\n        process.standardOutput = out\n        process.standardError = err\n        self.outputPipe = out\n        self.errorPipe = err\n\n        out.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str) }\n            }\n        }\n        err.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str, isError: true) }\n            }\n        }\n\n        process.terminationHandler = { [weak self] terminatedProcess in\n            Task { @MainActor in\n                self?.isRunning = false\n                self?.process = nil\n                self?.proxyBridge.stop()\n                self?.outputPipe?.fileHandleForReading.readabilityHandler = nil\n                self?.errorPipe?.fileHandleForReading.readabilityHandler = nil\n\n                let reason: String\n                switch terminatedProcess.terminationReason {\n                case .exit:\n                    reason = \"Exited with code \\(terminatedProcess.terminationStatus)\"\n                case .uncaughtSignal:\n                    reason = \"Terminated by signal \\(terminatedProcess.terminationStatus)\"\n                @unknown default:\n                    reason = \"Unknown reason\"\n                }\n                self?.appendLog(\"Service stopped. \\(reason)\\n\", isError: terminatedProcess.terminationStatus != 0)\n            }\n        }\n\n        do {\n            appendLog(\"Starting Local AI Server on port \\(internalPort)...\\n\")\n            try process.run()\n            self.process = process\n\n            // Wait for startup\n            try await Task.sleep(nanoseconds: 1_500_000_000)\n\n            guard process.isRunning else {\n                let reason: String\n                switch process.terminationReason {\n                case .exit:\n                    reason = \"Exited with code \\(process.terminationStatus)\"\n                case .uncaughtSignal:\n                    reason = \"Terminated by signal \\(process.terminationStatus)\"\n                @unknown default:\n                    reason = \"Unknown reason\"\n                }\n                let errText = \"Process failed to stay running. \\(reason).\"\n                appendLog(errText + \"\\n\", isError: true)\n                throw ServiceError.startupFailed\n            }\n\n            // Start Proxy Bridge\n            proxyBridge.configure(listenPort: port, targetPort: internalPort)\n            proxyBridge.start()\n\n            // Wait for bridge\n            try await Task.sleep(nanoseconds: 500_000_000)\n\n            if !proxyBridge.isRunning {\n                process.terminate()\n                appendLog(\"Proxy bridge failed to start.\\n\", isError: true)\n                throw ServiceError.startupFailed\n            }\n\n            isRunning = true\n            appendLog(\"Service started successfully.\\n\")\n\n        } catch {\n            lastError = error.localizedDescription\n            appendLog(\"Error starting service: \\(error.localizedDescription)\\n\", isError: true)\n            throw error\n        }\n    }\n\n    func stop() {\n        // Use Homebrew services if Homebrew installation detected\n        if binarySource == .homebrew, brewCommandPath != nil {\n            brewServicesStop()\n            return\n        }\n\n        proxyBridge.stop()\n\n        if let p = process, p.isRunning {\n            p.terminate()\n        }\n        process = nil\n\n        cleanupOrphanProcesses()\n        isRunning = false\n    }\n\n    func clearLogs() {\n        logs = \"\"\n    }\n\n    private func appendLog(_ text: String, isError: Bool = false) {\n        // Keep last 50k characters to avoid memory issues\n        if logs.count > 50000 {\n            logs = String(logs.suffix(40000))\n        }\n        let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)\n        logs.append(\"[\\(timestamp)] \\(text)\")\n\n        // Also output to AppLogger for better visibility in debug mode\n        let trimmedText = text.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmedText.isEmpty {\n            if isError {\n                AppLogger.shared.error(trimmedText, source: \"CLIProxyService\")\n            } else {\n                AppLogger.shared.info(trimmedText, source: \"CLIProxyService\")\n            }\n        }\n    }\n\n    // MARK: - Installation\n\n    var binaryFilePath: String {\n        resolvedBinaryPath\n    }\n\n    func install() async throws {\n        isInstalling = true\n        installProgress = 0\n        defer { isInstalling = false }\n\n        do {\n            appendLog(\"Fetching latest release...\\n\")\n            installProgress = 0.1\n            let release = try await fetchLatestRelease()\n            guard let asset = findCompatibleAsset(in: release) else {\n                throw ServiceError.noCompatibleBinary\n            }\n\n            appendLog(\"Downloading binary...\\n\")\n            installProgress = 0.3\n            let data = try await downloadAsset(url: asset.downloadURL)\n            installProgress = 0.6\n\n            appendLog(\"Extracting and installing...\\n\")\n            installProgress = 0.7\n            try await extractAndInstall(data: data, assetName: asset.name)\n            installProgress = 0.9\n\n            // Re-detect after installation\n            appendLog(\"Verifying installation...\\n\")\n            performInitialDetection()\n            installProgress = 1.0\n\n            appendLog(\"Installation completed successfully.\\n\")\n        } catch {\n            lastError = error.localizedDescription\n            appendLog(\"Installation failed: \\(error.localizedDescription)\\n\", isError: true)\n            throw error\n        }\n    }\n\n    func login(provider: LocalAuthProvider) async throws {\n        guard isBinaryInstalled else {\n            appendLog(\"Binary not found. Please install it first.\\n\", isError: true)\n            throw ServiceError.binaryNotFound\n        }\n\n        openedLoginURL = nil\n\n        // Qwen: Skip CLI --no-browser mode, use management OAuth directly\n        // The CLI device code flow has reliability issues with browser callback detection\n        if provider == .qwen {\n            appendLog(\"Starting \\(provider.displayName) login via management API...\\n\")\n            try await loginViaManagement(provider: provider)\n            return\n        }\n\n        let flag = provider.loginFlag\n\n        // Hide existing auth files to force a new login flow\n        let hiddenFiles = hideAuthFiles(for: provider)\n        defer {\n            restoreAuthFiles(hiddenFiles)\n        }\n\n        appendLog(\"Starting \\(provider.displayName) login...\\n\")\n        do {\n            try await withTaskCancellationHandler {\n                try await runCLI(arguments: [\"-config\", configPath, flag, \"-incognito\"], loginProvider: provider)\n            } onCancel: {\n                Task { @MainActor in\n                    self.cancelLogin()\n                }\n            }\n            appendLog(\"\\(provider.displayName) login finished.\\n\")\n        } catch is CancellationError {\n            appendLog(\"\\(provider.displayName) login cancelled.\\n\")\n            throw CancellationError()\n        }\n    }\n\n    private func hideAuthFiles(for provider: LocalAuthProvider) -> [URL] {\n        let fm = FileManager.default\n        guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return [] }\n        var hidden: [URL] = []\n        let aliases = provider.authAliases.map { $0.lowercased() }\n\n        for name in items {\n            guard name.hasSuffix(\".json\") else { continue }\n            let url = URL(fileURLWithPath: authDir).appendingPathComponent(name)\n\n            // Check if this file belongs to the provider\n            var belongsToProvider = false\n            if aliases.contains(where: { name.lowercased().contains($0) }) {\n                belongsToProvider = true\n            } else if let data = try? Data(contentsOf: url),\n                      let text = String(data: data, encoding: .utf8) {\n                let lower = text.lowercased()\n                let patterns = [\n                    \"\\\"type\\\":\\\"\\(provider)\\\"\",\n                    \"\\\"type\\\": \\\"\\(provider)\\\"\",\n                    \"\\\"provider\\\":\\\"\\(provider)\\\"\",\n                    \"\\\"provider\\\": \\\"\\(provider)\\\"\"\n                ]\n                if patterns.contains(where: { lower.contains($0) }) {\n                    belongsToProvider = true\n                }\n            }\n\n            if belongsToProvider {\n                let backupURL = url.appendingPathExtension(\"bak\")\n                do {\n                    try fm.moveItem(at: url, to: backupURL)\n                    hidden.append(backupURL)\n                } catch {\n                    appendLog(\"Failed to hide auth file \\(name): \\(error.localizedDescription)\\n\", isError: true)\n                }\n            }\n        }\n        return hidden\n    }\n\n    private func restoreAuthFiles(_ backups: [URL]) {\n        let fm = FileManager.default\n        for backupURL in backups {\n            let originalURL = backupURL.deletingPathExtension()\n\n            // If the original file exists (meaning a new one was created with the same name),\n            // we assume the new one is the latest valid session for that account, so we discard the backup.\n            if fm.fileExists(atPath: originalURL.path) {\n                try? fm.removeItem(at: backupURL)\n            } else {\n                // Otherwise, restore the old file (different account)\n                do {\n                    try fm.moveItem(at: backupURL, to: originalURL)\n                } catch {\n                    appendLog(\"Failed to restore auth file \\(originalURL.lastPathComponent): \\(error.localizedDescription)\\n\", isError: true)\n                }\n            }\n        }\n    }\n\n    func cancelLogin() {\n        loginCancellationRequested = true\n        if let process = loginProcess, process.isRunning {\n            process.terminate()\n        }\n        loginPrompt = nil\n        openedLoginURL = nil\n    }\n\n    func logout(provider: LocalAuthProvider) {\n        let accounts = listOAuthAccounts().filter { $0.provider == provider }\n        let fm = FileManager.default\n        var removed = 0\n        for account in accounts {\n            try? fm.removeItem(atPath: account.filePath)\n            removed += 1\n        }\n        if removed > 0 {\n            appendLog(\"Removed \\(removed) \\(provider.displayName) credential file(s).\\n\")\n        }\n    }\n\n    func deleteOAuthAccount(_ account: OAuthAccount) {\n        let fm = FileManager.default\n        do {\n            try fm.removeItem(atPath: account.filePath)\n            appendLog(\"Removed credential file for \\(account.provider.displayName) (\\(account.email ?? \"unknown\")).\\n\")\n        } catch {\n            appendLog(\"Failed to delete credential file: \\(error.localizedDescription)\\n\", isError: true)\n        }\n    }\n\n    func listOAuthAccounts() -> [OAuthAccount] {\n        let fm = FileManager.default\n        guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return [] }\n        var accounts: [OAuthAccount] = []\n\n        for name in items {\n            guard name.hasSuffix(\".json\") else { continue }\n            let path = (authDir as NSString).appendingPathComponent(name)\n            guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)),\n                  let text = String(data: data, encoding: .utf8) else { continue }\n\n            // Identify provider\n            var identifiedProvider: LocalAuthProvider?\n            for provider in LocalAuthProvider.allCases {\n                let aliases = provider.authAliases.map { $0.lowercased() }\n\n                // Check filename first\n                if aliases.contains(where: { name.lowercased().contains($0) }) {\n                    identifiedProvider = provider\n                    break\n                }\n\n                // Check content\n                let patterns = [\n                    \"\\\"type\\\":\\\"\\(provider)\\\"\",\n                    \"\\\"type\\\": \\\"\\(provider)\\\"\",\n                    \"\\\"provider\\\":\\\"\\(provider)\\\"\",\n                    \"\\\"provider\\\": \\\"\\(provider)\\\"\"\n                ]\n                if patterns.contains(where: { text.lowercased().contains($0) }) {\n                    identifiedProvider = provider\n                    break\n                }\n            }\n\n            guard let provider = identifiedProvider else { continue }\n\n            // Extract email/account info\n            var email: String?\n            if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {\n                email = json[\"email\"] as? String\n                    ?? json[\"user_email\"] as? String\n                    ?? json[\"account\"] as? String\n                    ?? json[\"user\"] as? String\n                    ?? json[\"nickname\"] as? String\n                    ?? json[\"name\"] as? String\n            }\n\n            accounts.append(OAuthAccount(\n                id: name, // Use filename as ID\n                provider: provider,\n                email: email,\n                filename: name,\n                filePath: path\n            ))\n        }\n\n        return accounts\n    }\n\n    func hasAuthToken(for provider: LocalAuthProvider) -> Bool {\n        let fm = FileManager.default\n        guard let items = try? fm.contentsOfDirectory(atPath: authDir) else { return false }\n        let normalized = items.map { $0.lowercased() }\n        let aliases = provider.authAliases.map { $0.lowercased() }\n        for (idx, name) in normalized.enumerated() {\n            guard name.hasSuffix(\".json\") else { continue }\n            if aliases.contains(where: { name.contains($0) }) { return true }\n            let original = items[idx]\n            let path = (authDir as NSString).appendingPathComponent(original)\n            if fileContainsProviderType(path: path, providers: aliases) {\n                return true\n            }\n        }\n        return false\n    }\n\n    private func fileContainsProviderType(path: String, providers: [String]) -> Bool {\n        guard let data = try? Data(contentsOf: URL(fileURLWithPath: path)) else { return false }\n        guard let text = String(data: data, encoding: .utf8) else { return false }\n        let lower = text.lowercased()\n        for provider in providers {\n            let patterns = [\n                \"\\\"type\\\":\\\"\\(provider)\\\"\",\n                \"\\\"type\\\": \\\"\\(provider)\\\"\",\n                \"\\\"provider\\\":\\\"\\(provider)\\\"\",\n                \"\\\"provider\\\": \\\"\\(provider)\\\"\"\n            ]\n            if patterns.contains(where: { lower.contains($0) }) {\n                return true\n            }\n        }\n        return false\n    }\n\n    func submitLoginInput(_ input: String) {\n        guard let pipe = loginInputPipe else { return }\n        let payload = input.hasSuffix(\"\\n\") ? input : (input + \"\\n\")\n        if let data = payload.data(using: .utf8) {\n            pipe.fileHandleForWriting.write(data)\n        }\n    }\n\n    func loadPublicAPIKey() -> String? {\n        guard let content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return nil }\n        var inKeys = false\n        for line in content.components(separatedBy: .newlines) {\n            let trimmed = line.trimmingCharacters(in: .whitespaces)\n            if trimmed.hasPrefix(\"api-keys:\") {\n                inKeys = true\n                continue\n            }\n            if inKeys {\n                if trimmed.hasPrefix(\"-\") {\n                    var value = trimmed\n                    if let range = value.range(of: \"-\") {\n                        value = String(value[range.upperBound...]).trimmingCharacters(in: .whitespaces)\n                    }\n                    if value.hasPrefix(\"\\\"\") && value.hasSuffix(\"\\\"\") {\n                        value.removeFirst()\n                        value.removeLast()\n                    }\n                    let trimmed = value.trimmingCharacters(in: .whitespaces)\n                    if !trimmed.isEmpty {\n                        persistPublicAPIKey(trimmed)\n                        return trimmed\n                    }\n                    return nil\n                }\n                if !trimmed.isEmpty {\n                    inKeys = false\n                }\n            }\n        }\n        if let stored = UserDefaults.standard.string(forKey: Self.publicAPIKeyDefaultsKey),\n           !stored.isEmpty\n        {\n            return stored\n        }\n        return nil\n    }\n\n    func resolvePublicAPIKey() -> String {\n        if let key = loadPublicAPIKey(), !key.isEmpty {\n            return key\n        }\n        if let stored = UserDefaults.standard.string(forKey: Self.publicAPIKeyDefaultsKey),\n           !stored.isEmpty\n        {\n            return stored\n        }\n        let generated = generatePublicAPIKey(length: Self.publicAPIKeyLength)\n        persistPublicAPIKey(generated)\n        return generated\n    }\n\n    func fetchLocalModels(forceRefresh: Bool = false) async -> [LocalModel] {\n        guard isRunning else { return [] }\n        if !forceRefresh, let cached = validCachedModels() {\n            return cached\n        }\n        let fallback = loadAnyCachedModels()\n\n        guard let url = URL(string: \"http://127.0.0.1:\\(port)/v1/models\") else {\n            return fallback ?? []\n        }\n        var request = URLRequest(url: url)\n        if let key = loadPublicAPIKey(), !key.isEmpty {\n            let bearer = key.hasPrefix(\"Bearer \") ? key : \"Bearer \\(key)\"\n            request.setValue(bearer, forHTTPHeaderField: \"Authorization\")\n        }\n        do {\n            let (data, response) = try await URLSession.shared.data(for: request)\n            guard (response as? HTTPURLResponse)?.statusCode == 200 else {\n                return fallback ?? []\n            }\n            let models = (try? JSONDecoder().decode(LocalModelList.self, from: data))?.data ?? []\n            persistCachedModels(models)\n            return models\n        } catch {\n            return fallback ?? []\n        }\n    }\n\n    /// Get cached provider name for a model ID (from config.yaml mapping)\n    /// This compensates for CLIProxyAPI not setting provider field correctly\n    func getProviderName(for modelId: String) -> String? {\n        return modelToProviderNameCache[modelId]\n    }\n\n    private func validCachedModels() -> [LocalModel]? {\n        if isCacheValid(cachedLocalModelsTimestamp), !cachedLocalModels.isEmpty {\n            return cachedLocalModels\n        }\n        let persisted = loadCachedModelsFromDefaults()\n        if isCacheValid(persisted.timestamp), !persisted.models.isEmpty {\n            cachedLocalModels = persisted.models\n            cachedLocalModelsTimestamp = persisted.timestamp\n            return persisted.models\n        }\n        return nil\n    }\n\n    private func loadAnyCachedModels() -> [LocalModel]? {\n        if !cachedLocalModels.isEmpty {\n            return cachedLocalModels\n        }\n        let persisted = loadCachedModelsFromDefaults()\n        if !persisted.models.isEmpty {\n            cachedLocalModels = persisted.models\n            cachedLocalModelsTimestamp = persisted.timestamp\n            return persisted.models\n        }\n        return nil\n    }\n\n    private func isCacheValid(_ timestamp: Date?) -> Bool {\n        guard let timestamp else { return false }\n        return Date().timeIntervalSince(timestamp) < Self.localModelsCacheTTL\n    }\n\n    private func loadCachedModelsFromDefaults() -> (models: [LocalModel], timestamp: Date?) {\n        let defaults = UserDefaults.standard\n        guard let data = defaults.data(forKey: Self.localModelsCacheKey) else {\n            return ([], nil)\n        }\n        let models = (try? JSONDecoder().decode([LocalModel].self, from: data)) ?? []\n        let timestamp = defaults.object(forKey: Self.localModelsCacheTimestampKey) as? Date\n        return (models, timestamp)\n    }\n\n    private func persistCachedModels(_ models: [LocalModel]) {\n        cachedLocalModels = models\n        cachedLocalModelsTimestamp = Date()\n        let defaults = UserDefaults.standard\n        if let data = try? JSONEncoder().encode(models) {\n            defaults.set(data, forKey: Self.localModelsCacheKey)\n        }\n        defaults.set(cachedLocalModelsTimestamp, forKey: Self.localModelsCacheTimestampKey)\n    }\n\n    func updatePublicAPIKey(_ key: String) {\n        guard FileManager.default.fileExists(atPath: configPath),\n              var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return }\n\n        let lines = content.components(separatedBy: .newlines)\n        var out: [String] = []\n        var inKeys = false\n        var replaced = false\n\n        for line in lines {\n            let trimmed = line.trimmingCharacters(in: .whitespaces)\n            if trimmed.hasPrefix(\"api-keys:\") {\n                inKeys = true\n                out.append(line)\n                continue\n            }\n            if inKeys {\n                if trimmed.hasPrefix(\"-\") {\n                    if !replaced {\n                        let indent = line.prefix { $0 == \" \" || $0 == \"\\t\" }\n                        out.append(\"\\(indent)- \\\"\\(key)\\\"\")\n                        replaced = true\n                    } else {\n                        out.append(line)\n                    }\n                    continue\n                }\n                if !trimmed.isEmpty {\n                    if !replaced {\n                        out.append(\"  - \\\"\\(key)\\\"\")\n                        replaced = true\n                    }\n                    inKeys = false\n                }\n            }\n            out.append(line)\n        }\n\n        if inKeys && !replaced {\n            out.append(\"  - \\\"\\(key)\\\"\")\n        }\n\n        content = out.joined(separator: \"\\n\")\n        try? content.write(toFile: configPath, atomically: true, encoding: .utf8)\n        persistPublicAPIKey(key)\n    }\n\n    func generatePublicAPIKey(length: Int = 36) -> String {\n        let prefix = Self.publicAPIKeyPrefix\n        let required = max(prefix.count + 1, length)\n        let bodyLength = required - prefix.count\n        var pool = \"\"\n        while pool.count < bodyLength {\n            pool += UUID().uuidString.replacingOccurrences(of: \"-\", with: \"\").lowercased()\n        }\n        let body = pool.prefix(bodyLength)\n        return prefix + body\n    }\n\n    private func persistPublicAPIKey(_ key: String) {\n        let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return }\n        UserDefaults.standard.set(trimmed, forKey: Self.publicAPIKeyDefaultsKey)\n    }\n\n    // MARK: - Homebrew Services Management\n\n    private func brewServicesStart() async throws {\n        guard let brewPath = brewCommandPath else {\n            throw ServiceError.binaryNotFound\n        }\n\n        // Check if service is already running\n        if await isHomebrewServiceRunning() {\n            appendLog(\"Homebrew service is already running.\\n\")\n            // Start Proxy Bridge if not already running\n            if !proxyBridge.isRunning {\n                proxyBridge.configure(listenPort: port, targetPort: internalPort)\n                proxyBridge.start()\n                try await Task.sleep(nanoseconds: 500_000_000)\n            }\n            isRunning = true\n            return\n        }\n\n        appendLog(\"Starting cliproxyapi via Homebrew services...\\n\")\n\n        // Ensure Homebrew config exists\n        if getHomebrewConfigPath() == nil {\n            appendLog(\"Creating Homebrew config file...\\n\")\n            createHomebrewConfigIfNeeded()\n        }\n\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: brewPath)\n        process.arguments = [\"services\", \"start\", \"cliproxyapi\"]\n\n        let out = Pipe()\n        let err = Pipe()\n        process.standardOutput = out\n        process.standardError = err\n\n        out.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str) }\n            }\n        }\n        err.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str, isError: true) }\n            }\n        }\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n\n            if process.terminationStatus == 0 {\n                // Wait a bit for service to start\n                try await Task.sleep(nanoseconds: 1_500_000_000)\n\n                // Verify service is actually running\n                if !(await isHomebrewServiceRunning()) {\n                    appendLog(\"Service failed to start (not running after start command).\\n\", isError: true)\n                    throw ServiceError.startupFailed\n                }\n\n                // Start Proxy Bridge\n                proxyBridge.configure(listenPort: port, targetPort: internalPort)\n                proxyBridge.start()\n\n                // Wait for bridge\n                try await Task.sleep(nanoseconds: 500_000_000)\n\n                if !proxyBridge.isRunning {\n                    appendLog(\"Proxy bridge failed to start.\\n\", isError: true)\n                    throw ServiceError.startupFailed\n                }\n\n                isRunning = true\n                appendLog(\"Service started successfully via Homebrew.\\n\")\n            } else {\n                let errText = \"brew services start failed with code \\(process.terminationStatus).\"\n                appendLog(errText + \"\\n\", isError: true)\n                throw ServiceError.startupFailed\n            }\n        } catch {\n            lastError = error.localizedDescription\n            appendLog(\"Error starting service via Homebrew: \\(error.localizedDescription)\\n\", isError: true)\n            throw error\n        }\n    }\n\n    private func brewServicesStop() {\n        guard let brewPath = brewCommandPath else { return }\n\n        appendLog(\"Stopping cliproxyapi via Homebrew services...\\n\")\n\n        proxyBridge.stop()\n\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: brewPath)\n        process.arguments = [\"services\", \"stop\", \"cliproxyapi\"]\n\n        let out = Pipe()\n        let err = Pipe()\n        process.standardOutput = out\n        process.standardError = err\n\n        out.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str) }\n            }\n        }\n        err.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str, isError: true) }\n            }\n        }\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n            isRunning = false\n            appendLog(\"Service stopped via Homebrew.\\n\")\n        } catch {\n            appendLog(\"Error stopping service via Homebrew: \\(error.localizedDescription)\\n\", isError: true)\n            isRunning = false\n        }\n    }\n\n    func brewUpgrade() async throws {\n        guard let brewPath = brewCommandPath else {\n            appendLog(\"brew command not found. Cannot upgrade.\\n\", isError: true)\n            throw ServiceError.binaryNotFound\n        }\n\n        isInstalling = true\n        installProgress = 0\n        defer { isInstalling = false }\n\n        appendLog(\"Upgrading cliproxyapi via Homebrew...\\n\")\n        installProgress = 0.3\n\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: brewPath)\n        process.arguments = [\"upgrade\", \"cliproxyapi\"]\n\n        let out = Pipe()\n        let err = Pipe()\n        process.standardOutput = out\n        process.standardError = err\n\n        out.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in\n                    self?.appendLog(str)\n                    // Update progress based on output\n                    if str.contains(\"Updating\") {\n                        self?.installProgress = 0.5\n                    } else if str.contains(\"Downloading\") {\n                        self?.installProgress = 0.7\n                    } else if str.contains(\"Installing\") {\n                        self?.installProgress = 0.9\n                    }\n                }\n            }\n        }\n        err.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in self?.appendLog(str, isError: true) }\n            }\n        }\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n\n            installProgress = 1.0\n\n            if process.terminationStatus == 0 {\n                appendLog(\"cliproxyapi upgraded successfully.\\n\")\n                // Re-detect after upgrade\n                performInitialDetection()\n            } else {\n                let errText = \"brew upgrade failed with code \\(process.terminationStatus).\"\n                appendLog(errText + \"\\n\", isError: true)\n                throw ServiceError.networkError\n            }\n        } catch {\n            lastError = error.localizedDescription\n            appendLog(\"Error upgrading via Homebrew: \\(error.localizedDescription)\\n\", isError: true)\n            throw error\n        }\n    }\n\n    // MARK: - Helpers\n\n    private func cleanupOrphanProcesses() {\n        killProcessOnPort(port)\n        killProcessOnPort(internalPort)\n    }\n\n    private func killProcessOnPort(_ port: UInt16) {\n        let task = Process()\n        task.executableURL = URL(fileURLWithPath: \"/usr/sbin/lsof\")\n        task.arguments = [\"-ti\", \"tcp:\\(port)\"]\n\n        let pipe = Pipe()\n        task.standardOutput = pipe\n\n        try? task.run()\n        task.waitUntilExit()\n\n        let data = pipe.fileHandleForReading.readDataToEndOfFile()\n        if let output = String(data: data, encoding: .utf8) {\n            for line in output.components(separatedBy: .newlines) {\n                if let pid = Int32(line.trimmingCharacters(in: .whitespaces)) {\n                    kill(pid, SIGKILL)\n                }\n            }\n        }\n    }\n\n    private func getHomebrewConfigPath() -> String? {\n        // Homebrew installations typically use ~/.cli-proxy-api/config.yaml\n        let homeDir = FileManager.default.homeDirectoryForCurrentUser\n        let homebrewConfigPath = homeDir.appendingPathComponent(\".cli-proxy-api/config.yaml\").path\n\n        if FileManager.default.fileExists(atPath: homebrewConfigPath) {\n            return homebrewConfigPath\n        }\n\n        // Try alternative location\n        let altPath = homeDir.appendingPathComponent(\".config/cli-proxy-api/config.yaml\").path\n        if FileManager.default.fileExists(atPath: altPath) {\n            return altPath\n        }\n\n        return nil\n    }\n\n    private func createHomebrewConfigIfNeeded() {\n        let homeDir = FileManager.default.homeDirectoryForCurrentUser\n        let configDir = homeDir.appendingPathComponent(\".cli-proxy-api\", isDirectory: true)\n        let configPath = configDir.appendingPathComponent(\"config.yaml\")\n\n        // Create directory if needed\n        try? FileManager.default.createDirectory(at: configDir, withIntermediateDirectories: true)\n\n        // Only create if doesn't exist\n        guard !FileManager.default.fileExists(atPath: configPath.path) else { return }\n\n        // Use same config as CodMate for consistency\n        let apiKey = resolvePublicAPIKey()\n        let config = \"\"\"\nhost: \\\"127.0.0.1\\\"\nport: \\(internalPort)\nauth-dir: \\\"\\(authDir)\\\"\n\napi-keys:\n  - \\\"\\(apiKey)\\\"\n\nremote-management:\n  allow-remote: false\n  secret-key: \\\"\\(managementKey)\\\"\n\ndebug: true\nlogging-to-file: true\nusage-statistics-enabled: true\n\nrouting:\n  strategy: \\\"round-robin\\\"\n\"\"\"\n\n        try? config.write(toFile: configPath.path, atomically: true, encoding: .utf8)\n        appendLog(\"Created Homebrew config at: \\(configPath.path)\\n\")\n    }\n\n    private func isHomebrewServiceRunning() async -> Bool {\n        guard let brewPath = brewCommandPath else { return false }\n\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: brewPath)\n        process.arguments = [\"services\", \"list\"]\n\n        let pipe = Pipe()\n        process.standardOutput = pipe\n        process.standardError = Pipe()\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            if let output = String(data: data, encoding: .utf8) {\n                // Check if cliproxyapi service is listed as started\n                return output.contains(\"cliproxyapi\") && output.contains(\"started\")\n            }\n        } catch {\n            return false\n        }\n\n        return false\n    }\n\n    private func loadEnabledAPIKeyProviders() -> Set<String> {\n        let defaults = UserDefaults.standard\n        let enabled = defaults.array(forKey: \"codmate.providers.apikey.enabled\") as? [String] ?? []\n        return Set(enabled)\n    }\n\n    /// Resolve API key from provider configuration\n    /// The envKey field can contain either:\n    /// 1. The API key itself (if it contains special chars like -, ., etc.)\n    /// 2. An environment variable name (if it looks like an env var)\n    private func resolveAPIKey(provider: ProvidersRegistryService.Provider) -> String? {\n        guard let envKey = provider.envKey, !envKey.isEmpty else {\n            return nil\n        }\n\n        // Check if envKey looks like an API key (contains special chars)\n        // API keys typically contain: -, ., alphanumeric characters\n        let looksLikeAPIKey = envKey.contains(\"-\") || envKey.contains(\".\") || envKey.count > 40\n\n        if looksLikeAPIKey {\n            // Treat as direct API key\n            return envKey\n        } else {\n            // Treat as environment variable name\n            return ProcessInfo.processInfo.environment[envKey]\n        }\n    }\n\n    /// Fetch available models from a third-party OpenAI-compatible API\n    private func fetchModelsFromProvider(baseURL: String, apiKey: String) async -> [String] {\n        guard let url = URL(string: baseURL)?.appendingPathComponent(\"models\") else {\n            appendLog(\"Invalid base URL: \\(baseURL)\\n\")\n            return []\n        }\n\n        var request = URLRequest(url: url)\n        request.httpMethod = \"GET\"\n        request.setValue(\"Bearer \\(apiKey)\", forHTTPHeaderField: \"Authorization\")\n        request.timeoutInterval = 10\n\n        do {\n            let (data, response) = try await URLSession.shared.data(for: request)\n\n            guard let httpResponse = response as? HTTPURLResponse else {\n                appendLog(\"Failed to fetch models from \\(baseURL): No HTTP response\\n\")\n                return []\n            }\n\n            guard httpResponse.statusCode == 200 else {\n                appendLog(\"Failed to fetch models from \\(baseURL): HTTP \\(httpResponse.statusCode)\\n\")\n                return []\n            }\n\n            struct ModelsResponse: Codable {\n                struct Model: Codable {\n                    let id: String\n                }\n                let data: [Model]\n            }\n\n            let modelsResponse = try JSONDecoder().decode(ModelsResponse.self, from: data)\n            let modelIds = modelsResponse.data.map { $0.id }\n            return modelIds\n        } catch {\n            appendLog(\"Error fetching models from \\(baseURL): \\(error.localizedDescription)\\n\")\n            return []\n        }\n    }\n\n    private func ensureConfigExists() {\n        guard !FileManager.default.fileExists(atPath: configPath) else { return }\n\n        let apiKey = resolvePublicAPIKey()\n        let config = \"\"\"\nhost: \\\"127.0.0.1\\\"\nport: \\(internalPort)\nauth-dir: \\\"\\(authDir)\\\"\n\napi-keys:\n  - \\\"\\(apiKey)\\\"\n\nremote-management:\n  allow-remote: false\n  secret-key: \\\"\\(managementKey)\\\"\n\ndebug: true\nlogging-to-file: true\nusage-statistics-enabled: true\n\nrouting:\n  strategy: \\\"round-robin\\\"\n\"\"\"\n\n        try? config.write(toFile: configPath, atomically: true, encoding: .utf8)\n    }\n\n    func syncThirdPartyProviders(enabledProviderIds: Set<String>) async {\n        let registry = ProvidersRegistryService()\n        let providers = await registry.listProviders()\n        let apiKey = resolvePublicAPIKey()\n\n        // Filter providers based on enabled status\n        let enabledProviders = providers.filter { enabledProviderIds.contains($0.id) }\n\n        var config = \"\"\"\nhost: \\\"127.0.0.1\\\"\nport: \\(internalPort)\nauth-dir: \\\"\\(authDir)\\\"\n\napi-keys:\n  - \\\"\\(apiKey)\\\"\n\nremote-management:\n  allow-remote: false\n  secret-key: \\\"\\(managementKey)\\\"\n\ndebug: true\nlogging-to-file: true\nusage-statistics-enabled: true\n\nrouting:\n  strategy: \\\"round-robin\\\"\n\n\"\"\"\n\n        // Append third-party providers configuration\n        // Collect all OpenAI-compatible providers (only enabled ones)\n        var openaiProviders: [(name: String, baseURL: String, apiKey: String, models: [String])] = []\n\n        for provider in enabledProviders {\n            // Extract API key (either directly from envKey or from environment variable)\n            guard let apiKey = resolveAPIKey(provider: provider), !apiKey.isEmpty else {\n                continue\n            }\n\n            let providerName = provider.name ?? provider.id\n\n            // Use OpenAI-compatible format for all third-party providers\n            if let codexConnector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue],\n               let baseURL = codexConnector.baseURL,\n               !baseURL.isEmpty {\n\n                // Priority 1: Use catalog models (user-configured in Edit Provider dialog)\n                var models: [String] = []\n                if let catalog = provider.catalog,\n                   let catalogModels = catalog.models,\n                   !catalogModels.isEmpty {\n                    models = catalogModels.compactMap { $0.vendorModelId }\n                    appendLog(\"Using \\(models.count) models from catalog for \\(providerName)\\n\")\n                } else {\n                    // Priority 2: Fetch from API if catalog is empty (only works for some providers like DeepSeek)\n                    models = await fetchModelsFromProvider(baseURL: baseURL, apiKey: apiKey)\n                    if !models.isEmpty {\n                        appendLog(\"Fetched \\(models.count) models from \\(providerName) API (\\(baseURL))\\n\")\n                    } else {\n                        appendLog(\"No models available for \\(providerName) (no catalog and API fetch failed)\\n\")\n                    }\n                }\n\n                if !models.isEmpty {\n                    openaiProviders.append((name: providerName, baseURL: baseURL, apiKey: apiKey, models: models))\n                }\n            }\n        }\n\n        // Build openai-compatibility section with models\n        if !openaiProviders.isEmpty {\n            config += \"\\nopenai-compatibility:\\n\"\n            for (name, baseURL, apiKey, models) in openaiProviders {\n                config += \"\"\"\n  - name: \"\\(name)\"\n    base-url: \"\\(baseURL)\"\n    api-key-entries:\n      - api-key: \"\\(apiKey)\"\n\n\"\"\"\n                // Add models if available\n                if !models.isEmpty {\n                    config += \"    models:\\n\"\n                    for modelId in models {\n                        config += \"      - name: \\\"\\(modelId)\\\"\\n\"\n                    }\n                    config += \"\\n\"\n                }\n            }\n        }\n\n        try? config.write(toFile: configPath, atomically: true, encoding: .utf8)\n        appendLog(\"Synced \\(openaiProviders.count) third-party provider(s) to config (openai-compatibility format).\\n\")\n\n        // Build model -> provider name cache by parsing what we just wrote\n        // This is simpler and more reliable than depending on CLIProxyAPI metadata\n        var newCache: [String: String] = [:]\n        for (name, _, _, models) in openaiProviders {\n            for modelId in models {\n                newCache[modelId] = name\n            }\n        }\n        modelToProviderNameCache = newCache\n        appendLog(\"Built model-to-provider cache: \\(newCache.count) models\\n\")\n\n        // Poll CLIProxyAPI until config is reloaded (with timeout)\n        if isRunning {\n            appendLog(\"Waiting for CLI Proxy API to reload config...\\n\")\n            let expectedModelIds = Set(openaiProviders.flatMap { $0.models })\n            await waitForConfigReload(expectedModelIds: expectedModelIds, timeoutSeconds: 5.0)\n        }\n    }\n\n    /// Poll CLIProxyAPI until the expected models appear (indicating config reload is complete)\n    private func waitForConfigReload(expectedModelIds: Set<String>, timeoutSeconds: Double) async {\n        let startTime = Date()\n        let timeoutInterval = timeoutSeconds\n        var attemptCount = 0\n\n        while Date().timeIntervalSince(startTime) < timeoutInterval {\n            attemptCount += 1\n\n            // Fetch current models from CLIProxyAPI\n            let currentModels = await fetchLocalModels(forceRefresh: true)\n            let currentModelIds = Set(currentModels.map { $0.id })\n\n            // Check if all expected models are present\n            let missingModels = expectedModelIds.subtracting(currentModelIds)\n            if missingModels.isEmpty {\n                appendLog(\"Config reloaded successfully after \\(attemptCount) attempt(s) (~\\(String(format: \"%.1f\", Date().timeIntervalSince(startTime)))s)\\n\")\n                return\n            }\n\n            // Wait before next poll (100ms intervals)\n            try? await Task.sleep(nanoseconds: 100_000_000)\n        }\n\n        // Timeout reached\n        appendLog(\"Warning: Config reload timeout after \\(String(format: \"%.1f\", timeoutSeconds))s. Some models may not be available yet.\\n\")\n    }\n\n    private func updateConfigPort(_ newPort: UInt16) {\n        guard FileManager.default.fileExists(atPath: configPath),\n              var content = try? String(contentsOfFile: configPath, encoding: .utf8) else { return }\n\n        if let range = content.range(of: #\"port:\\s*\\d+\"#, options: .regularExpression) {\n            content.replaceSubrange(range, with: \"port: \\(newPort)\")\n            try? content.write(toFile: configPath, atomically: true, encoding: .utf8)\n        }\n    }\n\n    // MARK: - GitHub API\n\n    private struct ReleaseInfo: Decodable {\n        let assets: [AssetInfo]\n    }\n\n    private struct AssetInfo: Decodable {\n        let name: String\n        let browser_download_url: String\n        var downloadURL: String { browser_download_url }\n    }\n\n    private struct CompatibleAsset {\n        let name: String\n        let downloadURL: String\n    }\n\n    private func fetchLatestRelease() async throws -> ReleaseInfo {\n        let url = URL(string: \"https://api.github.com/repos/\\(Self.githubRepo)/releases/latest\")!\n        var req = URLRequest(url: url)\n        req.addValue(\"application/vnd.github.v3+json\", forHTTPHeaderField: \"Accept\")\n\n        let (data, resp) = try await URLSession.shared.data(for: req)\n        guard (resp as? HTTPURLResponse)?.statusCode == 200 else {\n            throw ServiceError.networkError\n        }\n        return try JSONDecoder().decode(ReleaseInfo.self, from: data)\n    }\n\n    private func findCompatibleAsset(in release: ReleaseInfo) -> CompatibleAsset? {\n        #if arch(arm64)\n        let arch = \"arm64\"\n        #else\n        let arch = \"amd64\"\n        #endif\n        let target = \"darwin_\\(arch)\"\n\n        for asset in release.assets {\n            let name = asset.name.lowercased()\n            if name.contains(target) && !name.contains(\"checksum\") {\n                return CompatibleAsset(name: asset.name, downloadURL: asset.downloadURL)\n            }\n        }\n        return nil\n    }\n\n    private func downloadAsset(url: String) async throws -> Data {\n        let (data, resp) = try await URLSession.shared.data(from: URL(string: url)!)\n        guard (resp as? HTTPURLResponse)?.statusCode == 200 else {\n            throw ServiceError.networkError\n        }\n        return data\n    }\n\n    private func extractAndInstall(data: Data, assetName: String) async throws {\n        let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)\n        try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)\n        defer { try? FileManager.default.removeItem(at: tempDir) }\n\n        let archivePath = tempDir.appendingPathComponent(assetName)\n        try data.write(to: archivePath)\n\n        // Extract\n        let tar = Process()\n        tar.executableURL = URL(fileURLWithPath: \"/usr/bin/tar\")\n        tar.arguments = [\"-xzf\", archivePath.path, \"-C\", tempDir.path]\n        try tar.run()\n        tar.waitUntilExit()\n\n        // Find binary\n        let binary = search(tempDir)\n\n        guard let validBinary = binary else {\n            throw ServiceError.extractionFailed\n        }\n\n        if FileManager.default.fileExists(atPath: binaryPath) {\n            try FileManager.default.removeItem(atPath: binaryPath)\n        }\n        try FileManager.default.copyItem(at: validBinary, to: URL(fileURLWithPath: binaryPath))\n        try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: binaryPath)\n    }\n\n    private func runShell(command: String, args: [String]) -> String {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: command)\n        process.arguments = args\n\n        let pipe = Pipe()\n        process.standardOutput = pipe\n        process.standardError = pipe\n\n        do {\n            try process.run()\n            process.waitUntilExit()\n            let data = pipe.fileHandleForReading.readDataToEndOfFile()\n            return String(data: data, encoding: .utf8) ?? \"Failed to read output\"\n        } catch {\n            return \"Failed to run command: \\(error.localizedDescription)\"\n        }\n    }\n\n    private func runCLI(arguments: [String], loginProvider: LocalAuthProvider? = nil) async throws {\n        let process = Process()\n        let execPath = resolvedBinaryPath\n        process.executableURL = URL(fileURLWithPath: execPath)\n        process.arguments = arguments\n        process.currentDirectoryURL = URL(fileURLWithPath: execPath).deletingLastPathComponent()\n\n        var env = ProcessInfo.processInfo.environment\n        env[\"TERM\"] = \"xterm-256color\"\n        process.environment = env\n\n        let out = Pipe()\n        let err = Pipe()\n        process.standardOutput = out\n        process.standardError = err\n        if loginProvider != nil {\n            let input = Pipe()\n            process.standardInput = input\n            self.loginInputPipe = input\n        }\n\n        out.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in\n                    self?.appendLog(str)\n                    if let provider = self?.loginProvider {\n                        self?.detectLoginURL(in: str, provider: provider)\n                        self?.detectLoginPrompt(in: str)\n                    }\n                }\n            }\n        }\n        err.fileHandleForReading.readabilityHandler = { [weak self] handle in\n            let data = handle.availableData\n            if let str = String(data: data, encoding: .utf8), !str.isEmpty {\n                Task { @MainActor [weak self] in\n                    self?.appendLog(str, isError: true)\n                    if let provider = self?.loginProvider {\n                        self?.detectLoginURL(in: str, provider: provider)\n                        self?.detectLoginPrompt(in: str)\n                    }\n                }\n            }\n        }\n\n        if let provider = loginProvider {\n            self.loginProvider = provider\n            self.loginProcess = process\n            self.loginCancellationRequested = false\n        }\n\n        defer {\n            if loginProvider != nil {\n                self.loginProvider = nil\n                self.loginProcess = nil\n                self.loginInputPipe = nil\n                self.loginPrompt = nil\n                self.openedLoginURL = nil\n            }\n        }\n\n        do {\n            try process.run()\n        } catch {\n            appendLog(\"Failed to start CLIProxyAPI: \\(error.localizedDescription)\\n\", isError: true)\n            throw ServiceError.loginFailed\n        }\n\n        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in\n            DispatchQueue.global(qos: .utility).async {\n                process.waitUntilExit()\n                continuation.resume(returning: ())\n            }\n        }\n\n        if loginProvider != nil, loginCancellationRequested {\n            loginCancellationRequested = false\n            throw CancellationError()\n        }\n\n        if process.terminationStatus != 0 {\n            appendLog(\"CLIProxyAPI exited with code \\(process.terminationStatus).\\n\", isError: true)\n            throw ServiceError.loginFailed\n        }\n    }\n\n    private func detectLoginPrompt(in text: String) {\n        guard let provider = loginProvider else { return }\n        let lower = text.lowercased()\n        let prompt: String?\n        if lower.contains(\"paste the codex callback url\") || lower.contains(\"paste the callback url\") {\n            if provider == .codex {\n                submitLoginInput(\"\")\n                appendLog(\"Codex callback prompt detected; continuing to wait.\\n\")\n                return\n            }\n            if provider == .gemini {\n                submitLoginInput(\"\")\n                appendLog(\"Gemini callback prompt detected; continuing to wait.\\n\")\n                return\n            }\n            prompt = \"Paste the callback URL\"\n        } else if lower.contains(\"enter project id\") {\n            if provider == .gemini {\n                submitLoginInput(\"\")\n                appendLog(\"Gemini project prompt detected; using default project.\\n\")\n                return\n            }\n            prompt = \"Enter project ID or ALL\"\n        } else if lower.contains(\"device code\")\n                    || lower.contains(\"verification code\")\n                    || lower.contains(\"enter code\")\n                    || lower.contains(\"input code\")\n                    || lower.contains(\"paste code\")\n                    || lower.contains(\"设备码\")\n                    || lower.contains(\"验证码\")\n                    || lower.contains(\"输入验证码\")\n                    || lower.contains(\"输入代码\")\n                    || lower.contains(\"输入设备码\") {\n            prompt = \"Enter device or verification code\"\n        } else if lower.contains(\"enter email\")\n                    || lower.contains(\"enter your email\")\n                    || lower.contains(\"enter nickname\")\n                    || lower.contains(\"enter a nickname\")\n                    || lower.contains(\"enter name\")\n                    || lower.contains(\"enter username\")\n                    || lower.contains(\"enter alias\")\n                    || lower.contains(\"enter account\")\n                    || lower.contains(\"enter label\")\n                    || lower.contains(\"enter display name\")\n                    || lower.contains(\"输入邮箱\")\n                    || lower.contains(\"输入昵称\")\n                    || lower.contains(\"输入名字\")\n                    || lower.contains(\"输入名称\")\n                    || lower.contains(\"输入用户名\")\n                    || lower.contains(\"输入别名\")\n                    || lower.contains(\"输入账号\")\n                    || lower.contains(\"输入账户\")\n                    || lower.contains(\"输入账号名称\")\n                    || lower.contains(\"输入账户名称\")\n                    || lower.contains(\"输入账号别名\")\n                    || lower.contains(\"输入账户别名\") {\n            prompt = \"Enter email or nickname\"\n        } else {\n            prompt = nil\n        }\n\n        guard let message = prompt else { return }\n        if loginPrompt?.message == message && loginPrompt?.provider == provider {\n            return\n        }\n        loginPrompt = LoginPrompt(provider: provider, message: message)\n    }\n\n    private func detectLoginURL(in text: String, provider: LocalAuthProvider) {\n        guard provider == .qwen else { return }\n        guard openedLoginURL == nil else { return }\n        guard text.contains(\"http\") else { return }\n        guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { return }\n        let range = NSRange(text.startIndex..<text.endIndex, in: text)\n        for match in detector.matches(in: text, options: [], range: range) {\n            guard let url = match.url else { continue }\n            openedLoginURL = url\n            appendLog(\"Opening \\(provider.displayName) login URL...\\n\")\n            NSWorkspace.shared.open(url)\n            break\n        }\n    }\n\n    private struct ManagementAuthURLResponse: Decodable {\n        let status: String?\n        let url: String?\n        let state: String?\n        let error: String?\n    }\n\n    private struct ManagementAuthStatusResponse: Decodable {\n        let status: String?\n        let error: String?\n    }\n\n    private struct ManagementAuthFilesResponse: Decodable {\n        let files: [AuthFileInfo]\n    }\n\n    struct AuthFileInfo: Decodable {\n        let id: String?\n        let name: String\n        let provider: String?\n        let status: String?\n        let email: String?\n        let account: String?\n        let plan: String?\n        let planType: String?\n        let tier: String?\n        let subscription: String?\n        let organization: String?\n        let accountType: String?\n        let disabled: Bool?\n        let idToken: CodexIDToken?\n\n        struct CodexIDToken: Decodable {\n            let chatgptAccountId: String?\n            let planType: String?\n\n            enum CodingKeys: String, CodingKey {\n                case chatgptAccountId = \"chatgpt_account_id\"\n                case planType = \"plan_type\"\n            }\n        }\n\n        enum CodingKeys: String, CodingKey {\n            case id, name, provider, status, email, account, plan, planType, tier, subscription\n            case organization, accountType, disabled\n            case idToken = \"id_token\"\n        }\n\n        var consolidatedPlan: String? {\n            plan ?? planType ?? tier ?? subscription ?? idToken?.planType\n        }\n\n        var consolidatedAccountType: String? {\n            accountType\n        }\n    }\n\n    private func loginViaManagement(provider: LocalAuthProvider) async throws {\n        let shouldStopAfter = !isRunning\n        if shouldStopAfter {\n            appendLog(\"Starting local server for \\(provider.displayName) login...\\n\")\n            try await start()\n        }\n        defer {\n            if shouldStopAfter {\n                stop()\n            }\n        }\n\n        let (authURL, state) = try await fetchManagementAuthURL(for: provider)\n        appendLog(\"Opening browser for \\(provider.displayName) login...\\n\")\n        NSWorkspace.shared.open(authURL)\n\n        guard let state, !state.isEmpty else {\n            appendLog(\"Missing auth state for \\(provider.displayName) login.\\n\", isError: true)\n            throw ServiceError.loginFailed\n        }\n\n        try await waitForAuthCompletion(state: state, provider: provider)\n        appendLog(\"\\(provider.displayName) login finished.\\n\")\n    }\n\n    private func fetchManagementAuthURL(for provider: LocalAuthProvider) async throws -> (URL, String?) {\n        guard let endpoint = managementAuthEndpoint(for: provider),\n              let request = managementRequest(path: endpoint) else {\n            throw ServiceError.networkError\n        }\n        let (data, response) = try await URLSession.shared.data(for: request)\n        guard (response as? HTTPURLResponse)?.statusCode == 200 else {\n            throw ServiceError.networkError\n        }\n        let payload = try JSONDecoder().decode(ManagementAuthURLResponse.self, from: data)\n        guard payload.status?.lowercased() == \"ok\",\n              let urlText = payload.url,\n              let url = URL(string: urlText) else {\n            throw ServiceError.loginFailed\n        }\n        return (url, payload.state)\n    }\n\n    private func waitForAuthCompletion(state: String, provider: LocalAuthProvider) async throws {\n        let timeoutSeconds: TimeInterval = 180\n        let deadline = Date().addingTimeInterval(timeoutSeconds)\n        while Date() < deadline {\n            try Task.checkCancellation()\n            let status = try await fetchAuthStatus(state: state)\n            switch status {\n            case \"ok\":\n                return\n            case \"error\":\n                appendLog(\"\\(provider.displayName) login failed.\\n\", isError: true)\n                throw ServiceError.loginFailed\n            default:\n                break\n            }\n            try await Task.sleep(nanoseconds: 1_000_000_000)\n        }\n        appendLog(\"\\(provider.displayName) login timed out.\\n\", isError: true)\n        throw ServiceError.loginFailed\n    }\n\n    private func fetchAuthStatus(state: String) async throws -> String {\n        let query = [URLQueryItem(name: \"state\", value: state)]\n        guard let request = managementRequest(path: \"get-auth-status\", queryItems: query) else {\n            throw ServiceError.networkError\n        }\n        let (data, response) = try await URLSession.shared.data(for: request)\n        guard (response as? HTTPURLResponse)?.statusCode == 200 else {\n            throw ServiceError.networkError\n        }\n        let payload = try JSONDecoder().decode(ManagementAuthStatusResponse.self, from: data)\n        return payload.status?.lowercased() ?? \"error\"\n    }\n\n    private func managementAuthEndpoint(for provider: LocalAuthProvider) -> String? {\n        switch provider {\n        case .codex: return \"codex-auth-url\"\n        case .claude: return \"anthropic-auth-url\"\n        case .gemini: return \"gemini-cli-auth-url\"\n        case .antigravity: return \"antigravity-auth-url\"\n        case .qwen: return \"qwen-auth-url\"\n        }\n    }\n\n    private func managementRequest(path: String, queryItems: [URLQueryItem]? = nil) -> URLRequest? {\n        guard var components = URLComponents(string: \"http://127.0.0.1:\\(internalPort)/v0/management/\\(path)\") else {\n            return nil\n        }\n        if let queryItems, !queryItems.isEmpty {\n            components.queryItems = queryItems\n        }\n        guard let url = components.url else { return nil }\n        var request = URLRequest(url: url)\n        request.setValue(\"Bearer \\(managementKey)\", forHTTPHeaderField: \"Authorization\")\n        return request\n    }\n\n    func fetchAuthFileInfo(for filename: String) async -> AuthFileInfo? {\n        guard isRunning else {\n            appendLog(\"Cannot fetch auth file info: service not running\\n\", isError: true)\n            return nil\n        }\n        guard let request = managementRequest(path: \"auth-files\") else {\n            appendLog(\"Cannot fetch auth file info: failed to create request\\n\", isError: true)\n            return nil\n        }\n\n        do {\n            let (data, response) = try await URLSession.shared.data(for: request)\n            let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1\n\n            if statusCode != 200 {\n                appendLog(\"Auth files API returned status \\(statusCode)\\n\", isError: true)\n                return nil\n            }\n\n            // Debug: print raw response\n            if let jsonString = String(data: data, encoding: .utf8) {\n                appendLog(\"Auth files API response: \\(jsonString.prefix(500))\\n\")\n            }\n\n            let authFiles: [AuthFileInfo]\n            if let wrapped = try? JSONDecoder().decode(ManagementAuthFilesResponse.self, from: data) {\n                authFiles = wrapped.files\n            } else {\n                authFiles = try JSONDecoder().decode([AuthFileInfo].self, from: data)\n            }\n            appendLog(\"Successfully decoded \\(authFiles.count) auth files\\n\")\n\n            if let found = authFiles.first(where: { $0.name == filename || $0.id == filename }) {\n                appendLog(\"Found auth file info for \\(filename): plan=\\(found.consolidatedPlan ?? \"nil\")\\n\")\n                return found\n            } else {\n                appendLog(\"Auth file \\(filename) not found in response\\n\", isError: true)\n                return nil\n            }\n        } catch {\n            appendLog(\"Failed to fetch auth file info: \\(error.localizedDescription)\\n\", isError: true)\n            return nil\n        }\n    }\n\n    /// DEPRECATED: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files\n    /// This function always returns false. Use local oauthAccountsEnabled settings instead.\n    ///\n    /// According to CLI Proxy API documentation (https://help.router-for.me/management/api),\n    /// the Management API only supports: GET, POST, DELETE for auth files, but not PATCH/UPDATE.\n    /// CLIProxyAPI loads all auth files, and applications should filter which ones to use locally.\n    @available(*, deprecated, message: \"CLI Proxy API does not support updating auth file disabled status via Management API\")\n    func updateAuthFileDisabled(filename: String, disabled: Bool) async -> Bool {\n        // CLI Proxy API's Management API does not provide this endpoint\n        // Always return false and rely on local oauthAccountsEnabled settings\n        return false\n    }\n\n    /// DEPRECATED: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files\n    /// This function does nothing. Use local oauthAccountsEnabled settings instead.\n    @available(*, deprecated, message: \"CLI Proxy API does not support updating auth file disabled status via Management API\")\n    func updateProviderAuthFilesDisabled(provider: LocalAuthProvider, disabled: Bool) async {\n        // CLI Proxy API's Management API does not provide this endpoint\n        // No-op - rely on local oauthAccountsEnabled settings\n    }\n\n    private func search(_ dir: URL) -> URL? {\n        guard let items = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.isExecutableKey, .isDirectoryKey]) else { return nil }\n\n        let candidates = [\"cliproxyapiplus\", \"cliproxyapi\", \"cli-proxy-api\", \"cli-proxy-api-plus\"]\n\n        for item in items {\n            if let vals = try? item.resourceValues(forKeys: [.isDirectoryKey]), vals.isDirectory == true {\n                 if let found = search(item) { return found }\n                 continue\n            }\n\n            let name = item.lastPathComponent.lowercased()\n            if candidates.contains(name) { return item }\n            if name.contains(\"cliproxy\") && !name.contains(\".txt\") && !name.contains(\".md\") && !name.contains(\".gz\") {\n                return item\n            }\n        }\n        return nil\n    }\n}\n\nenum ServiceError: LocalizedError {\n    case binaryNotFound\n    case startupFailed\n    case networkError\n    case noCompatibleBinary\n    case extractionFailed\n    case loginFailed\n\n    var errorDescription: String? {\n        switch self {\n        case .binaryNotFound: return \"CLIProxyAPI binary not found. Please install it first.\"\n        case .startupFailed: return \"Failed to start CLIProxyAPI\"\n        case .networkError: return \"Network error\"\n        case .noCompatibleBinary: return \"No compatible binary found\"\n        case .extractionFailed: return \"Extraction failed\"\n        case .loginFailed: return \"Login failed\"\n        }\n    }\n}\n"
  },
  {
    "path": "services/ClaudeSessionParser.swift",
    "content": "import Foundation\n\nstruct ClaudeParsedLog {\n    let summary: SessionSummary\n    let rows: [SessionRow]\n}\n\nprivate struct ActiveDurationAccumulator {\n    var currentTurnStart: Date?\n    var lastOutput: Date?\n    var total: TimeInterval = 0\n\n    mutating func observe(type: String?, timestamp: Date?) {\n        guard let ts = timestamp else { return }\n        switch type {\n        case \"user\":\n            flush()\n            currentTurnStart = ts\n            lastOutput = nil\n        case \"assistant\", \"system\", \"summary\":\n            lastOutput = ts\n        default:\n            break\n        }\n    }\n\n    mutating func flush() {\n        guard let end = lastOutput else { return }\n        let start = currentTurnStart ?? end\n        let delta = end.timeIntervalSince(start)\n        if delta > 0 { total += delta }\n        currentTurnStart = nil\n        lastOutput = nil\n    }\n}\n\nstruct ClaudeSessionParser {\n    private let decoder: JSONDecoder\n    private let newline: UInt8 = 0x0A\n    private let carriageReturn: UInt8 = 0x0D\n    private let chunkSize = 64 * 1024\n\n    init() {\n        self.decoder = FlexibleDecoders.iso8601Flexible()\n    }\n\n    /// Fast path: extract sessionId by scanning until a line that carries it.\n    /// Avoids doing full conversion work. Returns nil if not found.\n    func fastSessionId(at url: URL) -> String? {\n        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else {\n            return nil\n        }\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(256) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let line = decodeLine(Data(slice)) else { continue }\n            if let sid = line.sessionId, !sid.isEmpty { return sid }\n        }\n        return nil\n    }\n\n    /// Fast path: extract cwd by scanning until a line that carries it.\n    /// Avoids doing full conversion work. Returns nil if not found.\n    func fastCWD(at url: URL) -> String? {\n        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else {\n            return nil\n        }\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(256) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let line = decodeLine(Data(slice)) else { continue }\n            if let cwd = line.cwd, !cwd.isEmpty { return cwd }\n        }\n        return nil\n    }\n\n    func parse(at url: URL, fileSize: UInt64? = nil) -> ClaudeParsedLog? {\n        // Skip agent-*.jsonl files entirely (sidechain warmup files)\n        let filename = url.deletingPathExtension().lastPathComponent\n        if filename.hasPrefix(\"agent-\") {\n            return nil\n        }\n\n        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),\n              !data.isEmpty else { return nil }\n\n        var accumulator = MetadataAccumulator()\n        var activeAccumulator = ActiveDurationAccumulator()\n        var rows: [SessionRow] = []\n        rows.reserveCapacity(256)\n        // For user messages without message.id, use timestamp+content as unique key\n        var seenUserMessageKeys: Set<String> = []\n        var seenAssistantMessageIds: Set<String> = []\n        var seenToolUseIds: Set<String> = []\n        // Track seen UUIDs to prevent true duplicates (same UUID = exact same JSONL record)\n        var seenUUIDs: Set<String> = []\n        var dedupUserCount = 0\n        var dedupAssistantCount = 0\n        var dedupToolCount = 0\n\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let line = decodeLine(Data(slice)) else { continue }\n            if line.isSidechain == true { continue }\n\n            // Skip true duplicates (exact same JSONL record with same UUID)\n            if let uuid = line.uuid, !uuid.isEmpty {\n                if !seenUUIDs.insert(uuid).inserted {\n                    // This is a true duplicate with the same UUID, skip it\n                    continue\n                }\n            }\n\n            let renderedText = line.message.flatMap(Self.renderFlatText)\n            let model = line.message?.model\n            let usageTokens = line.message?.usage?.totalTokens\n            accumulator.consume(line, renderedText: renderedText, model: model, usageTokens: usageTokens)\n            activeAccumulator.observe(type: line.type, timestamp: line.timestamp)\n            let hasText = ClaudeSessionParser.hasRenderableText(line.message)\n\n            // Track stats for user and assistant messages\n            if let type = line.type {\n                let messageId = line.message?.id\n                switch type {\n                case \"user\":\n                    // Just count for stats, UUID already handles deduplication\n                    if hasText {\n                        let userKey: String\n                        if let msgId = messageId, !msgId.isEmpty {\n                            userKey = msgId\n                        } else {\n                            let ts = line.timestamp?.timeIntervalSince1970 ?? 0\n                            let content = renderedText ?? \"\"\n                            userKey = \"\\(Int(ts * 1000)):\\(content.prefix(100).hashValue)\"\n                        }\n                        if seenUserMessageKeys.insert(userKey).inserted {\n                            dedupUserCount &+= 1\n                        }\n                    }\n                case \"assistant\":\n                    // Just count for stats, UUID already handles deduplication\n                    let newTools = ClaudeSessionParser.countToolUses(in: line.message, seen: &seenToolUseIds)\n                    if newTools > 0 { dedupToolCount &+= newTools }\n                    if hasText {\n                        if messageId.map({ seenAssistantMessageIds.insert($0).inserted }) ?? true {\n                            dedupAssistantCount &+= 1\n                        }\n                    }\n                default:\n                    break\n                }\n            }\n\n            // Always convert (UUID-based deduplication already handled true duplicates)\n            rows.append(contentsOf: convert(line))\n        }\n        activeAccumulator.flush()\n\n        let contextRow = accumulator.makeContextRow()\n        guard let metaRow = accumulator.makeMetaRow(),\n              let summary = buildSummary(\n                url: url,\n                fileSize: fileSize,\n                metaRow: metaRow,\n                contextRow: contextRow,\n                additionalRows: rows,\n                totalTokens: accumulator.totalTokens,\n                tokenBreakdown: accumulator.tokenBreakdown(),\n                lastTimestamp: accumulator.lastTimestamp,\n                activeDuration: activeAccumulator.total > 0 ? activeAccumulator.total : nil) else {\n            return nil\n        }\n\n        var combinedRows: [SessionRow] = [metaRow]\n        if let contextRow { combinedRows.append(contextRow) }\n        combinedRows.append(contentsOf: rows)\n        let finalSummary = adjustCounts(\n            summary: summary,\n            userCount: dedupUserCount,\n            assistantCount: dedupAssistantCount,\n            toolCount: dedupToolCount)\n        let timelineAdjusted = adjustCountsFromTimeline(summary: finalSummary, rows: combinedRows)\n        return ClaudeParsedLog(summary: timelineAdjusted, rows: combinedRows)\n    }\n\n    private func adjustCounts(\n        summary: SessionSummary,\n        userCount: Int,\n        assistantCount: Int,\n        toolCount: Int\n    ) -> SessionSummary {\n        let adjustedUser = userCount > 0 ? userCount : summary.userMessageCount\n        let adjustedAssistant = assistantCount > 0 ? assistantCount : summary.assistantMessageCount\n        let adjustedTools = toolCount > 0 ? toolCount : summary.toolInvocationCount\n\n        var adjustedResponseCounts = summary.responseCounts\n        if toolCount > 0 {\n            adjustedResponseCounts[\"tool_call\"] = toolCount\n        }\n        let adjustedEventCount = adjustedUser + adjustedAssistant + adjustedResponseCounts.values.reduce(0, +)\n\n        var adjusted = SessionSummary(\n            id: summary.id,\n            fileURL: summary.fileURL,\n            fileSizeBytes: summary.fileSizeBytes,\n            startedAt: summary.startedAt,\n            endedAt: summary.endedAt,\n            activeDuration: summary.activeDuration,\n            cliVersion: summary.cliVersion,\n            cwd: summary.cwd,\n            originator: summary.originator,\n            instructions: summary.instructions,\n            model: summary.model,\n            approvalPolicy: summary.approvalPolicy,\n            userMessageCount: adjustedUser,\n            assistantMessageCount: adjustedAssistant,\n            toolInvocationCount: adjustedTools,\n            responseCounts: adjustedResponseCounts,\n            turnContextCount: summary.turnContextCount,\n            messageTypeCounts: summary.messageTypeCounts,\n            totalTokens: summary.totalTokens,\n            tokenBreakdown: summary.tokenBreakdown,\n            eventCount: adjustedEventCount,\n            lineCount: summary.lineCount,\n            lastUpdatedAt: summary.lastUpdatedAt,\n            source: summary.source,\n            remotePath: summary.remotePath,\n            userTitle: summary.userTitle,\n            userComment: summary.userComment,\n            taskId: summary.taskId\n        )\n        adjusted.parseLevel = summary.parseLevel\n        return adjusted\n    }\n\n    /// Align counts with the visible timeline logic to keep list metrics consistent.\n    private func adjustCountsFromTimeline(\n        summary: SessionSummary,\n        rows: [SessionRow]\n    ) -> SessionSummary {\n        let loader = SessionTimelineLoader()\n        let turns = loader.turns(from: rows)\n        let turnCount = turns.count\n        // Preserve the tool invocation count from the previous adjustCounts() step,\n        // which correctly counts tool_use blocks in assistant messages.\n        // Do NOT count tool outputs here (actor == .tool), as those are results, not calls.\n        return ClaudeSessionParser.normalizeCounts(\n            summary: summary,\n            turnCount: turnCount,\n            assistantTextCount: turnCount,\n            toolCount: summary.toolInvocationCount)\n    }\n\n    /// Normalize counts to CodMate's definition: one user→assistant exchange equals one message.\n    private static func normalizeCounts(\n        summary: SessionSummary,\n        turnCount: Int,\n        assistantTextCount: Int,\n        toolCount: Int\n    ) -> SessionSummary {\n        let turns = max(turnCount, 0)\n        let assistant = turns > 0 ? max(min(turns, assistantTextCount), turns) : assistantTextCount\n        let tools = max(toolCount, 0)\n\n        var counts = summary.responseCounts\n        counts[\"tool_call\"] = tools\n\n        return SessionSummary(\n            id: summary.id,\n            fileURL: summary.fileURL,\n            fileSizeBytes: summary.fileSizeBytes,\n            startedAt: summary.startedAt,\n            endedAt: summary.endedAt,\n            activeDuration: summary.activeDuration,\n            cliVersion: summary.cliVersion,\n            cwd: summary.cwd,\n            originator: summary.originator,\n            instructions: summary.instructions,\n            model: summary.model,\n            approvalPolicy: summary.approvalPolicy,\n            userMessageCount: turns,\n            assistantMessageCount: assistant,\n            toolInvocationCount: tools,\n            responseCounts: counts,\n            turnContextCount: summary.turnContextCount,\n            messageTypeCounts: summary.messageTypeCounts,\n            totalTokens: summary.totalTokens,\n            tokenBreakdown: summary.tokenBreakdown,\n            eventCount: turns + tools,\n            lineCount: summary.lineCount,\n            lastUpdatedAt: summary.lastUpdatedAt,\n            source: summary.source,\n            remotePath: summary.remotePath,\n            userTitle: summary.userTitle,\n            userComment: summary.userComment,\n            taskId: summary.taskId,\n            parseLevel: .enriched  // Mark as enriched with timeline-based turn counting\n        )\n    }\n\n    private func decodeLine(_ data: Data) -> ClaudeLogLine? {\n        do {\n            return try decoder.decode(ClaudeLogLine.self, from: data)\n        } catch {\n            return nil\n        }\n    }\n\n    private func convert(_ line: ClaudeLogLine) -> [SessionRow] {\n        guard let timestamp = line.timestamp else { return [] }\n        guard let type = line.type else { return [] }\n\n        // Skip sidechain messages (warmup, etc.)\n        if line.isSidechain == true {\n            return []\n        }\n\n        switch type {\n        case \"user\":\n            return convertUser(line, timestamp: timestamp)\n        case \"assistant\":\n            return convertAssistant(line, timestamp: timestamp)\n        case \"system\":\n            return convertSystem(line, timestamp: timestamp)\n        case \"summary\":\n            guard let summary = line.summary else { return [] }\n            let payload = EventMessagePayload(\n                type: \"system_summary\",\n                message: summary,\n                kind: nil,\n                text: summary,\n                reason: nil,\n                info: nil,\n                rateLimits: nil)\n            return [SessionRow(timestamp: timestamp, kind: .eventMessage(payload))]\n        default:\n            return []\n        }\n    }\n\n    private func convertUser(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] {\n        guard let message = line.message else { return [] }\n        let blocks = Self.blocks(from: message)\n        var rows: [SessionRow] = []\n\n        // Collect all text blocks and images into a single user message\n        var textParts: [String] = []\n        var hasImage = false\n        var imageDataURLs: [String] = []\n\n        for block in blocks {\n            switch block.type {\n            case \"text\", nil:\n                if let text = Self.renderText(from: block), !text.isEmpty {\n                    textParts.append(text)\n                }\n            case \"image\":\n                hasImage = true\n                if let dataURL = Self.imageDataURL(from: block) {\n                    imageDataURLs.append(dataURL)\n                }\n            case \"tool_result\":\n                let outputValue: JSONValue? = {\n                    if let content = block.content { return content }\n                    if let text = block.text, !text.isEmpty { return .string(text) }\n                    if let rendered = Self.renderText(from: block), !rendered.isEmpty { return .string(rendered) }\n                    return nil\n                }()\n                if let outputValue {\n                    let item = ResponseItemPayload(\n                        type: \"tool_output\",\n                        status: nil,\n                        callID: block.toolUseId,\n                        name: block.name,\n                        content: nil,\n                        summary: nil,\n                        encryptedContent: nil,\n                        role: \"system\",\n                        arguments: nil,\n                        input: nil,\n                        output: outputValue,\n                        ghostCommit: nil)\n                    rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item)))\n                }\n            default:\n                break\n            }\n        }\n\n        // Create a single user message combining all text blocks\n        if !textParts.isEmpty || hasImage {\n            let combinedText = textParts.joined(separator: \"\\n\\n\")\n\n            // Check if this is a system-generated message that should be classified as \"other\"\n            let isSystemGenerated = combinedText.contains(\"<command-name>\") ||\n                                  combinedText.contains(\"<command-message>\") ||\n                                  combinedText.contains(\"<command-args>\") ||\n                                  combinedText.contains(\"<local-command-stdout>\") ||\n                                  combinedText.contains(\"<local-command-stderr>\") ||\n                                  combinedText.hasPrefix(\"Caveat: \")\n\n            let messageType = isSystemGenerated ? \"info_other\" : \"user_message\"\n            let displayText = hasImage && combinedText.isEmpty ? \"[Image]\" : combinedText\n\n            let payload = EventMessagePayload(\n                type: messageType,\n                message: displayText,\n                kind: nil,\n                text: displayText,\n                reason: nil,\n                info: nil,\n                rateLimits: nil,\n                images: imageDataURLs.isEmpty ? nil : imageDataURLs)\n            rows.insert(SessionRow(timestamp: timestamp, kind: .eventMessage(payload)), at: 0)\n        }\n\n        if let toolResult = line.toolUseResult {\n            let outputValue: JSONValue = toolResult\n            let payload = ResponseItemPayload(\n                type: \"tool_output\",\n                status: nil,\n                callID: nil,\n                name: nil,\n                content: nil,\n                summary: nil,\n                encryptedContent: nil,\n                role: \"system\",\n                arguments: nil,\n                input: nil,\n                output: outputValue,\n                ghostCommit: nil\n            )\n            rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(payload)))\n        }\n\n        if let usage = message.usage, let row = tokenUsageRow(usage, timestamp: timestamp) {\n            rows.append(row)\n        }\n\n        return rows\n    }\n\n    private func convertAssistant(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] {\n        guard let message = line.message else { return [] }\n        let blocks = Self.blocks(from: message)\n        var rows: [SessionRow] = []\n\n        for block in blocks {\n            switch block.type {\n            case \"text\", nil:\n                if let text = Self.renderText(from: block), !text.isEmpty {\n                    let payload = EventMessagePayload(\n                        type: \"agent_message\",\n                        message: text,\n                        kind: nil,\n                        text: text,\n                        reason: nil,\n                        info: nil,\n                        rateLimits: nil)\n                    rows.append(SessionRow(timestamp: timestamp, kind: .eventMessage(payload)))\n                }\n            case \"thinking\":\n                // Extended thinking block - count as reasoning\n                if let text = Self.renderText(from: block), !text.isEmpty {\n                    let item = ResponseItemPayload(\n                        type: \"reasoning\",\n                        status: nil,\n                        callID: block.id,\n                        name: nil,\n                        content: [ResponseContentBlock(type: \"text\", text: text)],\n                        summary: nil,\n                        encryptedContent: nil,\n                        role: \"assistant\",\n                        arguments: nil,\n                        input: nil,\n                        output: nil,\n                        ghostCommit: nil)\n                    rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item)))\n                }\n            case \"tool_use\":\n                let inputValue = block.input\n                let item = ResponseItemPayload(\n                    type: \"tool_call\",\n                    status: nil,\n                    callID: block.id,\n                    name: block.name,\n                    content: nil,\n                    summary: nil,\n                    encryptedContent: nil,\n                    role: \"assistant\",\n                    arguments: nil,\n                    input: inputValue,\n                    output: nil,\n                    ghostCommit: nil)\n                rows.append(SessionRow(timestamp: timestamp, kind: .responseItem(item)))\n            default:\n                break\n            }\n        }\n\n        if let usage = message.usage, let row = tokenUsageRow(usage, timestamp: timestamp) {\n            rows.append(row)\n        }\n\n        return rows\n    }\n\n    private func tokenUsageRow(_ usage: ClaudeUsage, timestamp: Date) -> SessionRow? {\n        var info: [String: JSONValue] = [:]\n        var hasNonZero = false\n\n        func addNumber(_ key: String, _ value: Int?) {\n            guard let value else { return }\n            info[key] = .number(Double(value))\n            if value > 0 { hasNonZero = true }\n        }\n\n        addNumber(\"input\", usage.inputTokens)\n        addNumber(\"output\", usage.outputTokens)\n        addNumber(\"cacheRead\", usage.cacheReadInputTokens)\n        addNumber(\"cacheCreation\", usage.cacheCreationInputTokens)\n        addNumber(\"total\", usage.totalTokens)\n\n        if let cache = usage.cacheCreation {\n            var cacheInfo: [String: JSONValue] = [:]\n            if let value = cache.ephemeral5m {\n                cacheInfo[\"ephemeral5m\"] = .number(Double(value))\n                if value > 0 { hasNonZero = true }\n            }\n            if let value = cache.ephemeral1h {\n                cacheInfo[\"ephemeral1h\"] = .number(Double(value))\n                if value > 0 { hasNonZero = true }\n            }\n            if !cacheInfo.isEmpty {\n                info[\"cacheCreationDetail\"] = .object(cacheInfo)\n            }\n        }\n\n        if let serverToolUse = usage.serverToolUse {\n            info[\"serverToolUse\"] = serverToolUse\n        }\n\n        if let tier = usage.serviceTier, !tier.isEmpty {\n            info[\"serviceTier\"] = .string(tier)\n        }\n\n        guard !info.isEmpty, hasNonZero else { return nil }\n        let payload = EventMessagePayload(\n            type: \"token_count\",\n            message: nil,\n            kind: nil,\n            text: nil,\n            reason: nil,\n            info: .object(info),\n            rateLimits: nil\n        )\n        return SessionRow(timestamp: timestamp, kind: .eventMessage(payload))\n    }\n\n    private func convertSystem(_ line: ClaudeLogLine, timestamp: Date) -> [SessionRow] {\n        guard let message = line.message else { return [] }\n        let text = Self.renderFlatText(message) ?? Self.renderText(from: Self.blocks(from: message).first)\n        guard let text, !text.isEmpty else { return [] }\n        let payload = EventMessagePayload(\n            type: \"system_message\",\n            message: text,\n            kind: line.subtype,\n            text: text,\n            reason: nil,\n            info: nil,\n            rateLimits: nil)\n        return [SessionRow(timestamp: timestamp, kind: .eventMessage(payload))]\n    }\n\n    private func buildSummary(\n        url: URL,\n        fileSize: UInt64?,\n        metaRow: SessionRow,\n        contextRow: SessionRow?,\n        additionalRows: [SessionRow],\n        totalTokens: Int,\n        tokenBreakdown: SessionTokenBreakdown?,\n        lastTimestamp: Date?,\n        activeDuration: TimeInterval?\n    ) -> SessionSummary? {\n        var builder = SessionSummaryBuilder()\n        builder.setSource(.claudeLocal)\n        builder.setFileSize(fileSize)\n        builder.seedTotalTokens(totalTokens)\n        if let breakdown = tokenBreakdown {\n            builder.seedTokenSnapshot(\n                input: breakdown.input,\n                output: breakdown.output,\n                cacheRead: breakdown.cacheRead,\n                cacheCreation: breakdown.cacheCreation\n            )\n        }\n\n        builder.observe(metaRow)\n        if let contextRow { builder.observe(contextRow) }\n        for row in additionalRows { builder.observe(row) }\n        if let lastTimestamp { builder.seedLastUpdated(lastTimestamp) }\n        builder.setModelFallback(\"Claude\")\n        guard let summary = builder.build(for: url) else { return nil }\n        if let activeDuration {\n            return SessionSummary(\n                id: summary.id,\n                fileURL: summary.fileURL,\n                fileSizeBytes: summary.fileSizeBytes,\n                startedAt: summary.startedAt,\n                endedAt: summary.endedAt,\n                activeDuration: activeDuration,\n                cliVersion: summary.cliVersion,\n                cwd: summary.cwd,\n                originator: summary.originator,\n                instructions: summary.instructions,\n                model: summary.model,\n                approvalPolicy: summary.approvalPolicy,\n                userMessageCount: summary.userMessageCount,\n                assistantMessageCount: summary.assistantMessageCount,\n                toolInvocationCount: summary.toolInvocationCount,\n                responseCounts: summary.responseCounts,\n                turnContextCount: summary.turnContextCount,\n                totalTokens: summary.totalTokens,\n                tokenBreakdown: summary.tokenBreakdown,\n                eventCount: summary.eventCount,\n                lineCount: summary.lineCount,\n                lastUpdatedAt: summary.lastUpdatedAt,\n                source: summary.source,\n                remotePath: summary.remotePath,\n                userTitle: summary.userTitle,\n                userComment: summary.userComment,\n                taskId: summary.taskId\n            )\n        }\n        return summary\n    }\n\n    private static func blocks(from message: ClaudeMessage) -> [ClaudeContentBlock] {\n        switch message.content {\n        case .string(let text):\n            return [ClaudeContentBlock(type: \"text\", text: text, thinking: nil, id: nil, name: nil, input: nil, toolUseId: nil, content: nil, signature: nil, source: nil)]\n        case .blocks(let blocks):\n            return blocks\n        case .none:\n            return []\n        }\n    }\n\n    private static func renderFlatText(_ message: ClaudeMessage) -> String? {\n        switch message.content {\n        case .string(let text):\n            return text\n        case .blocks(let blocks):\n            let rendered = blocks.compactMap { renderText(from: $0) }.joined(separator: \"\\n\")\n            return rendered.isEmpty ? nil : rendered\n        case .none:\n            return nil\n        }\n    }\n\n    private static func renderText(from block: ClaudeContentBlock?) -> String? {\n        guard let block else { return nil }\n        if let text = block.text, !text.isEmpty { return text }\n        if let thinking = block.thinking, !thinking.isEmpty { return thinking }\n        if let rendered = block.content.flatMap({ stringify($0) }), !rendered.isEmpty {\n            return rendered\n        }\n        if let rendered = block.input.flatMap({ stringify($0) }), !rendered.isEmpty {\n            return rendered\n        }\n        return nil\n    }\n\n    private static func imageDataURL(from block: ClaudeContentBlock) -> String? {\n        guard block.type == \"image\", let source = block.source else { return nil }\n        guard let data = source.data, !data.isEmpty else { return nil }\n        let mediaType = source.mediaType?.trimmingCharacters(in: .whitespacesAndNewlines)\n        let resolvedType = (mediaType?.isEmpty == false) ? mediaType! : \"image/png\"\n        return \"data:\\(resolvedType);base64,\\(data)\"\n    }\n\n    private static func stringify(_ value: JSONValue?) -> String? {\n        guard let value else { return nil }\n        switch value {\n        case .string(let str):\n            return str\n        case .number(let number):\n            return String(number)\n        case .bool(let flag):\n            return flag ? \"true\" : \"false\"\n        case .array(let array):\n            let rendered = array.compactMap { stringify($0) }.joined(separator: \"\\n\")\n            return rendered.isEmpty ? nil : rendered\n        case .object(let object):\n            let raw = object.mapValues { $0.toAnyValue() }\n            guard JSONSerialization.isValidJSONObject(raw),\n                  let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]),\n                  let text = String(data: data, encoding: .utf8) else {\n                return nil\n            }\n            return text\n        case .null:\n            return nil\n        }\n    }\n\n    private struct MetadataAccumulator {\n        var sessionId: String?\n        var agentId: String?\n        var version: String?\n        var cwd: String?\n        var model: String?\n        var instructions: String?\n        var firstTimestamp: Date?\n        var lastTimestamp: Date?\n        var totalTokens: Int = 0\n        var tokenInput: Int = 0\n        var tokenOutput: Int = 0\n        var tokenCacheRead: Int = 0\n        var tokenCacheCreation: Int = 0\n        var seenMessageIds: Set<String> = []\n        var usageByMessageId: [String: UsageSnapshot] = [:]\n\n        struct UsageSnapshot {\n            let total: Int\n            let input: Int\n            let output: Int\n            let cacheRead: Int\n            let cacheCreation: Int\n        }\n\n        mutating func consume(\n            _ line: ClaudeLogLine,\n            renderedText: String?,\n            model: String?,\n            usageTokens: Int?\n        ) {\n            if let sid = line.sessionId, sessionId == nil { sessionId = sid }\n            if let aid = line.agentId, agentId == nil { agentId = aid }\n            if let ver = line.version, version == nil { version = ver }\n            if let path = line.cwd, cwd == nil { cwd = path }\n            if let timestamp = line.timestamp {\n                if firstTimestamp == nil || timestamp < firstTimestamp! { firstTimestamp = timestamp }\n                if lastTimestamp == nil || timestamp > lastTimestamp! { lastTimestamp = timestamp }\n            }\n            if instructions == nil, line.isMeta == true,\n               let text = renderedText, !text.isEmpty {\n                instructions = text\n            }\n            if self.model == nil, let model, !model.isEmpty {\n                self.model = model\n            }\n            let messageId = line.message?.id\n            let isNewMessage = messageId.map { seenMessageIds.insert($0).inserted } ?? true\n\n            if let usage = line.message?.usage {\n                let snapshot = UsageSnapshot(\n                    total: usage.totalTokens,\n                    input: usage.inputTokens ?? 0,\n                    output: usage.outputTokens ?? 0,\n                    cacheRead: usage.cacheReadInputTokens ?? 0,\n                    cacheCreation: (usage.cacheCreationInputTokens ?? 0)\n                        + (usage.cacheCreation?.ephemeral5m ?? 0)\n                        + (usage.cacheCreation?.ephemeral1h ?? 0)\n                )\n                applyUsage(snapshot: snapshot, messageId: messageId, isNewMessage: isNewMessage)\n            } else if let usageTokens, usageTokens > 0 {\n                let snapshot = UsageSnapshot(total: usageTokens, input: 0, output: 0, cacheRead: 0, cacheCreation: 0)\n                applyUsage(snapshot: snapshot, messageId: messageId, isNewMessage: isNewMessage)\n            }\n        }\n\n        private mutating func applyUsage(snapshot: UsageSnapshot, messageId: String?, isNewMessage: Bool) {\n            // For messages with IDs, accumulate deltas (streamed usage updates share the same ID)\n            if let messageId {\n                let previous = usageByMessageId[messageId]\n                let deltaTotal = snapshot.total - (previous?.total ?? 0)\n                let deltaInput = snapshot.input - (previous?.input ?? 0)\n                let deltaOutput = snapshot.output - (previous?.output ?? 0)\n                let deltaCacheRead = snapshot.cacheRead - (previous?.cacheRead ?? 0)\n                let deltaCacheCreation = snapshot.cacheCreation - (previous?.cacheCreation ?? 0)\n\n                if deltaTotal > 0 { totalTokens &+= deltaTotal }\n                if deltaInput > 0 { tokenInput &+= deltaInput }\n                if deltaOutput > 0 { tokenOutput &+= deltaOutput }\n                if deltaCacheRead > 0 { tokenCacheRead &+= deltaCacheRead }\n                if deltaCacheCreation > 0 { tokenCacheCreation &+= deltaCacheCreation }\n                usageByMessageId[messageId] = snapshot\n                return\n            }\n\n            // Messages without IDs: retain legacy behavior to avoid over-counting duplicated lines.\n            if isNewMessage {\n                totalTokens &+= snapshot.total\n                tokenInput &+= snapshot.input\n                tokenOutput &+= snapshot.output\n                tokenCacheRead &+= snapshot.cacheRead\n                tokenCacheCreation &+= snapshot.cacheCreation\n            }\n        }\n\n        func makeMetaRow() -> SessionRow? {\n            guard let sessionId, let timestamp = firstTimestamp, let cwd else { return nil }\n            let payload = SessionMetaPayload(\n                id: sessionId,\n                timestamp: timestamp,\n                cwd: cwd,\n                originator: \"Claude Code\",\n                cliVersion: \"claude-code \\(version ?? \"unknown\")\",\n                instructions: instructions\n            )\n            return SessionRow(timestamp: timestamp, kind: .sessionMeta(payload))\n        }\n\n        func makeContextRow() -> SessionRow? {\n            // For Claude sessions, we don't generate context update rows.\n            // Model info is already shown in the session info card at the top.\n            // This avoids duplicate \"Syncing / Context Updated / model: xxx\" entries in the timeline.\n            return nil\n        }\n\n        func tokenBreakdown() -> SessionTokenBreakdown? {\n            let input = tokenInput\n            let output = tokenOutput\n            let cacheRead = tokenCacheRead\n            let cacheCreation = tokenCacheCreation\n            if input == 0 && output == 0 && cacheRead == 0 && cacheCreation == 0 {\n                return nil\n            }\n            return SessionTokenBreakdown(\n                input: input,\n                output: output,\n                cacheRead: cacheRead,\n                cacheCreation: cacheCreation\n            )\n        }\n    }\n\n    private struct ClaudeLogLine: Decodable {\n        let type: String?\n        let timestamp: Date?\n        let sessionId: String?\n        let agentId: String?\n        let version: String?\n        let cwd: String?\n        let message: ClaudeMessage?\n        let toolUseResult: JSONValue?\n        let summary: String?\n        let isMeta: Bool?\n        let subtype: String?\n        let isSidechain: Bool?\n        let uuid: String?\n    }\n\n    private struct ClaudeMessage: Decodable {\n        let id: String?\n        let role: String?\n        let model: String?\n        let content: ClaudeMessageContent?\n        let usage: ClaudeUsage?\n\n        enum CodingKeys: String, CodingKey {\n            case id\n            case role\n            case model\n            case content\n            case usage\n        }\n    }\n\n    private struct ClaudeUsage: Decodable {\n        let inputTokens: Int?\n        let outputTokens: Int?\n        let cacheReadInputTokens: Int?\n        let cacheCreationInputTokens: Int?\n        let cacheCreation: CacheCreation?\n        let serverToolUse: JSONValue?\n        let serviceTier: String?\n\n        enum CodingKeys: String, CodingKey {\n            case inputTokens = \"input_tokens\"\n            case outputTokens = \"output_tokens\"\n            case cacheReadInputTokens = \"cache_read_input_tokens\"\n            case cacheCreationInputTokens = \"cache_creation_input_tokens\"\n            case cacheCreation = \"cache_creation\"\n            case serverToolUse = \"server_tool_use\"\n            case serviceTier = \"service_tier\"\n        }\n\n        struct CacheCreation: Decodable {\n            let ephemeral5m: Int?\n            let ephemeral1h: Int?\n\n            enum CodingKeys: String, CodingKey {\n                case ephemeral5m = \"ephemeral_5m_input_tokens\"\n                case ephemeral1h = \"ephemeral_1h_input_tokens\"\n            }\n        }\n\n        var totalTokens: Int {\n            let creation = (cacheCreationInputTokens ?? 0) +\n                (cacheCreation?.ephemeral5m ?? 0) +\n                (cacheCreation?.ephemeral1h ?? 0)\n            return (inputTokens ?? 0) + (outputTokens ?? 0) + (cacheReadInputTokens ?? 0) + creation\n        }\n    }\n\n    private enum ClaudeMessageContent: Decodable {\n        case string(String)\n        case blocks([ClaudeContentBlock])\n\n        init(from decoder: Decoder) throws {\n            let container = try decoder.singleValueContainer()\n            if let text = try? container.decode(String.self) {\n                self = .string(text)\n                return\n            }\n            if let block = try? container.decode(ClaudeContentBlock.self) {\n                self = .blocks([block])\n                return\n            }\n            if let blocks = try? container.decode([ClaudeContentBlock].self) {\n                self = .blocks(blocks)\n                return\n            }\n            self = .blocks([])\n        }\n    }\n\n    private struct ClaudeImageSource: Decodable {\n        let type: String?\n        let mediaType: String?\n        let data: String?\n\n        enum CodingKeys: String, CodingKey {\n            case type\n            case mediaType = \"media_type\"\n            case data\n        }\n    }\n\n    private struct ClaudeContentBlock: Decodable {\n        let type: String?\n        let text: String?\n        let thinking: String?\n        let id: String?\n        let name: String?\n        let input: JSONValue?\n        let toolUseId: String?\n        let content: JSONValue?\n        let signature: String?\n        let source: ClaudeImageSource?\n    }\n\n    private static func countToolUses(in message: ClaudeMessage?, seen: inout Set<String>) -> Int {\n        guard let message else { return 0 }\n        switch message.content {\n        case .blocks(let blocks):\n            return blocks.reduce(0) { partial, block in\n                guard block.type == \"tool_use\" else { return partial }\n                if let id = block.id {\n                    return seen.insert(id).inserted ? partial + 1 : partial\n                }\n                return partial + 1\n            }\n        case .string, .none:\n            return 0\n        }\n    }\n\n    private static func hasRenderableText(_ message: ClaudeMessage?) -> Bool {\n        guard let message else { return false }\n        switch message.content {\n        case .string(let text):\n            return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        case .blocks(let blocks):\n            return blocks.contains { block in\n                guard let rendered = renderText(from: block) else { return false }\n                return !rendered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n            }\n        case .none:\n            return false\n        }\n    }\n}\n\nprivate extension JSONValue {\n    func toAnyValue() -> Any {\n        switch self {\n        case .string(let str): return str\n        case .number(let number): return number\n        case .bool(let flag): return flag\n        case .array(let array): return array.map { $0.toAnyValue() }\n        case .object(let dict): return dict.mapValues { $0.toAnyValue() }\n        case .null: return NSNull()\n        }\n    }\n}\n"
  },
  {
    "path": "services/ClaudeSessionProvider.swift",
    "content": "import Foundation\n#if canImport(Darwin)\nimport Darwin\n#endif\n\nactor ClaudeSessionProvider {\n    enum SessionProviderCacheError: Error {\n        case cacheUnavailable\n    }\n\n    private let parser = ClaudeSessionParser()\n    private let fileManager: FileManager\n    private let root: URL?\n    private let cacheStore: SessionIndexSQLiteStore?\n    // Best-effort cache: sessionId -> canonical file URL (updated on scans)\n    private var canonicalURLById: [String: URL] = [:]\n    // mtime/size summary cache to skip re-parse when unchanged\n    private var summaryCache: [String: CacheEntry] = [:]\n\n    private struct CacheEntry {\n        let modificationDate: Date?\n        let fileSize: UInt64?\n        let summary: SessionSummary\n    }\n\n    init(fileManager: FileManager = .default, cacheStore: SessionIndexSQLiteStore? = nil) {\n        self.fileManager = fileManager\n        self.cacheStore = cacheStore\n        // Use real user home directory, not sandbox container\n        let home = Self.getRealUserHomeURL()\n        let projects = home\n            .appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"projects\", isDirectory: true)\n        root = fileManager.fileExists(atPath: projects.path) ? projects : nil\n    }\n    \n    /// Get the real user home directory (not sandbox container)\n    private static func getRealUserHomeURL() -> URL {\n        #if canImport(Darwin)\n        if let homeDir = getpwuid(getuid())?.pointee.pw_dir {\n            let path = String(cString: homeDir)\n            return URL(fileURLWithPath: path, isDirectory: true)\n        }\n        #endif\n        if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n            return URL(fileURLWithPath: home, isDirectory: true)\n        }\n        return FileManager.default.homeDirectoryForCurrentUser\n    }\n\n    func sessions(scope: SessionLoadScope, ignoredPaths: [String] = []) async throws -> [SessionSummary] {\n        guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable }\n        guard let root else { return [] }\n        guard let enumerator = fileManager.enumerator(\n            at: root,\n            includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants])\n        else { return [] }\n\n        // Gather all parsed summaries then dedupe by sessionId,\n        // preferring canonical filenames and newer/longer files.\n        var bestById: [String: SessionSummary] = [:]\n        let urls = enumerator.compactMap { $0 as? URL }\n        for url in urls {\n            guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n            // Apply ignore rules\n            if shouldIgnorePath(url.path, ignoredPaths: ignoredPaths) {\n                continue\n            }\n            let values = try url.resourceValues(\n                forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey])\n            guard values.isRegularFile == true else { continue }\n            let fileSize = resolveFileSize(for: url, resourceValues: values)\n            let mtime = values.contentModificationDate\n            let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize)\n                ?? parser.parse(at: url, fileSize: fileSize)?.summary\n            guard let summary else { continue }\n            // Check ignore rules against cwd\n            // Note: If ignored, we skip caching this newly parsed session.\n            // Existing cache entries remain untouched - they will be filtered out when reading,\n            // but will reappear if ignore rules are removed later (cache preservation strategy).\n            if shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) {\n                continue\n            }\n            guard matches(scope: scope, summary: summary) else { continue }\n            cache(summary: summary, for: url, modificationDate: mtime, fileSize: fileSize)\n            persist(summary: summary, modificationDate: mtime, fileSize: fileSize)\n\n            if let existing = bestById[summary.id] {\n                let pick = prefer(lhs: existing, rhs: summary)\n                bestById[summary.id] = pick\n            } else {\n                bestById[summary.id] = summary\n            }\n        }\n\n        // Update canonical map for later fallbacks\n        for (_, s) in bestById { canonicalURLById[s.id] = s.fileURL }\n        return Array(bestById.values)\n    }\n\n    /// Load only the sessions under a specific project directory (e.g. ~/.claude/projects/-Users-loocor-GitHub-CodMate)\n    /// Directory should be the original project cwd; it will be encoded to Claude's folder name.\n    func sessions(inProjectDirectory directory: String) async throws -> [SessionSummary] {\n        guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable }\n        guard let root else { return [] }\n        let folder = encodeProjectFolder(from: directory)\n        let projectURL = root.appendingPathComponent(folder, isDirectory: true)\n        guard let enumerator = fileManager.enumerator(\n            at: projectURL,\n            includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants])\n        else { return [] }\n\n        var results: [SessionSummary] = []\n        let urls = enumerator.compactMap { $0 as? URL }\n        for url in urls {\n            guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n            let values = try url.resourceValues(\n                forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey])\n            guard values.isRegularFile == true else { continue }\n            let fileSize = resolveFileSize(for: url, resourceValues: values)\n            let mtime = values.contentModificationDate\n            let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize)\n                ?? parser.parse(at: url, fileSize: fileSize)?.summary\n\n            if let summary {\n                cache(summary: summary, for: url, modificationDate: mtime, fileSize: fileSize)\n                persist(summary: summary, modificationDate: mtime, fileSize: fileSize)\n                results.append(summary)\n            }\n        }\n        return results\n    }\n\n    private func encodeProjectFolder(from cwd: String) -> String {\n        let expanded = (cwd as NSString).expandingTildeInPath\n        var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path\n        if standardized.hasSuffix(\"/\") && standardized.count > 1 { standardized.removeLast() }\n        var name = standardized.replacingOccurrences(of: \":\", with: \"-\")\n        name = name.replacingOccurrences(of: \"/\", with: \"-\")\n        if !name.hasPrefix(\"-\") { name = \"-\" + name }\n        return name\n    }\n\n    func countAllSessions() -> Int {\n        guard let root else { return 0 }\n        guard let enumerator = fileManager.enumerator(\n            at: root,\n            includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants])\n        else { return 0 }\n        var total = 0\n        for case let url as URL in enumerator where url.pathExtension.lowercased() == \"jsonl\" {\n            let name = url.deletingPathExtension().lastPathComponent\n            if name.hasPrefix(\"agent-\") { continue }\n            let values = try? url.resourceValues(forKeys: [.fileSizeKey])\n            if let size = values?.fileSize, size == 0 { continue }\n            total += 1\n        }\n        return total\n    }\n\n    func collectCWDCounts() async -> [String: Int] {\n        guard let root else { return [:] }\n        guard let enumerator = fileManager.enumerator(\n            at: root,\n            includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants])\n        else { return [:] }\n\n        var counts: [String: Int] = [:]\n        let urls = enumerator.compactMap { $0 as? URL }\n        do {\n            for url in urls {\n                guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n                let values = try url.resourceValues(\n                    forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey])\n                guard values.isRegularFile == true else { continue }\n                let fileSize = resolveFileSize(for: url, resourceValues: values)\n                let mtime = values.contentModificationDate\n                if let summary = try await cachedSummary(for: url, modificationDate: mtime, fileSize: fileSize) {\n                    counts[summary.cwd, default: 0] += 1\n                    continue\n                }\n                if let parsed = parser.parse(at: url, fileSize: fileSize) {\n                    cache(summary: parsed.summary, for: url, modificationDate: mtime, fileSize: fileSize)\n                    counts[parsed.summary.cwd, default: 0] += 1\n                }\n            }\n        } catch {\n            return [:]\n        }\n        return counts\n    }\n\n    func enrich(summary: SessionSummary) -> SessionSummary? {\n        guard summary.source.baseKind == .claude else { return summary }\n        // Parse using canonical file path when available\n        let url = resolveCanonicalURL(for: summary)\n        guard let parsed = parser.parse(at: url) else { return nil }\n        let loader = SessionTimelineLoader()\n        let turns = loader.turns(from: parsed.rows)\n        let activeDuration = computeActiveDuration(turns: turns)\n\n        return SessionSummary(\n            id: parsed.summary.id,\n            fileURL: parsed.summary.fileURL,\n            fileSizeBytes: parsed.summary.fileSizeBytes,\n            startedAt: parsed.summary.startedAt,\n            endedAt: parsed.summary.endedAt,\n            activeDuration: activeDuration,\n            cliVersion: parsed.summary.cliVersion,\n            cwd: parsed.summary.cwd,\n            originator: parsed.summary.originator,\n            instructions: parsed.summary.instructions,\n            model: parsed.summary.model,\n            approvalPolicy: parsed.summary.approvalPolicy,\n            userMessageCount: parsed.summary.userMessageCount,\n            assistantMessageCount: parsed.summary.assistantMessageCount,\n            toolInvocationCount: parsed.summary.toolInvocationCount,\n            responseCounts: parsed.summary.responseCounts,\n            turnContextCount: parsed.summary.turnContextCount,\n            messageTypeCounts: parsed.summary.messageTypeCounts,\n            totalTokens: parsed.summary.totalTokens,\n            tokenBreakdown: parsed.summary.tokenBreakdown,\n            eventCount: parsed.summary.eventCount,\n            lineCount: parsed.summary.lineCount,\n            lastUpdatedAt: parsed.summary.lastUpdatedAt,\n            source: parsed.summary.source,\n            remotePath: parsed.summary.remotePath,\n            userTitle: parsed.summary.userTitle,\n            userComment: parsed.summary.userComment\n        )\n    }\n\n    func timeline(for summary: SessionSummary) -> [ConversationTurn]? {\n        guard summary.source.baseKind == .claude else { return nil }\n        let url = resolveCanonicalURL(for: summary)\n        guard let parsed = parser.parse(at: url) else { return nil }\n        let loader = SessionTimelineLoader()\n        return loader.turns(from: parsed.rows)\n    }\n\n    private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool {\n        let calendar = Calendar.current\n        let referenceDates = [\n            summary.startedAt,\n            summary.lastUpdatedAt ?? summary.startedAt\n        ]\n        switch scope {\n        case .all:\n            return true\n        case .today:\n            return referenceDates.contains(where: { calendar.isDateInToday($0) })\n        case .day(let day):\n            return referenceDates.contains(where: { calendar.isDate($0, inSameDayAs: day) })\n        case .month(let date):\n            return referenceDates.contains {\n                calendar.isDate($0, equalTo: date, toGranularity: .month)\n            }\n        }\n    }\n\n    private func computeActiveDuration(turns: [ConversationTurn]) -> TimeInterval? {\n        guard !turns.isEmpty else { return nil }\n        let filtered = turns.removingEnvironmentContext()\n        guard !filtered.isEmpty else { return nil }\n        var total: TimeInterval = 0\n        for turn in filtered {\n            let start = turn.userMessage?.timestamp ?? turn.outputs.first?.timestamp\n            guard let s = start, let end = turn.outputs.last?.timestamp else { continue }\n            let delta = end.timeIntervalSince(s)\n            if delta > 0 { total += delta }\n        }\n        return total\n    }\n\n    private func cachedSummary(for url: URL, modificationDate: Date?, fileSize: UInt64?) async throws -> SessionSummary? {\n        if let entry = summaryCache[url.path],\n           entry.modificationDate == modificationDate,\n           entry.fileSize == fileSize {\n            canonicalURLById[entry.summary.id] = url\n            return entry.summary\n        }\n        guard let cacheStore, let modificationDate else { return nil }\n        guard let cached = try await cacheStore.fetch(\n            path: url.path,\n            modificationDate: modificationDate,\n            fileSize: fileSize\n        ) else { return nil }\n        cache(summary: cached, for: url, modificationDate: modificationDate, fileSize: fileSize)\n        return cached\n    }\n\n    private func cache(summary: SessionSummary, for url: URL, modificationDate: Date?, fileSize: UInt64?) {\n        summaryCache[url.path] = CacheEntry(modificationDate: modificationDate, fileSize: fileSize, summary: summary)\n        canonicalURLById[summary.id] = url\n    }\n\n    private func persist(summary: SessionSummary, modificationDate: Date?, fileSize: UInt64?) {\n        guard let cacheStore else { return }\n        Task.detached { [cacheStore] in\n            try? await cacheStore.upsert(\n                summary: summary,\n                project: nil,\n                fileModificationTime: modificationDate,\n                fileSize: fileSize,\n                tokenBreakdown: summary.tokenBreakdown,\n                parseError: nil\n            )\n        }\n    }\n\n    private func resolveFileSize(for url: URL) -> UInt64? {\n        if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),\n           let size = values.fileSize {\n            return UInt64(size)\n        }\n        if let attributes = try? fileManager.attributesOfItem(atPath: url.path),\n           let number = attributes[.size] as? NSNumber {\n            return number.uint64Value\n        }\n        return nil\n    }\n\n    private func resolveFileSize(for url: URL, resourceValues: URLResourceValues) -> UInt64? {\n        if let size = resourceValues.fileSize { return UInt64(size) }\n        return resolveFileSize(for: url)\n    }\n\n    // MARK: - Canonical resolution and dedupe helpers\n\n    /// Prefer canonical filename and more complete/updated files for the same session ID.\n    /// Heuristics:\n    /// - Prefer non \"agent-\" filenames over \"agent-\" (agent is an early placeholder)\n    /// - If both non-agent, pick the one with later lastUpdated or larger file size\n    private func prefer(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary {\n        if lhs.id != rhs.id { return lhs } // shouldn't happen, but keep lhs\n        let isAgentL = lhs.fileURL.deletingPathExtension().lastPathComponent.hasPrefix(\"agent-\")\n        let isAgentR = rhs.fileURL.deletingPathExtension().lastPathComponent.hasPrefix(\"agent-\")\n        if isAgentL != isAgentR { return isAgentL ? rhs : lhs }\n        // Both same class; prefer newer lastUpdated, then larger size\n        let lt = lhs.lastUpdatedAt ?? lhs.startedAt\n        let rt = rhs.lastUpdatedAt ?? rhs.startedAt\n        if lt != rt { return lt > rt ? lhs : rhs }\n        let ls = lhs.fileSizeBytes ?? 0\n        let rs = rhs.fileSizeBytes ?? 0\n        if ls != rs { return ls > rs ? lhs : rhs }\n        // Stable fallback: lexical by filename to reduce churn\n        return lhs.fileURL.lastPathComponent < rhs.fileURL.lastPathComponent ? lhs : rhs\n    }\n\n    /// Resolve a stable file URL for a session summary. Handles cases where the\n    /// initial file was \"agent-*.jsonl\" and later renamed to canonical UUID or\n    /// rollout-named files. Falls back to summary.fileURL if nothing better is found.\n    private func resolveCanonicalURL(for summary: SessionSummary) -> URL {\n        // 1) If file exists and is readable, use it.\n        if fileManager.fileExists(atPath: summary.fileURL.path) {\n            return summary.fileURL\n        }\n        // 2) Return cached mapping if available\n        if let cached = canonicalURLById[summary.id], fileManager.fileExists(atPath: cached.path) {\n            return cached\n        }\n        // 3) Probe sibling files under the project folder for a better match\n        let dir = summary.fileURL.deletingLastPathComponent()\n        if let best = findSibling(bySessionId: summary.id, inDirectory: dir) {\n            canonicalURLById[summary.id] = best\n            return best\n        }\n        // 4) As a last resort, scan the entire Claude root\n        if let root, let best = findSibling(bySessionId: summary.id, inDirectory: root) {\n            canonicalURLById[summary.id] = best\n            return best\n        }\n        return summary.fileURL\n    }\n\n    /// Find a file in the given directory tree that belongs to the sessionId,\n    /// preferring non-agent names and newest mtime.\n    private func findSibling(bySessionId sessionId: String, inDirectory base: URL) -> URL? {\n        guard let enumerator = fileManager.enumerator(\n            at: base,\n            includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants]) else { return nil }\n\n        var candidates: [(url: URL, mtime: Date, isAgent: Bool)] = []\n        for case let url as URL in enumerator {\n            guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n            // Quick filename check: many canonical files include the sessionId directly\n            let name = url.deletingPathExtension().lastPathComponent\n            if name.contains(sessionId) {\n                let mtime = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast\n                candidates.append((url, mtime, name.hasPrefix(\"agent-\")))\n                continue\n            }\n            // As a fallback, peek the sessionId from file contents (cheap prefix scan)\n            if let sid = parser.fastSessionId(at: url), sid == sessionId {\n                let mtime = (try? url.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate) ?? .distantPast\n                candidates.append((url, mtime, name.hasPrefix(\"agent-\")))\n            }\n        }\n        guard !candidates.isEmpty else { return nil }\n        // Prefer non-agent, then newest mtime\n        candidates.sort { a, b in\n            if a.isAgent != b.isAgent { return !a.isAgent } // non-agent first\n            if a.mtime != b.mtime { return a.mtime > b.mtime }\n            return a.url.lastPathComponent < b.url.lastPathComponent\n        }\n        return candidates.first?.url\n    }\n}\n\n// MARK: - SessionProvider\n\nextension ClaudeSessionProvider: SessionProvider {\n    nonisolated var kind: SessionSource.Kind { .claude }\n    nonisolated var identifier: String { \"claude-local\" }\n    nonisolated var label: String { \"Claude (local)\" }\n\n    func load(context: SessionProviderContext) async throws -> SessionProviderResult {\n        switch context.cachePolicy {\n        case .cacheOnly:\n            if let cacheStore {\n                let dateColumn = context.dateDimension == .updated ? \"COALESCE(last_updated_at, started_at)\" : \"started_at\"\n                let range = context.dateRange ?? Self.dateRange(for: context.scope)\n                var cached = try await cacheStore.fetchSummaries(\n                    kinds: [.claude],\n                    includeRemote: false,\n                    dateColumn: dateColumn,\n                    dateRange: range,\n                    projectIds: context.projectIds\n                )\n                // Apply ignore rules to cached results\n                let originalCount = cached.count\n                if !context.ignoredPaths.isEmpty {\n                    cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) }\n                    print(\"ClaudeSessionProvider: filtered \\(originalCount - cached.count) sessions by ignore rules (\\(cached.count) remain)\")\n                }\n                if !cached.isEmpty {\n                    return SessionProviderResult(summaries: cached, coverage: nil, cacheHit: true)\n                }\n            }\n            return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true)\n        case .refresh:\n            guard let cacheStore else { throw SessionProviderCacheError.cacheUnavailable }\n            // Require cache availability; if missing/unopenable, surface error instead of falling back to parse.\n            _ = try await cacheStore.fetchMeta()\n            let summaries = try await sessions(scope: context.scope, ignoredPaths: context.ignoredPaths)\n            return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false)\n        }\n    }\n\n    private static func dateRange(for scope: SessionLoadScope) -> (Date, Date)? {\n        let cal = Calendar.current\n        switch scope {\n        case .all:\n            return nil\n        case .today:\n            let start = cal.startOfDay(for: Date())\n            guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n            return (start, end)\n        case .day(let day):\n            let start = cal.startOfDay(for: day)\n            guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n            return (start, end)\n        case .month(let date):\n            guard\n              let start = cal.date(from: cal.dateComponents([.year, .month], from: date)),\n              let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start)\n            else { return nil }\n            return (start, end)\n        }\n    }\n    \n  // MARK: - Ignore Rules\n  \n  private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths)\n  }\n  \n  private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths)\n  }\n}\n"
  },
  {
    "path": "services/ClaudeSettingsService.swift",
    "content": "import Foundation\n\n// MARK: - Claude Code user settings writer (~/.claude/settings.json)\n\nactor ClaudeSettingsService {\n    struct Paths {\n        let dir: URL\n        let file: URL\n        static func `default`() -> Paths {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let dir = home.appendingPathComponent(\".claude\", isDirectory: true)\n            return Paths(dir: dir, file: dir.appendingPathComponent(\"settings.json\", isDirectory: false))\n        }\n    }\n\n    // MARK: - Runtime composite\n    struct Runtime: Sendable {\n        var permissionMode: String? // default/acceptEdits/bypassPermissions/plan\n        var skipPermissions: Bool\n        var allowSkipPermissions: Bool\n        var debug: Bool\n        var debugFilter: String?\n        var verbose: Bool\n        var ide: Bool\n        var strictMCP: Bool\n        var fallbackModel: String?\n        var allowedTools: String?\n        var disallowedTools: String?\n        var addDirs: [String]?\n    }\n\n    struct NotificationHooksStatus: Sendable {\n        var permissionHookInstalled: Bool\n        var completionHookInstalled: Bool\n    }\n\n    private enum HookEvent: String {\n        case permission\n        case complete\n    }\n\n    private struct HookPayload {\n        var title: String\n        var body: String\n    }\n\n    private let codMateHookURLPrefix = \"codmate://notify?source=claude&event=\"\n    private let claudeNotificationKey = \"Notification\"\n    private let claudeStopKey = \"Stop\"\n    private let codMateManagedHookNamePrefix = \"codmate-hook:\"\n\n    func applyRuntime(_ r: Runtime) throws {\n        var obj = loadObject()\n        func setOrRemove(_ key: String, _ value: Any?) {\n            if let v = value {\n                obj[key] = v\n            } else {\n                obj.removeValue(forKey: key)\n            }\n        }\n        // permissionMode: omit when default\n        let pm = (r.permissionMode == nil || r.permissionMode == \"default\") ? nil : r.permissionMode\n        setOrRemove(\"permissionMode\", pm)\n        // booleans: only store when true to keep file light\n        setOrRemove(\"skipPermissions\", r.skipPermissions ? true : nil)\n        setOrRemove(\"allowSkipPermissions\", r.allowSkipPermissions ? true : nil)\n        setOrRemove(\"debug\", r.debug ? true : nil)\n        let df = (r.debugFilter?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.debugFilter : nil\n        setOrRemove(\"debugFilter\", df)\n        setOrRemove(\"verbose\", r.verbose ? true : nil)\n        setOrRemove(\"ide\", r.ide ? true : nil)\n        setOrRemove(\"strictMCP\", r.strictMCP ? true : nil)\n        let fb = (r.fallbackModel?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.fallbackModel : nil\n        setOrRemove(\"fallbackModel\", fb)\n        let at = (r.allowedTools?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.allowedTools : nil\n        let dt = (r.disallowedTools?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false) ? r.disallowedTools : nil\n        setOrRemove(\"allowedTools\", at)\n        setOrRemove(\"disallowedTools\", dt)\n        let dirs = (r.addDirs?.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })\n        setOrRemove(\"addDirs\", (dirs?.isEmpty == false) ? dirs : nil)\n        try writeObject(obj)\n    }\n\n    // MARK: - Notification hooks (CodMate-managed)\n    func codMateNotificationHooksStatus() -> NotificationHooksStatus {\n        let obj = loadObject()\n        guard let hooks = obj[\"hooks\"] as? [String: Any] else {\n            return NotificationHooksStatus(permissionHookInstalled: false, completionHookInstalled: false)\n        }\n        return NotificationHooksStatus(\n            permissionHookInstalled: containsCodMateHook(in: hooks, key: claudeNotificationKey, event: .permission),\n            completionHookInstalled: containsCodMateHook(in: hooks, key: claudeStopKey, event: .complete)\n        )\n    }\n\n    func setCodMateNotificationHooks(enabled: Bool) throws {\n        var obj = loadObject()\n        var hooks = obj[\"hooks\"] as? [String: Any] ?? [:]\n        hooks = updateHooksContainer(\n            hooks,\n            key: claudeNotificationKey,\n            event: .permission,\n            enabled: enabled\n        )\n        hooks = updateHooksContainer(\n            hooks,\n            key: claudeStopKey,\n            event: .complete,\n            enabled: enabled\n        )\n        if hooks.isEmpty {\n            obj.removeValue(forKey: \"hooks\")\n        } else {\n            obj[\"hooks\"] = hooks\n        }\n        try writeObject(obj)\n    }\n\n    // MARK: - User hooks (CodMate Extensions)\n    func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] {\n        var obj = loadObject()\n        if (obj[\"allowManagedHooksOnly\"] as? Bool) == true {\n            return [\n                HookSyncWarning(\n                    provider: .claude,\n                    message: \"Claude Code settings has allowManagedHooksOnly=true; skipping hooks apply.\"\n                )\n            ]\n        }\n\n        var warnings: [HookSyncWarning] = []\n        var hooks = obj[\"hooks\"] as? [String: Any] ?? [:]\n\n        // Remove previously applied CodMate-managed hooks (by name prefix).\n        hooks = pruneCodMateManagedHooks(hooks)\n\n        let filtered = rules.filter { $0.isEnabled(for: .claude) }\n        for rule in filtered {\n            let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !rawEvent.isEmpty else { continue }\n            let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .claude)\n            if resolution.isKnown, !resolution.isSupported {\n                warnings.append(HookSyncWarning(\n                    provider: .claude,\n                    message: \"Claude Code does not support hook event \\\"\\(rawEvent)\\\"; skipping \\\"\\(rule.name)\\\".\"\n                ))\n                continue\n            }\n            let event = resolution.name\n\n            let supportsMatcher = HookEventCatalog.supportsMatcher(resolution.canonicalName, provider: .claude)\n            let matcherText = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines)\n            let matcher = supportsMatcher ? (matcherText?.isEmpty == false ? matcherText : nil) : nil\n            if !supportsMatcher, matcherText?.isEmpty == false {\n                warnings.append(HookSyncWarning(\n                    provider: .claude,\n                    message: \"Claude hook event \\\"\\(event)\\\" does not support matcher; ignoring matcher for \\\"\\(rule.name)\\\".\"\n                ))\n            }\n\n            var hookObjects: [[String: Any]] = []\n            for (index, cmd) in rule.commands.enumerated() {\n                let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !program.isEmpty else { continue }\n                var hook: [String: Any] = [\n                    \"type\": \"command\",\n                    \"command\": program,\n                    \"name\": \"\\(codMateManagedHookNamePrefix)\\(rule.id):\\(index)\"\n                ]\n                if let args = cmd.args, !args.isEmpty { hook[\"args\"] = args }\n                if let timeout = cmd.timeoutMs { hook[\"timeout\"] = timeout }\n                if let env = cmd.env, !env.isEmpty {\n                    warnings.append(HookSyncWarning(\n                        provider: .claude,\n                        message: \"Claude Code hook commands do not support env in settings.json; ignoring env for \\\"\\(rule.name)\\\".\"\n                    ))\n                }\n                hookObjects.append(hook)\n            }\n            guard !hookObjects.isEmpty else { continue }\n\n            var entries = (hooks[event] as? [[String: Any]]) ?? []\n            let matcherKey: String? = matcher\n\n            if let idx = entries.firstIndex(where: { entry in\n                let existing = (entry[\"matcher\"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)\n                let existingKey = (existing?.isEmpty == false) ? existing : nil\n                return existingKey == matcherKey\n            }) {\n                var entry = entries[idx]\n                var nested = (entry[\"hooks\"] as? [[String: Any]]) ?? []\n                nested.append(contentsOf: hookObjects)\n                entry[\"hooks\"] = nested\n                entries[idx] = entry\n            } else {\n                var entry: [String: Any] = [\"hooks\": hookObjects]\n                if let matcherKey { entry[\"matcher\"] = matcherKey }\n                entries.append(entry)\n            }\n\n            hooks[event] = entries\n        }\n\n        if hooks.isEmpty {\n            obj.removeValue(forKey: \"hooks\")\n        } else {\n            obj[\"hooks\"] = hooks\n        }\n        try writeObject(obj)\n        return warnings\n    }\n\n    func importHooksAsCodMateRules() -> [HookRule] {\n        let obj = loadObject()\n        guard let hooks = obj[\"hooks\"] as? [String: Any] else { return [] }\n        var rules: [HookRule] = []\n\n        for (event, value) in hooks {\n            guard let entries = value as? [[String: Any]] else { continue }\n            let canonicalEvent = HookEventCatalog.canonicalName(for: event, provider: .claude)\n            for entry in entries {\n                let matcher = (entry[\"matcher\"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard let hookList = entry[\"hooks\"] as? [[String: Any]] else { continue }\n\n                var commands: [HookCommand] = []\n                for hook in hookList {\n                    guard (hook[\"type\"] as? String) == \"command\" else { continue }\n                    guard let command = hook[\"command\"] as? String else { continue }\n                    if command.contains(codMateHookURLPrefix) { continue } // managed by Notifications UI\n                    let args = hook[\"args\"] as? [String]\n                    let timeout = (hook[\"timeout\"] as? Int) ?? (hook[\"timeout\"] as? NSNumber)?.intValue\n                    commands.append(HookCommand(command: command, args: args, env: nil, timeoutMs: timeout))\n                }\n                guard !commands.isEmpty else { continue }\n                let name = HookEventCatalog.defaultName(event: canonicalEvent, matcher: matcher, command: commands.first)\n                let targets = HookTargets(codex: false, claude: true, gemini: false)\n                rules.append(HookRule(\n                    name: name,\n                    event: canonicalEvent,\n                    matcher: (matcher?.isEmpty == false ? matcher : nil),\n                    commands: commands,\n                    enabled: true,\n                    targets: targets,\n                    source: \"import\"\n                ))\n            }\n        }\n        return rules\n    }\n\n    private func pruneCodMateManagedHooks(_ hooks: [String: Any]) -> [String: Any] {\n        var out: [String: Any] = [:]\n        for (event, value) in hooks {\n            guard let entries = value as? [[String: Any]] else {\n                out[event] = value\n                continue\n            }\n            var newEntries: [[String: Any]] = []\n            for var entry in entries {\n                guard var nested = entry[\"hooks\"] as? [[String: Any]] else {\n                    newEntries.append(entry)\n                    continue\n                }\n                nested.removeAll { hook in\n                    guard let name = hook[\"name\"] as? String else { return false }\n                    return name.hasPrefix(codMateManagedHookNamePrefix)\n                }\n                guard !nested.isEmpty else { continue }\n                entry[\"hooks\"] = nested\n                newEntries.append(entry)\n            }\n            if !newEntries.isEmpty {\n                out[event] = newEntries\n            }\n        }\n        return out\n    }\n\n    private func containsCodMateHook(in hooks: [String: Any], key: String, event: HookEvent) -> Bool {\n        guard let entries = hooks[key] as? [[String: Any]] else { return false }\n        let marker = \"\\(codMateHookURLPrefix)\\(event.rawValue)\"\n        for entry in entries {\n            guard let nested = entry[\"hooks\"] as? [[String: Any]] else { continue }\n            if nested.contains(where: { ($0[\"command\"] as? String)?.contains(marker) == true }) {\n                return true\n            }\n        }\n        return false\n    }\n\n    private func updateHooksContainer(\n        _ hooks: [String: Any],\n        key: String,\n        event: HookEvent,\n        enabled: Bool\n    ) -> [String: Any] {\n        var container = hooks\n        var entries = (container[key] as? [[String: Any]]) ?? []\n        let marker = \"\\(codMateHookURLPrefix)\\(event.rawValue)\"\n        entries.removeAll { entry in\n            guard let nested = entry[\"hooks\"] as? [[String: Any]] else { return false }\n            return nested.contains { ($0[\"command\"] as? String)?.contains(marker) == true }\n        }\n        if enabled {\n            if let urlString = hookURL(for: event) {\n                // 使用 -j (隐藏启动) 而不是 -g (后台启动) 来防止 SwiftUI WindowGroup 自动创建新窗口\n                let command = \"/usr/bin/open -j \\\"\\(urlString)\\\"\"\n                entries.append([\"hooks\": [[\"type\": \"command\", \"command\": command]]])\n            }\n        }\n        if entries.isEmpty {\n            container.removeValue(forKey: key)\n        } else {\n            container[key] = entries\n        }\n        return container\n    }\n\n    private func hookURL(for event: HookEvent) -> String? {\n        let payload = hookPayload(for: event)\n        var comps = URLComponents()\n        comps.scheme = \"codmate\"\n        comps.host = \"notify\"\n        var query: [URLQueryItem] = [\n            URLQueryItem(name: \"source\", value: \"claude\"),\n            URLQueryItem(name: \"event\", value: event.rawValue)\n        ]\n        if let titleData = payload.title.data(using: .utf8) {\n            query.append(URLQueryItem(name: \"title64\", value: titleData.base64EncodedString()))\n        }\n        if let bodyData = payload.body.data(using: .utf8) {\n            query.append(URLQueryItem(name: \"body64\", value: bodyData.base64EncodedString()))\n        }\n        comps.queryItems = query\n        return comps.url?.absoluteString\n    }\n\n    private func hookPayload(for event: HookEvent) -> HookPayload {\n        switch event {\n        case .permission:\n            return HookPayload(\n                title: \"Claude Code\",\n                body: \"Claude Code requires approval. Return to the Claude window to respond.\"\n            )\n        case .complete:\n            return HookPayload(\n                title: \"Claude Code\",\n                body: \"Claude Code finished its current task.\"\n            )\n        }\n    }\n\n    private let fm: FileManager\n    private let paths: Paths\n\n    init(fileManager: FileManager = .default, paths: Paths = .default()) {\n        self.fm = fileManager\n        self.paths = paths\n    }\n\n    // Load existing JSON dict or empty\n    private func loadObject() -> [String: Any] {\n        guard let data = try? Data(contentsOf: paths.file) else { return [:] }\n        return (try? JSONSerialization.jsonObject(with: data) as? [String: Any]) ?? [:]\n    }\n\n    // Atomic write with backup\n    private func writeObject(_ obj: [String: Any]) throws {\n        try fm.createDirectory(at: paths.dir, withIntermediateDirectories: true)\n        if let data = try? Data(contentsOf: paths.file) {\n            let backup = paths.file.appendingPathExtension(\"backup\")\n            try? data.write(to: backup, options: .atomic)\n        }\n        let out = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])\n        try out.write(to: paths.file, options: .atomic)\n    }\n\n    // MARK: - Public upserts\n    func setModel(_ modelId: String?) throws {\n        var obj = loadObject()\n        if let m = modelId?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty {\n            obj[\"model\"] = m\n        } else {\n            obj.removeValue(forKey: \"model\")\n        }\n        try writeObject(obj)\n    }\n\n    func setForceLoginMethod(_ method: String?) throws {\n        var obj = loadObject()\n        if let m = method?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty {\n            obj[\"forceLoginMethod\"] = m\n        } else {\n            obj.removeValue(forKey: \"forceLoginMethod\")\n        }\n        try writeObject(obj)\n    }\n\n    func setEnvBaseURL(_ baseURL: String?) throws {\n        var obj = loadObject()\n        var env = (obj[\"env\"] as? [String: Any]) ?? [:]\n        if let url = baseURL?.trimmingCharacters(in: .whitespacesAndNewlines), !url.isEmpty {\n            env[\"ANTHROPIC_BASE_URL\"] = url\n        } else {\n            env.removeValue(forKey: \"ANTHROPIC_BASE_URL\")\n        }\n        if env.isEmpty { obj.removeValue(forKey: \"env\") } else { obj[\"env\"] = env }\n        try writeObject(obj)\n    }\n\n    func setEnvToken(_ token: String?) throws {\n        var obj = loadObject()\n        var env = (obj[\"env\"] as? [String: Any]) ?? [:]\n        if let t = token?.trimmingCharacters(in: .whitespacesAndNewlines), !t.isEmpty {\n            env[\"ANTHROPIC_AUTH_TOKEN\"] = t\n        } else {\n            env.removeValue(forKey: \"ANTHROPIC_AUTH_TOKEN\")\n        }\n        if env.isEmpty { obj.removeValue(forKey: \"env\") } else { obj[\"env\"] = env }\n        try writeObject(obj)\n    }\n\n    func setEnvValues(_ entries: [String: String?]) throws {\n        guard !entries.isEmpty else { return }\n        var obj = loadObject()\n        var env = (obj[\"env\"] as? [String: Any]) ?? [:]\n        for (key, value) in entries {\n            if let v = value?.trimmingCharacters(in: .whitespacesAndNewlines), !v.isEmpty {\n                env[key] = v\n            } else {\n                env.removeValue(forKey: key)\n            }\n        }\n        if env.isEmpty { obj.removeValue(forKey: \"env\") } else { obj[\"env\"] = env }\n        try writeObject(obj)\n    }\n\n    func currentModel() -> String? {\n        let obj = loadObject()\n        return obj[\"model\"] as? String\n    }\n\n    func envSnapshot() -> [String: String] {\n        let obj = loadObject()\n        guard let env = obj[\"env\"] as? [String: Any] else { return [:] }\n        var out: [String: String] = [:]\n        for (key, value) in env {\n            if let str = value as? String, !str.isEmpty {\n                out[key] = str\n            }\n        }\n        return out\n    }\n}\n"
  },
  {
    "path": "services/ClaudeUsageAPIClient.swift",
    "content": "import Foundation\nimport Security\nimport CryptoKit\n\nstruct ClaudeUsageAPIClient {\n    enum ClientError: Error, LocalizedError {\n        case credentialNotFound\n        case keychainAccessRestricted(OSStatus)\n        case malformedCredential\n        case missingAccessToken\n        case credentialExpired(Date)\n        case requestFailed(Int)\n        case emptyResponse\n        case decodingFailed\n\n        var errorDescription: String? {\n            switch self {\n            case .credentialNotFound:\n                return \"Claude Code keychain entry not found.\"\n            case .keychainAccessRestricted(let status):\n                return SecCopyErrorMessageString(status, nil) as String? ?? \"Keychain access denied.\"\n            case .malformedCredential:\n                return \"Claude Code credential payload is invalid.\"\n            case .missingAccessToken:\n                return \"Claude Code credential is missing an access token.\"\n            case .credentialExpired(let date):\n                let formatter = DateFormatter()\n                formatter.dateStyle = .medium\n                formatter.timeStyle = .short\n                return \"Claude Code credential expired on \\(formatter.string(from: date)). Please sign in again.\"\n            case .requestFailed(let code):\n                return \"Claude usage API returned status \\(code).\"\n            case .emptyResponse:\n                return \"Claude usage API returned no data.\"\n            case .decodingFailed:\n                return \"Failed to decode Claude usage response.\"\n            }\n        }\n    }\n\n    private struct CredentialEnvelope: Decodable {\n        struct OAuth: Decodable {\n            let accessToken: String\n            let expiresAt: TimeInterval?\n            let rateLimitTier: String?\n\n            enum CodingKeys: String, CodingKey {\n                case accessToken\n                case expiresAt\n                case rateLimitTier = \"rate_limit_tier\"\n            }\n        }\n\n        let claudeAiOauth: OAuth\n    }\n\n    private struct UsageLimitsResponse: Decodable {\n        struct Window: Decodable {\n            let utilization: Double?\n            let resetsAt: Date?\n\n            enum CodingKeys: String, CodingKey {\n                case utilization\n                case resetsAt = \"resets_at\"\n            }\n\n            init(from decoder: Decoder) throws {\n                let container = try decoder.container(keyedBy: CodingKeys.self)\n                utilization = try container.decodeIfPresent(Double.self, forKey: .utilization)\n                if let raw = try container.decodeIfPresent(String.self, forKey: .resetsAt) {\n                    resetsAt = ClaudeUsageAPIClient.isoFormatter.date(from: raw)\n                } else {\n                    resetsAt = nil\n                }\n            }\n        }\n\n        let fiveHour: Window?\n        let sevenDay: Window?\n\n        enum CodingKeys: String, CodingKey {\n            case fiveHour = \"five_hour\"\n            case sevenDay = \"seven_day\"\n        }\n    }\n\n    private static let isoFormatter: ISO8601DateFormatter = {\n        let formatter = ISO8601DateFormatter()\n        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        return formatter\n    }()\n\n    private let session: URLSession\n\n    init(session: URLSession = .shared) {\n        self.session = session\n    }\n\n    func fetchUsageStatus(now: Date = Date()) async throws -> ClaudeUsageStatus {\n        // Priority 1: Try Web API (browser cookies)\n        do {\n            let status = try await ClaudeWebAPIClient.fetchUsageViaWebAPI(now: now)\n            NSLog(\"[ClaudeUsage] Web API succeeded\")\n            return status\n        } catch {\n            // Web API failed, fall back to OAuth API\n            NSLog(\"[ClaudeUsage] Web API failed: \\(error.localizedDescription), falling back to OAuth API\")\n        }\n\n        // Priority 2: OAuth API (existing implementation)\n        let credential = try fetchCredentialEnvelope()\n        var sessionExpiresAt: Date? = nil\n        if let expiresAt = credential.claudeAiOauth.expiresAt {\n            // expiresAt is in milliseconds, convert to seconds\n            let expiryDate = Date(timeIntervalSince1970: expiresAt / 1000)\n            sessionExpiresAt = expiryDate\n            if expiryDate < now {\n                throw ClientError.credentialExpired(expiryDate)\n            }\n        }\n        let token = credential.claudeAiOauth.accessToken\n        let response = try await fetchUsageLimits(token: token)\n        guard response.fiveHour != nil || response.sevenDay != nil else {\n            throw ClientError.emptyResponse\n        }\n\n        let fiveHourWindowMinutes = 5.0 * 60.0\n        let weeklyWindowMinutes = 7.0 * 24.0 * 60.0\n\n        func minutesUsed(from window: UsageLimitsResponse.Window?, windowMinutes: Double) -> Double? {\n            guard let utilization = window?.utilization else { return nil }\n            let percent = max(0, min(utilization, 100))\n            return (percent / 100.0) * windowMinutes\n        }\n\n        // Try to detect plan type from OAuth token (best effort)\n        var planType = await detectPlanTypeViaOAuth(token: token)\n\n        if planType == nil, let tier = credential.claudeAiOauth.rateLimitTier {\n            planType = mapRateLimitTierToPlan(tier)\n            if planType != nil {\n                NSLog(\"[ClaudeUsage] Detected plan type from credential: \\(tier) -> \\(planType!)\")\n            }\n        }\n\n        let status = ClaudeUsageStatus(\n            updatedAt: now,\n            modelName: nil,\n            contextUsedTokens: nil,\n            contextLimitTokens: nil,\n            fiveHourUsedMinutes: minutesUsed(from: response.fiveHour, windowMinutes: fiveHourWindowMinutes),\n            fiveHourWindowMinutes: fiveHourWindowMinutes,\n            fiveHourResetAt: response.fiveHour?.resetsAt,\n            weeklyUsedMinutes: minutesUsed(from: response.sevenDay, windowMinutes: weeklyWindowMinutes),\n            weeklyWindowMinutes: weeklyWindowMinutes,\n            weeklyResetAt: response.sevenDay?.resetsAt,\n            sessionExpiresAt: sessionExpiresAt,\n            planType: planType\n        )\n\n        return status\n    }\n\n    private func fetchCredentialEnvelope() throws -> CredentialEnvelope {\n        if let credential = try fetchEnvelopeFromKeychain() {\n            return credential\n        }\n        if let credential = fetchEnvelopeFromPlaintext() {\n            return credential\n        }\n        throw ClientError.credentialNotFound\n    }\n\n    private func fetchEnvelopeFromKeychain() throws -> CredentialEnvelope? {\n        let accountName = Self.keychainAccountName()\n        var lastError: Error?\n        for service in Self.candidateCredentialServiceNames() {\n            do {\n                if let envelope = try fetchEnvelope(service: service, account: accountName) {\n                    return envelope\n                }\n            } catch let error as ClientError {\n                lastError = error\n            }\n        }\n        if let error = lastError {\n            throw error\n        }\n        return nil\n    }\n\n    private func fetchEnvelope(service: String, account: String) throws -> CredentialEnvelope? {\n        let query: [String: Any] = [\n            kSecClass as String: kSecClassGenericPassword,\n            kSecAttrService as String: service,\n            kSecAttrAccount as String: account,\n            kSecMatchLimit as String: kSecMatchLimitOne,\n            kSecReturnData as String: true\n        ]\n\n        var item: CFTypeRef?\n        let status = SecItemCopyMatching(query as CFDictionary, &item)\n\n        if status == errSecItemNotFound {\n            return nil\n        }\n\n        guard status == errSecSuccess else {\n            throw ClientError.keychainAccessRestricted(status)\n        }\n\n        guard let data = item as? Data else {\n            throw ClientError.malformedCredential\n        }\n\n        let decoder = JSONDecoder()\n        guard let envelope = try? decoder.decode(CredentialEnvelope.self, from: data) else {\n            throw ClientError.malformedCredential\n        }\n\n        guard !envelope.claudeAiOauth.accessToken.isEmpty else {\n            throw ClientError.missingAccessToken\n        }\n\n        return envelope\n    }\n\n    private func fetchUsageLimits(token: String) async throws -> UsageLimitsResponse {\n        let base = ProcessInfo.processInfo.environment[\"ANTHROPIC_BASE_URL\"] ?? \"https://api.anthropic.com\"\n        let url: URL?\n        if base.lowercased().hasSuffix(\"/api/oauth/usage\") {\n            url = URL(string: base)\n        } else {\n            url = URL(string: base)?.appendingPathComponent(\"api/oauth/usage\")\n        }\n        guard let url else {\n            throw ClientError.requestFailed(-1)\n        }\n\n        var request = URLRequest(url: url)\n        request.httpMethod = \"GET\"\n        request.timeoutInterval = 15\n        request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n        request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n        request.setValue(\"CodMate/\\(Bundle.main.shortVersionString)\", forHTTPHeaderField: \"User-Agent\")\n        request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n        request.setValue(\"oauth-2025-04-20\", forHTTPHeaderField: \"anthropic-beta\")\n        request.setValue(\"2023-06-01\", forHTTPHeaderField: \"anthropic-version\")\n\n        let (data, response) = try await session.data(for: request)\n\n        guard let http = response as? HTTPURLResponse else {\n            throw ClientError.requestFailed(-1)\n        }\n        guard (200..<300).contains(http.statusCode) else {\n            NSLog(\"[ClaudeUsageAPI] HTTP error \\(http.statusCode)\")\n            throw ClientError.requestFailed(http.statusCode)\n        }\n\n        do {\n            return try JSONDecoder().decode(UsageLimitsResponse.self, from: data)\n        } catch {\n            NSLog(\"[ClaudeUsageAPI] Decoding failed: \\(error)\")\n            throw ClientError.decodingFailed\n        }\n    }\n\n    // MARK: - Credential helpers\n\n    private static func candidateCredentialServiceNames() -> [String] {\n        var names: [String] = []\n        for oauth in candidateOauthSuffixes() {\n            for hashSuffix in candidateHashSuffixes() {\n                let value = \"Claude Code\\(oauth)-credentials\\(hashSuffix)\"\n                if !names.contains(value) {\n                    names.append(value)\n                }\n            }\n        }\n        return names\n    }\n\n    private static func candidateOauthSuffixes() -> [String] {\n        let env = ProcessInfo.processInfo.environment\n        if let explicit = env[\"CLAUDE_ENV\"] ?? env[\"CLAUDE_CODE_ENV\"], !explicit.isEmpty {\n            return [oauthSuffix(for: explicit)]\n        }\n        return [\"\", \"-staging-oauth\", \"-local-oauth\"]\n    }\n\n    private static func oauthSuffix(for value: String) -> String {\n        switch value.lowercased() {\n        case \"local\": return \"-local-oauth\"\n        case \"staging\": return \"-staging-oauth\"\n        default: return \"\"\n        }\n    }\n\n    private static func candidateHashSuffixes() -> [String] {\n        var suffixes: [String] = [\"\"]\n        func appendUnique(_ value: String) {\n            if !suffixes.contains(value) {\n                suffixes.append(value)\n            }\n        }\n        let env = ProcessInfo.processInfo.environment\n        if let override = env[\"CLAUDE_CONFIG_DIR\"], !override.isEmpty {\n            appendUnique(\"-\" + hashPrefix(for: override))\n        }\n        let defaultPath = SessionPreferencesStore.getRealUserHomeURL()\n            .appendingPathComponent(\".claude\", isDirectory: true)\n            .path\n        appendUnique(\"-\" + hashPrefix(for: defaultPath))\n        return suffixes\n    }\n\n    private static func hashPrefix(for rawPath: String) -> String {\n        let expanded = (rawPath as NSString).expandingTildeInPath\n        let digest = SHA256.hash(data: Data(expanded.utf8))\n        let hex = digest.map { String(format: \"%02x\", $0) }.joined()\n        return String(hex.prefix(8))\n    }\n\n    private static func keychainAccountName() -> String {\n        if let explicit = ProcessInfo.processInfo.environment[\"USER\"], !explicit.isEmpty {\n            return explicit\n        }\n        return NSUserName()\n    }\n\n    private func fetchEnvelopeFromPlaintext() -> CredentialEnvelope? {\n        let fm = FileManager.default\n        let configDir: URL\n        if let override = ProcessInfo.processInfo.environment[\"CLAUDE_CONFIG_DIR\"],\n           !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            configDir = URL(fileURLWithPath: override, isDirectory: true)\n        } else {\n            configDir = SessionPreferencesStore.getRealUserHomeURL()\n                .appendingPathComponent(\".claude\", isDirectory: true)\n        }\n        let fileURL = configDir.appendingPathComponent(\".credentials.json\", isDirectory: false)\n        guard fm.fileExists(atPath: fileURL.path) else { return nil }\n        guard let data = try? Data(contentsOf: fileURL) else { return nil }\n        let decoder = JSONDecoder()\n        return try? decoder.decode(CredentialEnvelope.self, from: data)\n    }\n\n    // MARK: - Plan Type Detection (Best Effort)\n\n    private func mapRateLimitTierToPlan(_ tier: String) -> String? {\n        let lower = tier.lowercased()\n        if lower.contains(\"max\") { return \"Max\" }\n        if lower.contains(\"pro\") { return \"Pro\" }\n        if lower.contains(\"team\") { return \"Team\" }\n        if lower.contains(\"enterprise\") { return \"Enterprise\" }\n        return nil\n    }\n\n    /// Attempts to detect plan type using OAuth token to access claude.ai Web API.\n    /// This is a best-effort approach - OAuth tokens may not work with claude.ai Web API.\n    /// Returns nil if detection fails (no error thrown, silent fallback).\n    private func detectPlanTypeViaOAuth(token: String) async -> String? {\n        let endpoint = \"https://claude.ai/api/account\"\n        guard let url = URL(string: endpoint) else { return nil }\n\n        var request = URLRequest(url: url)\n        request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n        request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n        request.timeoutInterval = 10\n\n        guard let (data, response) = try? await session.data(for: request),\n              let httpResponse = response as? HTTPURLResponse,\n              httpResponse.statusCode == 200\n        else {\n            // OAuth token doesn't work with claude.ai Web API, or user not authenticated\n            // This is expected - OAuth tokens are for api.anthropic.com, not claude.ai\n            return nil\n        }\n\n        // Parse response using the same structure as ClaudeWebAPIClient\n        struct AccountResponse: Decodable {\n            let memberships: [Membership]?\n\n            struct Membership: Decodable {\n                let organization: Organization\n\n                struct Organization: Decodable {\n                    let uuid: String?\n                    let rateLimitTier: String?\n                    let billingType: String?\n\n                    enum CodingKeys: String, CodingKey {\n                        case uuid\n                        case rateLimitTier = \"rate_limit_tier\"\n                        case billingType = \"billing_type\"\n                    }\n                }\n            }\n        }\n\n        guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data),\n              let membership = response.memberships?.first\n        else {\n            return nil\n        }\n\n        let tier = membership.organization.rateLimitTier?.lowercased() ?? \"\"\n        let billing = membership.organization.billingType?.lowercased() ?? \"\"\n\n        if tier.contains(\"max\") { return \"Max\" }\n        if tier.contains(\"pro\") { return \"Pro\" }\n        if tier.contains(\"team\") { return \"Team\" }\n        if tier.contains(\"enterprise\") { return \"Enterprise\" }\n        if billing.contains(\"stripe\"), tier.contains(\"claude\") { return \"Pro\" }\n\n        return nil\n    }\n}\n\nextension Bundle {\n    var shortVersionString: String {\n        infoDictionary?[\"CFBundleShortVersionString\"] as? String ?? \"Unknown\"\n    }\n}\n"
  },
  {
    "path": "services/ClaudeUsageAnalyzer.swift",
    "content": "import Foundation\n\nprivate enum ClaudeUsageConstants {\n    static let blockDuration: TimeInterval = 5 * 60 * 60\n    static let defaultHorizon: TimeInterval = -7 * 24 * 60 * 60\n}\n\nstruct ClaudeUsageAnalyzer {\n\n    private let isoFormatter: ISO8601DateFormatter\n    private let fallbackISOFormatter: ISO8601DateFormatter\n    private let newline: UInt8 = 0x0A\n    private let carriageReturn: UInt8 = 0x0D\n\n    init() {\n        let formatter = ISO8601DateFormatter()\n        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        isoFormatter = formatter\n\n        let fallback = ISO8601DateFormatter()\n        fallback.formatOptions = [.withInternetDateTime]\n        fallbackISOFormatter = fallback\n    }\n\n    func buildStatus(\n        from sessions: [SessionSummary],\n        limit: Int = 48,\n        now: Date = Date()\n    ) -> ClaudeUsageStatus? {\n        guard !sessions.isEmpty else {\n            NSLog(\"[ClaudeUsage] No sessions provided\")\n            return nil\n        }\n\n        let weekInterval = Calendar.current.dateInterval(of: .weekOfYear, for: now)\n        let horizon = weekInterval?.start.addingTimeInterval(-ClaudeUsageConstants.blockDuration)\n            ?? now.addingTimeInterval(ClaudeUsageConstants.defaultHorizon)\n\n        let entries = collectEntries(from: sessions, limit: limit, horizon: horizon)\n        guard !entries.isEmpty else {\n            NSLog(\"[ClaudeUsage] No entries collected from \\(sessions.count) sessions\")\n            return nil\n        }\n\n        NSLog(\"[ClaudeUsage] Collected \\(entries.count) entries from \\(sessions.count) sessions\")\n\n        let blocks = UsageBlockBuilder(entries: entries).build()\n        guard let latestBlock = blocks.last else {\n            NSLog(\"[ClaudeUsage] No blocks built\")\n            return nil\n        }\n\n        NSLog(\"[ClaudeUsage] Built \\(blocks.count) blocks, latest: sessionLimit=\\(latestBlock.sessionLimitReached), usageReset=\\(String(describing: latestBlock.usageLimitReset))\")\n\n        let fiveHour = latestSessionUsage(block: latestBlock, now: now)\n        let weekly = WeeklyUsageAggregator(blocks: blocks, now: now).summary()\n        let weeklyOverride = blocks.last(where: { $0.weeklyLimitReached })\n        let weeklyReset = weeklyOverride?.weeklyLimitReset ?? weekly.resetDate\n\n        NSLog(\"[ClaudeUsage] 5h: \\(fiveHour.minutes)min, reset=\\(String(describing: fiveHour.resetDate)); Weekly: \\(weekly.minutes)min, reset=\\(String(describing: weeklyReset))\")\n\n        return ClaudeUsageStatus(\n            updatedAt: latestBlock.lastActivity,\n            modelName: latestBlock.primaryModel,\n            contextUsedTokens: latestBlock.totalTokens,\n            contextLimitTokens: ClaudeModelContextProvider.contextLimit(for: latestBlock.primaryModel),\n            fiveHourUsedMinutes: fiveHour.minutes,\n            fiveHourWindowMinutes: ClaudeUsageConstants.blockDuration / 60,\n            fiveHourResetAt: fiveHour.resetDate,\n            weeklyUsedMinutes: weekly.minutes,\n            weeklyWindowMinutes: weekly.windowMinutes,\n            weeklyResetAt: weeklyReset\n        )\n    }\n\n    // MARK: - Entry Collection\n\n    private func collectEntries(\n        from sessions: [SessionSummary],\n        limit: Int,\n        horizon: Date\n    ) -> [UsageEntry] {\n        var entries: [UsageEntry] = []\n        var processed = 0\n\n        for summary in sessions.sorted(by: { ($0.lastUpdatedAt ?? $0.startedAt) > ($1.lastUpdatedAt ?? $1.startedAt) }) {\n            guard summary.source.baseKind == .claude else { continue }\n            if processed >= limit { break }\n            if let last = summary.lastUpdatedAt, last < horizon, !entries.isEmpty {\n                break\n            }\n\n            guard let data = try? Data(contentsOf: summary.fileURL, options: [.mappedIfSafe]), !data.isEmpty else { continue }\n            var fileEntries: [UsageEntry] = []\n            var seenKeys: Set<String> = []\n\n            for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n                if slice.last == carriageReturn { slice = slice.dropLast() }\n                guard let entry = parseLine(Data(slice), seenKeys: &seenKeys) else { continue }\n                guard entry.timestamp >= horizon else { continue }\n                fileEntries.append(entry)\n            }\n\n            entries.append(contentsOf: fileEntries)\n            processed += 1\n        }\n\n        entries.sort { $0.timestamp < $1.timestamp }\n        return entries\n    }\n\n    private func parseLine(_ data: Data, seenKeys: inout Set<String>) -> UsageEntry? {\n        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }\n        guard let timestampString = json[\"timestamp\"] as? String else { return nil }\n        guard let timestamp = isoFormatter.date(from: timestampString) ?? fallbackISOFormatter.date(from: timestampString) else {\n            return nil\n        }\n\n        let message = json[\"message\"] as? [String: Any]\n        let usage = extractUsageDictionary(from: json, message: message)\n\n        let dedupKey = makeDedupKey(message: message, root: json)\n        if let dedupKey {\n            if seenKeys.contains(dedupKey) { return nil }\n            seenKeys.insert(dedupKey)\n        }\n\n        let (limitResetFromMessage, limitKind) = parseLimitResetHint(from: message, timestamp: timestamp)\n\n        var tokens = 0\n        if let usage {\n            let input = numberValue(in: usage, keys: [\"input_tokens\", \"inputTokens\"])\n            let cacheCreation = numberValue(in: usage, keys: [\"cache_creation_input_tokens\", \"cacheCreationInputTokens\"])\n            let cacheRead = numberValue(in: usage, keys: [\"cache_read_input_tokens\", \"cacheReadInputTokens\"])\n            let output = numberValue(in: usage, keys: [\"output_tokens\", \"outputTokens\"])\n            tokens = input + cacheCreation + cacheRead + output\n        }\n        if tokens <= 0, limitKind == nil {\n            return nil\n        }\n\n        let model = (message?[\"model\"] as? String)\n            ?? (json[\"model\"] as? String)\n            ?? ((json[\"metadata\"] as? [String: Any])?[\"model\"] as? String)\n        let resetDate = limitResetFromMessage ?? parseResetDate(from: json, timestamp: timestamp)\n\n        return UsageEntry(\n            timestamp: timestamp,\n            tokens: tokens,\n            model: model,\n            usageLimitReset: resetDate,\n            limitKind: limitKind\n        )\n    }\n\n    private func makeDedupKey(message: [String: Any]?, root: [String: Any]) -> String? {\n        if let message, let messageID = message[\"id\"] as? String, !messageID.isEmpty {\n            return \"msg:\\(messageID)\"\n        }\n        if let requestID = root[\"requestId\"] as? String, !requestID.isEmpty {\n            return \"req:\\(requestID)\"\n        }\n        return nil\n    }\n\n    private func extractUsageDictionary(from root: [String: Any], message: [String: Any]?) -> [String: Any]? {\n        if let usage = message?[\"usage\"] as? [String: Any] { return usage }\n        if let usage = root[\"usage\"] as? [String: Any] { return usage }\n        if\n            let metadata = root[\"metadata\"] as? [String: Any],\n            let usage = metadata[\"usage\"] as? [String: Any]\n        {\n            return usage\n        }\n        if\n            let info = root[\"info\"] as? [String: Any],\n            let usage = info[\"usage\"] as? [String: Any]\n        {\n            return usage\n        }\n        return nil\n    }\n\n    private func numberValue(in dict: [String: Any], keys: [String]) -> Int {\n        for key in keys {\n            if let number = dict[key] as? NSNumber { return number.intValue }\n            if let string = dict[key] as? String, let value = Int(string) { return value }\n        }\n        return 0\n    }\n\n    private func parseResetDate(from json: [String: Any], timestamp: Date) -> Date? {\n        if let absolute = json[\"usage_limit_reset_time\"] as? NSNumber {\n            return Date(timeIntervalSince1970: absolute.doubleValue)\n        }\n        if let absolute = json[\"usageLimitResetTime\"] as? NSNumber {\n            return Date(timeIntervalSince1970: absolute.doubleValue)\n        }\n        if let seconds = json[\"usage_limit_reset_in_seconds\"] as? NSNumber {\n            return timestamp.addingTimeInterval(seconds.doubleValue)\n        }\n        if let seconds = json[\"usageLimitResetSeconds\"] as? NSNumber {\n            return timestamp.addingTimeInterval(seconds.doubleValue)\n        }\n        return nil\n    }\n\n    private func parseLimitResetHint(from message: [String: Any]?, timestamp: Date) -> (Date?, UsageEntry.LimitKind?) {\n        guard\n            let message,\n            let contents = message[\"content\"] as? [[String: Any]]\n        else { return (nil, nil) }\n\n        let text = contents.compactMap { $0[\"text\"] as? String }.joined(separator: \" \")\n        guard !text.isEmpty else { return (nil, nil) }\n\n        let lower = text.lowercased()\n        let kind: UsageEntry.LimitKind?\n        if lower.contains(\"session limit reached\") {\n            kind = .session\n        } else if lower.contains(\"weekly limit reached\") {\n            kind = .weekly\n        } else {\n            kind = nil\n        }\n        guard let kind else { return (nil, nil) }\n        NSLog(\"[ClaudeUsage] Found limit message: kind=\\(kind), text=\\(text.prefix(80))\")\n        let resetDate = parseResetDateHint(from: text, reference: timestamp)\n        NSLog(\"[ClaudeUsage] Parsed reset date: \\(String(describing: resetDate))\")\n        return (resetDate, kind)\n    }\n\n    private func parseResetDateHint(from text: String, reference: Date) -> Date? {\n        // First, try to extract Unix timestamp (format: |<digits>)\n        // This is the most accurate source from Claude Code CLI\n        if let unixTimestamp = extractUnixTimestamp(from: text) {\n            NSLog(\"[ClaudeUsage] Extracted Unix timestamp: \\(unixTimestamp)\")\n            return Date(timeIntervalSince1970: TimeInterval(unixTimestamp))\n        }\n\n        guard var payload = extractResetPayload(from: text) else { return nil }\n        if payload.isEmpty { return nil }\n\n        if let dated = parseMonthBasedReset(payload: payload, reference: reference) {\n            return dated\n        }\n\n        payload = payload.replacingOccurrences(of: \" at \", with: \" \")\n        payload = payload.replacingOccurrences(of: \"  \", with: \" \")\n        return parseTimeOnlyReset(payload: payload, reference: reference)\n    }\n\n    private func extractUnixTimestamp(from text: String) -> Int? {\n        // Claude Code CLI embeds Unix timestamp as |<digits>\n        // Example: \"Session limit reached · resets 3pm … |1731147600\"\n        let pattern = \"\\\\|(\\\\d+)\"\n        guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }\n        let range = NSRange(text.startIndex..., in: text)\n        guard let match = regex.firstMatch(in: text, range: range) else { return nil }\n        guard let timestampRange = Range(match.range(at: 1), in: text) else { return nil }\n        let timestampString = String(text[timestampRange])\n        return Int(timestampString)\n    }\n\n    private func extractResetPayload(from text: String) -> String? {\n        let lower = text.lowercased()\n        guard let range = lower.range(of: \"resets\") else { return nil }\n        var payload = String(text[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)\n        if payload.hasPrefix(\"∙\") {\n            payload = String(payload.dropFirst()).trimmingCharacters(in: .whitespacesAndNewlines)\n        }\n        if let idx = payload.firstIndex(of: \"(\") {\n            payload = String(payload[..<idx])\n        }\n        return payload.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private func parseMonthBasedReset(payload: String, reference: Date) -> Date? {\n        NSLog(\"[ClaudeUsage] parseMonthBasedReset: payload=\\\"\\(payload)\\\"\")\n        let formatter = DateFormatter()\n        formatter.locale = Locale(identifier: \"en_US_POSIX\")\n        formatter.timeZone = TimeZone.current\n        let attempts = [\n            \"MMM d 'at' h:mma\",\n            \"MMM d 'at' h a\",\n            \"MMM d 'at' ha\",\n            \"MMM d h:mma\",\n            \"MMM d h a\",\n            \"MMM d ha\"\n        ]\n        let year = Calendar.current.component(.year, from: reference)\n        let enriched = payload + \" \\(year)\"\n        for format in attempts {\n            formatter.dateFormat = format + \" yyyy\"\n            if let date = formatter.date(from: enriched) {\n                NSLog(\"[ClaudeUsage] Matched format \\\"\\(format)\\\", date=\\(date)\")\n                if date < reference,\n                   let nextYear = Calendar.current.date(byAdding: .year, value: 1, to: date) {\n                    return nextYear\n                }\n                return date\n            }\n        }\n        NSLog(\"[ClaudeUsage] No format matched for payload=\\\"\\(payload)\\\"\")\n        return nil\n    }\n\n    private func parseTimeOnlyReset(payload: String, reference: Date) -> Date? {\n        let trimmed = payload.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else {\n            NSLog(\"[ClaudeUsage] parseTimeOnlyReset: empty payload\")\n            return nil\n        }\n        NSLog(\"[ClaudeUsage] parseTimeOnlyReset: payload=\\\"\\(trimmed)\\\"\")\n        let calendar = Calendar.current\n        var components = calendar.dateComponents([.year, .month, .day], from: reference)\n\n        let formatter = DateFormatter()\n        formatter.locale = Locale(identifier: \"en_US_POSIX\")\n        formatter.timeZone = TimeZone.current\n\n        let lower = trimmed.lowercased()\n        let attempts: [String]\n        if lower.contains(\"am\") || lower.contains(\"pm\") {\n            attempts = [\"h:mma\", \"h.mma\", \"ha\", \"hmma\"]\n        } else if trimmed.contains(\":\") {\n            attempts = [\"HH:mm\"]\n        } else {\n            NSLog(\"[ClaudeUsage] parseTimeOnlyReset: no am/pm or colon found\")\n            return nil\n        }\n\n        for format in attempts {\n            formatter.dateFormat = format\n            let testString = trimmed.replacingOccurrences(of: \" \", with: \"\")\n            if let date = formatter.date(from: testString) {\n                let time = calendar.dateComponents([.hour, .minute], from: date)\n                components.hour = time.hour\n                components.minute = time.minute\n                components.second = 0\n                if let combined = calendar.date(from: components) {\n                    NSLog(\"[ClaudeUsage] parseTimeOnlyReset: matched format \\\"\\(format)\\\", combined=\\(combined)\")\n                    if combined <= reference {\n                        let next = calendar.date(byAdding: .day, value: 1, to: combined)\n                        NSLog(\"[ClaudeUsage] parseTimeOnlyReset: date in past, adding 1 day -> \\(String(describing: next))\")\n                        return next\n                    }\n                    return combined\n                }\n            }\n        }\n        NSLog(\"[ClaudeUsage] parseTimeOnlyReset: no format matched\")\n        return nil\n    }\n}\n\n// MARK: - Usage Entry\n\nprivate struct UsageEntry {\n    enum LimitKind { case session, weekly }\n\n    let timestamp: Date\n    let tokens: Int\n    let model: String?\n    let usageLimitReset: Date?\n    let limitKind: LimitKind?\n}\n\n// MARK: - Usage Blocks\n\nprivate struct UsageBlock {\n    let startTime: Date\n    let lastActivity: Date\n    let totalTokens: Int\n    let models: Set<String>\n    let usageLimitReset: Date?\n    let sessionLimitReached: Bool\n    let weeklyLimitReset: Date?\n    let weeklyLimitReached: Bool\n\n    private static let blockDuration = ClaudeUsageConstants.blockDuration\n\n    var primaryModel: String? {\n        guard !models.isEmpty else { return nil }\n        return models.sorted().first\n    }\n\n    var usedMinutes: Double {\n        let blockEnd = startTime.addingTimeInterval(Self.blockDuration)\n        let effectiveEnd = min(blockEnd, lastActivity)\n        return max(0, effectiveEnd.timeIntervalSince(startTime) / 60)\n    }\n\n    var resetDate: Date {\n        usageLimitReset ?? startTime.addingTimeInterval(Self.blockDuration)\n    }\n\n    var activeInterval: DateInterval {\n        let end = min(startTime.addingTimeInterval(Self.blockDuration), lastActivity)\n        return DateInterval(start: startTime, end: max(startTime, end))\n    }\n}\n\nprivate struct UsageBlockBuilder {\n    let entries: [UsageEntry]\n\n    func build() -> [UsageBlock] {\n        guard !entries.isEmpty else { return [] }\n\n        var blocks: [UsageBlock] = []\n        var currentEntries: [UsageEntry] = []\n        var blockStart: Date = entries[0].timestamp\n        var lastTimestamp: Date = entries[0].timestamp\n\n        func finalize() {\n            guard !currentEntries.isEmpty else { return }\n            let tokens = currentEntries.reduce(0) { $0 + $1.tokens }\n            let models = Set(currentEntries.compactMap(\\.model))\n            let sessionLimitReached = currentEntries.contains { $0.limitKind == .session }\n            let usageReset: Date? = {\n                if sessionLimitReached {\n                    return currentEntries.last { entry in\n                        entry.limitKind == .session && entry.usageLimitReset != nil\n                    }?.usageLimitReset ?? currentEntries.last(where: { $0.usageLimitReset != nil })?.usageLimitReset\n                }\n                return currentEntries.last(where: { $0.usageLimitReset != nil })?.usageLimitReset\n            }()\n        let block = UsageBlock(\n            startTime: currentEntries.first!.timestamp,\n            lastActivity: currentEntries.last!.timestamp,\n            totalTokens: tokens,\n            models: models,\n            usageLimitReset: usageReset,\n            sessionLimitReached: sessionLimitReached,\n            weeklyLimitReset: currentEntries.last(where: { $0.limitKind == .weekly && $0.usageLimitReset != nil })?.usageLimitReset,\n            weeklyLimitReached: currentEntries.contains { $0.limitKind == .weekly }\n        )\n            blocks.append(block)\n            currentEntries.removeAll(keepingCapacity: true)\n        }\n\n        let blockDuration = ClaudeUsageConstants.blockDuration\n\n        for entry in entries {\n            if currentEntries.isEmpty {\n                blockStart = entry.timestamp\n                currentEntries.append(entry)\n                lastTimestamp = entry.timestamp\n                if entry.limitKind == .session {\n                    finalize()\n                }\n                continue\n            }\n\n            let exceedsBlock = entry.timestamp.timeIntervalSince(blockStart) > blockDuration\n            let gapTooLarge = entry.timestamp.timeIntervalSince(lastTimestamp) > blockDuration\n\n            if exceedsBlock || gapTooLarge {\n                finalize()\n                blockStart = entry.timestamp\n                lastTimestamp = entry.timestamp\n                currentEntries.append(entry)\n                if entry.limitKind == .session {\n                    finalize()\n                }\n                continue\n            }\n\n            currentEntries.append(entry)\n            lastTimestamp = entry.timestamp\n\n            if entry.limitKind == .session {\n                finalize()\n            }\n        }\n\n        finalize()\n        return blocks\n    }\n}\n\n// MARK: - Weekly Aggregation\n\nprivate struct WeeklyUsageAggregator {\n    let blocks: [UsageBlock]\n    let now: Date\n\n    func summary() -> (minutes: Double, windowMinutes: Double, resetDate: Date?) {\n        guard let interval = Calendar.current.dateInterval(of: .weekOfYear, for: now) else {\n            return (0, 7 * 24 * 60, nil)\n        }\n\n        var totalMinutes: Double = 0\n        for block in blocks {\n            if let overlap = block.activeInterval.intersection(with: interval) {\n                totalMinutes += overlap.duration / 60\n            }\n        }\n\n        return (\n            minutes: totalMinutes,\n            windowMinutes: interval.duration / 60,\n            resetDate: interval.end\n        )\n    }\n}\n\n// MARK: - Latest Session (5-hour window) Aggregation\n\nprivate func latestSessionUsage(block: UsageBlock, now: Date) -> (minutes: Double, resetDate: Date?) {\n    let duration = ClaudeUsageConstants.blockDuration\n    let windowEnd = block.startTime.addingTimeInterval(duration)\n\n    if block.sessionLimitReached {\n        let reset = block.usageLimitReset ?? windowEnd\n        if reset <= now {\n            return (minutes: 0, resetDate: nil)\n        }\n        return (\n            minutes: duration / 60,\n            resetDate: reset\n        )\n    }\n\n    guard windowEnd > now else { return (0, nil) }\n\n    let usedSeconds = min(now, windowEnd).timeIntervalSince(block.startTime)\n    let minutes = max(0, usedSeconds) / 60\n\n    let candidateReset = block.usageLimitReset\n    let resetDate: Date?\n    if let candidateReset, candidateReset > now {\n        resetDate = candidateReset\n    } else if windowEnd > now {\n        resetDate = windowEnd\n    } else {\n        resetDate = nil\n    }\n\n    return (minutes: minutes, resetDate: resetDate)\n}\n\n// MARK: - Context Limit Resolution\n\nenum ClaudeModelContextProvider {\n    private static let highCapacityModels: [String] = [\n        \"claude-sonnet-4-20250514\",\n        \"claude-sonnet-4\",\n        \"claude-sonnet-4@20250514\"\n    ]\n\n    private static let lowCapacityModels: [String] = [\n        \"claude-instant-v1\",\n        \"claude-v1\",\n        \"claude-v2\",\n        \"claude-2\"\n    ]\n\n    static func contextLimit(for modelName: String?) -> Int? {\n        guard let model = modelName?.lowercased() else { return nil }\n        if highCapacityModels.contains(where: { model.contains($0) }) {\n            return 1_000_000\n        }\n        if lowCapacityModels.contains(where: { model.contains($0) }) {\n            return 100_000\n        }\n        return 200_000\n    }\n}\n"
  },
  {
    "path": "services/ClaudeWebAPIClient.swift",
    "content": "import Foundation\n\n/// Fetches Claude usage data directly from the claude.ai API using browser session cookies.\n///\n/// This approach automatically extracts the session key from Safari/Chrome cookies instead of\n/// requiring OAuth token management, providing a more reliable fallback when OAuth tokens expire.\n///\n/// API endpoints used:\n/// - `GET https://claude.ai/api/organizations` → get org UUID\n/// - `GET https://claude.ai/api/organizations/{org_id}/usage` → usage percentages + reset times\nenum ClaudeWebAPIClient {\n  private static let baseURL = \"https://claude.ai/api\"\n\n  enum FetchError: LocalizedError {\n    case noSessionKeyFound\n    case invalidSessionKey\n    case networkError(Error)\n    case invalidResponse\n    case unauthorized\n    case serverError(statusCode: Int)\n    case noOrganization\n\n    var errorDescription: String? {\n      switch self {\n      case .noSessionKeyFound:\n        \"No Claude session key found in browser cookies.\"\n      case .invalidSessionKey:\n        \"Invalid Claude session key format.\"\n      case let .networkError(error):\n        \"Network error: \\(error.localizedDescription)\"\n      case .invalidResponse:\n        \"Invalid response from Claude API.\"\n      case .unauthorized:\n        \"Unauthorized. Your Claude session may have expired.\"\n      case let .serverError(code):\n        \"Claude API error: HTTP \\(code)\"\n      case .noOrganization:\n        \"No Claude organization found for this account.\"\n      }\n    }\n  }\n\n  struct OrganizationInfo {\n    let id: String\n    let name: String?\n  }\n\n  struct WebUsageData {\n    let sessionPercentUsed: Double\n    let sessionResetsAt: Date?\n    let weeklyPercentUsed: Double?\n    let weeklyResetsAt: Date?\n    let planType: String?  // Subscription type (Pro, Max, Team, etc.)\n  }\n\n  private struct AccountResponse: Decodable {\n    let emailAddress: String?\n    let memberships: [Membership]?\n\n    enum CodingKeys: String, CodingKey {\n      case emailAddress = \"email_address\"\n      case memberships\n    }\n\n    struct Membership: Decodable {\n      let organization: Organization\n\n      struct Organization: Decodable {\n        let uuid: String?\n        let rateLimitTier: String?\n        let billingType: String?\n\n        enum CodingKeys: String, CodingKey {\n          case uuid\n          case rateLimitTier = \"rate_limit_tier\"\n          case billingType = \"billing_type\"\n        }\n      }\n    }\n  }\n\n  // MARK: - Public API\n\n  /// Fetches Claude usage status using browser cookies\n  /// - Parameter now: Current date for status construction\n  /// - Returns: ClaudeUsageStatus compatible with existing system\n  /// - Throws: FetchError if session key cannot be found or API call fails\n  static func fetchUsageViaWebAPI(now: Date = Date()) async throws -> ClaudeUsageStatus {\n    NSLog(\"[ClaudeWebAPI] Attempting to fetch usage via Web API\")\n\n    // Extract session key from browser cookies\n    let sessionKey = try extractSessionKey()\n    NSLog(\"[ClaudeWebAPI] Found sessionKey: \\(sessionKey.prefix(20))...\")\n\n    // Fetch organization info\n    let organization = try await fetchOrganizationInfo(sessionKey: sessionKey)\n    NSLog(\"[ClaudeWebAPI] Organization ID: \\(organization.id)\")\n\n    // Fetch usage data\n    var usage = try await fetchUsageData(orgId: organization.id, sessionKey: sessionKey)\n    NSLog(\"[ClaudeWebAPI] Usage fetched successfully\")\n\n    // Fetch account info for plan type (best effort)\n    if let planType = await fetchAccountPlanType(\n      sessionKey: sessionKey, orgId: organization.id)\n    {\n      usage = WebUsageData(\n        sessionPercentUsed: usage.sessionPercentUsed,\n        sessionResetsAt: usage.sessionResetsAt,\n        weeklyPercentUsed: usage.weeklyPercentUsed,\n        weeklyResetsAt: usage.weeklyResetsAt,\n        planType: planType\n      )\n      NSLog(\"[ClaudeWebAPI] ✅ Detected plan type: \\(planType)\")\n    } else {\n      NSLog(\"[ClaudeWebAPI] ⚠️ Could not detect plan type\")\n    }\n\n    // Convert to ClaudeUsageStatus\n    return convertToUsageStatus(usage, now: now)\n  }\n\n  // MARK: - Session Key Extraction\n\n  private static func extractSessionKey() throws -> String {\n    // Try Safari first (no Keychain prompt required)\n    do {\n      if let sessionKey = try SafariCookieImporter.extractClaudeSessionKey() {\n        guard validateSessionKey(sessionKey) else {\n          throw FetchError.invalidSessionKey\n        }\n        NSLog(\"[ClaudeWebAPI] Found sessionKey in Safari cookies\")\n        return sessionKey\n      }\n    } catch {\n      NSLog(\"[ClaudeWebAPI] Safari cookie load failed: \\(error.localizedDescription)\")\n    }\n\n    // Try Chrome (may trigger Keychain prompt)\n    do {\n      if let sessionKey = try ChromeCookieImporter.extractClaudeSessionKey() {\n        guard validateSessionKey(sessionKey) else {\n          throw FetchError.invalidSessionKey\n        }\n        NSLog(\"[ClaudeWebAPI] Found sessionKey in Chrome cookies\")\n        return sessionKey\n      }\n    } catch {\n      NSLog(\"[ClaudeWebAPI] Chrome cookie load failed: \\(error.localizedDescription)\")\n    }\n\n    throw FetchError.noSessionKeyFound\n  }\n\n  private static func validateSessionKey(_ key: String) -> Bool {\n    // Claude session keys start with \"sk-ant-\"\n    return key.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(\"sk-ant-\")\n  }\n\n  // MARK: - API Calls\n\n  private static func fetchOrganizationInfo(sessionKey: String) async throws\n    -> OrganizationInfo\n  {\n    let url = URL(string: \"\\(baseURL)/organizations\")!\n    var request = URLRequest(url: url)\n    request.setValue(\"sessionKey=\\(sessionKey)\", forHTTPHeaderField: \"Cookie\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    request.httpMethod = \"GET\"\n    request.timeoutInterval = 15\n\n    let (data, response) = try await URLSession.shared.data(for: request)\n\n    guard let httpResponse = response as? HTTPURLResponse else {\n      throw FetchError.invalidResponse\n    }\n\n    NSLog(\"[ClaudeWebAPI] Organizations API status: \\(httpResponse.statusCode)\")\n\n    switch httpResponse.statusCode {\n    case 200:\n      return try parseOrganizationResponse(data)\n    case 401, 403:\n      throw FetchError.unauthorized\n    default:\n      throw FetchError.serverError(statusCode: httpResponse.statusCode)\n    }\n  }\n\n  private static func fetchUsageData(orgId: String, sessionKey: String) async throws\n    -> WebUsageData\n  {\n    let url = URL(string: \"\\(baseURL)/organizations/\\(orgId)/usage\")!\n    var request = URLRequest(url: url)\n    request.setValue(\"sessionKey=\\(sessionKey)\", forHTTPHeaderField: \"Cookie\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    request.httpMethod = \"GET\"\n    request.timeoutInterval = 15\n\n    let (data, response) = try await URLSession.shared.data(for: request)\n\n    guard let httpResponse = response as? HTTPURLResponse else {\n      throw FetchError.invalidResponse\n    }\n\n    NSLog(\"[ClaudeWebAPI] Usage API status: \\(httpResponse.statusCode)\")\n\n    switch httpResponse.statusCode {\n    case 200:\n      return try parseUsageResponse(data)\n    case 401, 403:\n      throw FetchError.unauthorized\n    default:\n      throw FetchError.serverError(statusCode: httpResponse.statusCode)\n    }\n  }\n\n  // MARK: - Response Parsing\n\n  private static func parseOrganizationResponse(_ data: Data) throws -> OrganizationInfo {\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],\n      let first = json.first,\n      let id = first[\"uuid\"] as? String\n    else {\n      throw FetchError.noOrganization\n    }\n\n    let name = first[\"name\"] as? String\n    return OrganizationInfo(id: id, name: name)\n  }\n\n  private static func parseUsageResponse(_ data: Data) throws -> WebUsageData {\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      throw FetchError.invalidResponse\n    }\n\n    // Parse five_hour (session) usage\n    var sessionPercent: Double?\n    var sessionResets: Date?\n    if let fiveHour = json[\"five_hour\"] as? [String: Any] {\n      if let utilization = fiveHour[\"utilization\"] as? Int {\n        sessionPercent = Double(utilization)\n      }\n      if let resetsAt = fiveHour[\"resets_at\"] as? String {\n        sessionResets = parseISO8601Date(resetsAt)\n      }\n    }\n\n    guard let sessionPercent else {\n      // If we can't parse session utilization, treat this as a failure\n      throw FetchError.invalidResponse\n    }\n\n    // Parse seven_day (weekly) usage\n    var weeklyPercent: Double?\n    var weeklyResets: Date?\n    if let sevenDay = json[\"seven_day\"] as? [String: Any] {\n      if let utilization = sevenDay[\"utilization\"] as? Int {\n        weeklyPercent = Double(utilization)\n      }\n      if let resetsAt = sevenDay[\"resets_at\"] as? String {\n        weeklyResets = parseISO8601Date(resetsAt)\n      }\n    }\n\n    return WebUsageData(\n      sessionPercentUsed: sessionPercent,\n      sessionResetsAt: sessionResets,\n      weeklyPercentUsed: weeklyPercent,\n      weeklyResetsAt: weeklyResets,\n      planType: nil  // Will be populated by fetchAccountPlanType\n    )\n  }\n\n  private static func fetchAccountPlanType(sessionKey: String, orgId: String) async -> String? {\n    let url = URL(string: \"\\(baseURL)/account\")!\n    var request = URLRequest(url: url)\n    request.setValue(\"sessionKey=\\(sessionKey)\", forHTTPHeaderField: \"Cookie\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    request.httpMethod = \"GET\"\n    request.timeoutInterval = 15\n\n    NSLog(\"[ClaudeWebAPI] Fetching account info for orgId: \\(orgId)\")\n\n    do {\n      let (data, response) = try await URLSession.shared.data(for: request)\n\n      if let httpResponse = response as? HTTPURLResponse {\n        NSLog(\"[ClaudeWebAPI] Account API status: \\(httpResponse.statusCode)\")\n\n        if httpResponse.statusCode != 200 {\n          NSLog(\"[ClaudeWebAPI] Account API failed with status \\(httpResponse.statusCode)\")\n          return nil\n        }\n      }\n\n      let planType = parseAccountPlanType(data, orgId: orgId)\n      NSLog(\"[ClaudeWebAPI] Parsed plan type: \\(planType ?? \"nil\")\")\n      return planType\n    } catch {\n      NSLog(\"[ClaudeWebAPI] Account API error: \\(error.localizedDescription)\")\n      return nil\n    }\n  }\n\n  private static func parseAccountPlanType(_ data: Data, orgId: String) -> String? {\n    guard let response = try? JSONDecoder().decode(AccountResponse.self, from: data) else {\n      NSLog(\"[ClaudeWebAPI] Failed to decode AccountResponse\")\n      return nil\n    }\n\n    NSLog(\"[ClaudeWebAPI] Account has \\(response.memberships?.count ?? 0) memberships\")\n\n    // Find matching membership or use first\n    let membership = selectMembership(response.memberships, orgId: orgId)\n\n    if let membership = membership {\n      NSLog(\"[ClaudeWebAPI] Selected membership - rateLimitTier: \\(membership.organization.rateLimitTier ?? \"nil\"), billingType: \\(membership.organization.billingType ?? \"nil\")\")\n    } else {\n      NSLog(\"[ClaudeWebAPI] No membership found\")\n    }\n\n    return inferPlan(\n      rateLimitTier: membership?.organization.rateLimitTier,\n      billingType: membership?.organization.billingType\n    )\n  }\n\n  private static func selectMembership(\n    _ memberships: [AccountResponse.Membership]?,\n    orgId: String\n  ) -> AccountResponse.Membership? {\n    guard let memberships, !memberships.isEmpty else { return nil }\n    if let match = memberships.first(where: { $0.organization.uuid == orgId }) {\n      return match\n    }\n    return memberships.first\n  }\n\n  private static func inferPlan(rateLimitTier: String?, billingType: String?) -> String? {\n    let tier = rateLimitTier?.lowercased() ?? \"\"\n    let billing = billingType?.lowercased() ?? \"\"\n\n    NSLog(\"[ClaudeWebAPI] inferPlan - tier: '\\(tier)', billing: '\\(billing)'\")\n\n    if tier.contains(\"max\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Max\")\n      return \"Max\"\n    }\n    if tier.contains(\"pro\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Pro\")\n      return \"Pro\"\n    }\n    if tier.contains(\"team\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Team\")\n      return \"Team\"\n    }\n    if tier.contains(\"enterprise\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Enterprise\")\n      return \"Enterprise\"\n    }\n    if billing.contains(\"stripe\"), tier.contains(\"claude\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Pro (via stripe+claude)\")\n      return \"Pro\"\n    }\n    if billing.contains(\"apple\") {\n      NSLog(\"[ClaudeWebAPI] Matched: Pro (via apple)\")\n      return \"Pro\"\n    }\n\n    NSLog(\"[ClaudeWebAPI] No plan matched\")\n    return nil\n  }\n\n  private static func parseISO8601Date(_ string: String) -> Date? {\n    let formatter = ISO8601DateFormatter()\n    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    if let date = formatter.date(from: string) {\n      return date\n    }\n    // Try without fractional seconds\n    formatter.formatOptions = [.withInternetDateTime]\n    return formatter.date(from: string)\n  }\n\n  // MARK: - Conversion to ClaudeUsageStatus\n\n  private static func convertToUsageStatus(_ usage: WebUsageData, now: Date)\n    -> ClaudeUsageStatus\n  {\n    let fiveHourWindowMinutes = 5.0 * 60.0\n    let weeklyWindowMinutes = 7.0 * 24.0 * 60.0\n\n    // Convert percentage to minutes used\n    let fiveHourUsedMinutes = (usage.sessionPercentUsed / 100.0) * fiveHourWindowMinutes\n    let weeklyUsedMinutes =\n      usage.weeklyPercentUsed.map { ($0 / 100.0) * weeklyWindowMinutes }\n\n    return ClaudeUsageStatus(\n      updatedAt: now,\n      modelName: nil,\n      contextUsedTokens: nil,\n      contextLimitTokens: nil,\n      fiveHourUsedMinutes: fiveHourUsedMinutes,\n      fiveHourWindowMinutes: fiveHourWindowMinutes,\n      fiveHourResetAt: usage.sessionResetsAt,\n      weeklyUsedMinutes: weeklyUsedMinutes,\n      weeklyWindowMinutes: weeklyWindowMinutes,\n      weeklyResetAt: usage.weeklyResetsAt,\n      sessionExpiresAt: nil,  // Web API doesn't have session expiry\n      planType: usage.planType\n    )\n  }\n}\n"
  },
  {
    "path": "services/CodexAppServerProbeService.swift",
    "content": "import Foundation\n\n/// Best-effort probe for Codex rate limits and account info via `codex app-server`.\n///\n/// This is intentionally lightweight and avoids creating Codex session logs, unlike starting\n/// interactive sessions. It is used to show Codex quota windows (5h/weekly) even when no\n/// recent session files are available.\nactor CodexAppServerProbeService {\n  struct Snapshot: Sendable {\n    let fetchedAt: Date\n    let primaryUsedPercent: Double?\n    let primaryWindowMinutes: Int?\n    let primaryResetAt: Date?\n    let secondaryUsedPercent: Double?\n    let secondaryWindowMinutes: Int?\n    let secondaryResetAt: Date?\n    let planType: String?\n  }\n\n  enum ProbeError: Swift.Error, LocalizedError {\n    case codexNotFound\n    case startFailed(String)\n    case malformedResponse(String)\n    case requestFailed(String)\n\n    var errorDescription: String? {\n      switch self {\n      case .codexNotFound:\n        return \"Codex CLI not found on PATH.\"\n      case .startFailed(let message):\n        return \"Failed to start codex app-server: \\(message)\"\n      case .malformedResponse(let message):\n        return \"Malformed codex app-server response: \\(message)\"\n      case .requestFailed(let message):\n        return \"Codex app-server request failed: \\(message)\"\n      }\n    }\n  }\n\n  private var cached: Snapshot?\n  private var inFlight: Task<Snapshot, Error>?\n\n  /// Returns cached data when it's fresh enough; otherwise starts a new probe.\n  func fetchIfStale(maxAge: TimeInterval = 60) async throws -> Snapshot {\n    if let cached {\n      let age = Date().timeIntervalSince(cached.fetchedAt)\n      if age >= 0, age <= maxAge { return cached }\n    }\n    if let inFlight {\n      let fresh = try await inFlight.value\n      self.cached = fresh\n      return fresh\n    }\n\n    let task = Task { try await Self.fetchOnce() }\n    self.inFlight = task\n    defer { self.inFlight = nil }\n    let fresh = try await task.value\n    self.cached = fresh\n    return fresh\n  }\n\n  /// Best-effort wrapper that never throws (used by UI refresh paths).\n  func fetchIfStaleOrNil(maxAge: TimeInterval = 60) async -> Snapshot? {\n    do {\n      return try await fetchIfStale(maxAge: maxAge)\n    } catch {\n      return nil\n    }\n  }\n\n  // MARK: - Probe implementation\n\n  private static func fetchOnce() async throws -> Snapshot {\n    let client = try CodexRPCClient()\n    defer { client.shutdown() }\n\n    try await client.initialize(clientName: \"codmate\", clientVersion: Bundle.main.shortVersionString)\n\n    let rateLimits = try await client.fetchRateLimits().rateLimits\n    let account = try? await client.fetchAccount()\n\n    let fetchedAt = Date()\n    let primary = Self.window(from: rateLimits.primary)\n    let secondary = Self.window(from: rateLimits.secondary)\n    let planType: String? = account?.account.flatMap { details in\n      if case let .chatgpt(_, planType) = details { return planType } else { return nil }\n    }\n\n    return Snapshot(\n      fetchedAt: fetchedAt,\n      primaryUsedPercent: primary.usedPercent,\n      primaryWindowMinutes: primary.windowMinutes,\n      primaryResetAt: primary.resetsAt,\n      secondaryUsedPercent: secondary.usedPercent,\n      secondaryWindowMinutes: secondary.windowMinutes,\n      secondaryResetAt: secondary.resetsAt,\n      planType: planType\n    )\n  }\n\n  private struct RateWindow: Sendable {\n    let usedPercent: Double?\n    let windowMinutes: Int?\n    let resetsAt: Date?\n  }\n\n  private static func window(from rpc: RPCRateLimitWindow?) -> RateWindow {\n    guard let rpc else {\n      return RateWindow(usedPercent: nil, windowMinutes: nil, resetsAt: nil)\n    }\n    let resetsAt = rpc.resetsAt.map { Date(timeIntervalSince1970: TimeInterval($0)) }\n    return RateWindow(usedPercent: rpc.usedPercent, windowMinutes: rpc.windowDurationMins, resetsAt: resetsAt)\n  }\n}\n\n// MARK: - Codex JSON-RPC client (local `codex app-server` process)\n\nprivate struct RPCRateLimitsResponse: Decodable {\n  let rateLimits: RPCRateLimitSnapshot\n}\n\nprivate struct RPCRateLimitSnapshot: Decodable {\n  let primary: RPCRateLimitWindow?\n  let secondary: RPCRateLimitWindow?\n}\n\nprivate struct RPCRateLimitWindow: Decodable {\n  let usedPercent: Double\n  let windowDurationMins: Int?\n  let resetsAt: Int?\n}\n\nprivate struct RPCAccountResponse: Decodable {\n  let account: RPCAccountDetails?\n}\n\nprivate enum RPCAccountDetails: Decodable {\n  case apiKey\n  case chatgpt(email: String, planType: String)\n\n  enum CodingKeys: String, CodingKey {\n    case type\n    case email\n    case planType\n  }\n\n  init(from decoder: Decoder) throws {\n    let container = try decoder.container(keyedBy: CodingKeys.self)\n    let type = try container.decode(String.self, forKey: .type)\n    switch type.lowercased() {\n    case \"apikey\":\n      self = .apiKey\n    case \"chatgpt\":\n      let email = try container.decodeIfPresent(String.self, forKey: .email) ?? \"unknown\"\n      let plan = try container.decodeIfPresent(String.self, forKey: .planType) ?? \"unknown\"\n      self = .chatgpt(email: email, planType: plan)\n    default:\n      throw DecodingError.dataCorruptedError(\n        forKey: .type,\n        in: container,\n        debugDescription: \"Unknown account type \\(type)\"\n      )\n    }\n  }\n}\n\nprivate final class CodexRPCClient: @unchecked Sendable {\n  private let process = Process()\n  private let stdinPipe = Pipe()\n  private let stdoutPipe = Pipe()\n  private let stderrPipe = Pipe()\n  private var nextID = 1\n\n  init() throws {\n    let env = Self.buildEnvironment()\n\n    self.process.environment = env\n    self.process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n    self.process.arguments = [\n      \"codex\",\n      \"-s\", \"read-only\",\n      \"-a\", \"untrusted\",\n      \"app-server\",\n    ]\n    self.process.standardInput = self.stdinPipe\n    self.process.standardOutput = self.stdoutPipe\n    self.process.standardError = self.stderrPipe\n\n    do {\n      try self.process.run()\n    } catch {\n      throw CodexAppServerProbeService.ProbeError.startFailed(error.localizedDescription)\n    }\n\n    let stderrHandle = self.stderrPipe.fileHandleForReading\n    stderrHandle.readabilityHandler = { handle in\n      let data = handle.availableData\n      if data.isEmpty {\n        handle.readabilityHandler = nil\n        return\n      }\n      guard let text = String(data: data, encoding: .utf8), !text.isEmpty else { return }\n      for line in text.split(whereSeparator: \\.isNewline) {\n        fputs(\"[codex stderr] \\(line)\\n\", stderr)\n      }\n    }\n  }\n\n  func initialize(clientName: String, clientVersion: String) async throws {\n    _ = try await request(\n      method: \"initialize\",\n      params: [\"clientInfo\": [\"name\": clientName, \"version\": clientVersion]]\n    )\n    try sendNotification(method: \"initialized\")\n  }\n\n  func fetchRateLimits() async throws -> RPCRateLimitsResponse {\n    let message = try await request(method: \"account/rateLimits/read\")\n    return try decodeResult(from: message)\n  }\n\n  func fetchAccount() async throws -> RPCAccountResponse {\n    let message = try await request(method: \"account/read\")\n    return try decodeResult(from: message)\n  }\n\n  func shutdown() {\n    if self.process.isRunning {\n      self.process.terminate()\n    }\n  }\n\n  // MARK: - JSON-RPC helpers\n\n  private func request(method: String, params: [String: Any]? = nil) async throws -> [String: Any] {\n    let id = self.nextID\n    self.nextID += 1\n    try sendRequest(id: id, method: method, params: params)\n\n    while true {\n      let message = try await readNextMessage()\n\n      if message[\"id\"] == nil {\n        continue\n      }\n\n      guard let messageID = jsonID(message[\"id\"]), messageID == id else { continue }\n\n      if let error = message[\"error\"] as? [String: Any],\n        let messageText = error[\"message\"] as? String\n      {\n        throw CodexAppServerProbeService.ProbeError.requestFailed(messageText)\n      }\n\n      return message\n    }\n  }\n\n  private func sendNotification(method: String, params: [String: Any]? = nil) throws {\n    let paramsValue: Any = params ?? [:]\n    try sendPayload([\"method\": method, \"params\": paramsValue])\n  }\n\n  private func sendRequest(id: Int, method: String, params: [String: Any]?) throws {\n    let paramsValue: Any = params ?? [:]\n    try sendPayload([\"id\": id, \"method\": method, \"params\": paramsValue])\n  }\n\n  private func sendPayload(_ payload: [String: Any]) throws {\n    let data = try JSONSerialization.data(withJSONObject: payload)\n    self.stdinPipe.fileHandleForWriting.write(data)\n    self.stdinPipe.fileHandleForWriting.write(Data([0x0A]))\n  }\n\n  private func readNextMessage() async throws -> [String: Any] {\n    for try await lineData in self.stdoutPipe.fileHandleForReading.bytes.lines {\n      if lineData.isEmpty { continue }\n      let line = String(lineData)\n      guard let data = line.data(using: .utf8) else { continue }\n      if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] {\n        return json\n      }\n    }\n    throw CodexAppServerProbeService.ProbeError.malformedResponse(\"codex app-server closed stdout\")\n  }\n\n  private func decodeResult<T: Decodable>(from message: [String: Any]) throws -> T {\n    guard let result = message[\"result\"] else {\n      throw CodexAppServerProbeService.ProbeError.malformedResponse(\"missing result field\")\n    }\n    let data = try JSONSerialization.data(withJSONObject: result)\n    return try JSONDecoder().decode(T.self, from: data)\n  }\n\n  private func jsonID(_ value: Any?) -> Int? {\n    switch value {\n    case let int as Int:\n      return int\n    case let number as NSNumber:\n      return number.intValue\n    default:\n      return nil\n    }\n  }\n\n  private static func buildEnvironment() -> [String: String] {\n    var env = ProcessInfo.processInfo.environment\n    let base = CLIEnvironment.buildBasePATH()\n    if let current = env[\"PATH\"], !current.isEmpty {\n      env[\"PATH\"] = base + \":\" + current\n    } else {\n      env[\"PATH\"] = base\n    }\n    env[\"NO_COLOR\"] = \"1\"\n    return env\n  }\n}\n"
  },
  {
    "path": "services/CodexConfigService.swift",
    "content": "import Foundation\n\n// MARK: - Models\n\npublic struct CodexProvider: Identifiable, Equatable, Sendable {\n    public var id: String                // table id, e.g., \"openai\"\n    public var name: String?             // display name\n    public var baseURL: String?\n    public var envKey: String?\n    public var wireAPI: String?\n    public var queryParamsRaw: String?   // raw TOML for query_params\n    public var httpHeadersRaw: String?   // raw TOML for http_headers\n    public var envHttpHeadersRaw: String?// raw TOML for env_http_headers\n    public var requestMaxRetries: Int?\n    public var streamMaxRetries: Int?\n    public var streamIdleTimeoutMs: Int?\n    public var managedByCodMate: Bool    // true when block contains our marker\n}\n\n// MARK: - Service\n\nactor CodexConfigService {\n    struct Paths {\n        let home: URL\n        let configURL: URL\n\n        static func `default`(fileManager: FileManager = .default) -> Paths {\n            // Use real user home (not sandbox container) so Codex CLI can read the config\n            let userHome = SessionPreferencesStore.getRealUserHomeURL()\n            let home = userHome.appendingPathComponent(\".codex\", isDirectory: true)\n            return Paths(home: home, configURL: home.appendingPathComponent(\"config.toml\", isDirectory: false))\n        }\n    }\n\n    private let paths: Paths\n    private let fm: FileManager\n\n    init(paths: Paths = .default(), fileManager: FileManager = .default) {\n        self.paths = paths\n        self.fm = fileManager\n    }\n\n    // MARK: - Diagnostics models\n    struct ProviderDiagnostics: Sendable {\n        var configPath: String\n        var providers: [CodexProvider]\n        var headerCounts: [String: Int]   // id -> occurrences in raw file\n        var duplicateIDs: [String]\n        var strayManagedBodies: Int       // bodies without header likely left behind\n        var canonicalRegion: String       // canonical providers region text (not applied)\n    }\n\n    // MARK: Public API (phase 1)\n\n    func listProviders() -> [CodexProvider] {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return parseProviders(from: text)\n    }\n\n    func activeProvider() -> String? {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return parseTopLevelString(key: \"model_provider\", from: text)\n    }\n\n    func setActiveProvider(_ id: String?) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = upsertTopLevelString(key: \"model_provider\", value: id, in: text)\n        try writeConfig(text)\n    }\n\n    func upsertProvider(_ provider: CodexProvider) throws {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        // Build new providers array based on current parsed providers (deduped by id)\n        var current = parseProviders(from: text)\n        if let idx = current.firstIndex(where: { $0.id == provider.id }) {\n            current[idx] = provider\n        } else {\n            current.append(provider)\n        }\n        let rewritten = rewriteProvidersRegion(in: text, with: current)\n        try writeConfig(rewritten)\n    }\n\n    func deleteProvider(id: String) throws {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        var current = parseProviders(from: text)\n        current.removeAll { $0.id == id }\n        let rewritten = rewriteProvidersRegion(in: text, with: current)\n        try writeConfig(rewritten)\n    }\n\n    func replaceProviders(with providers: [CodexProvider]) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = rewriteProvidersRegion(in: text, with: providers)\n        try writeConfig(text)\n    }\n\n    func applyProviderFromRegistry(_ provider: ProvidersRegistryService.Provider?) throws {\n        if let provider {\n            guard\n                let connector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]\n            else {\n                try replaceProviders(with: [])\n                try setActiveProvider(nil)\n                return\n            }\n            var envKeyValue = provider.envKey ?? connector.envKey\n            var headerMap = connector.httpHeaders ?? [:]\n            if let v = envKeyValue {\n                let lower = v.lowercased()\n                let looksLikeToken = lower.contains(\"sk-\") || v.hasPrefix(\"eyJ\") || v.contains(\".\")\n                if looksLikeToken {\n                    envKeyValue = nil\n                    headerMap[\"Authorization\"] = v.hasPrefix(\"Bearer \") ? v : \"Bearer \\(v)\"\n                }\n            }\n            let codexProvider = CodexProvider(\n                id: provider.id,\n                name: provider.name,\n                baseURL: connector.baseURL,\n                envKey: envKeyValue,\n                wireAPI: (connector.wireAPI?.lowercased() == \"responses\") ? \"responses\" : \"chat\",\n                queryParamsRaw: connector.queryParams.map { renderInlineTable($0) },\n                httpHeadersRaw: headerMap.isEmpty ? nil : renderInlineTable(headerMap),\n                envHttpHeadersRaw: connector.envHttpHeaders.map { renderInlineTable($0) },\n                requestMaxRetries: connector.requestMaxRetries,\n                streamMaxRetries: connector.streamMaxRetries,\n                streamIdleTimeoutMs: connector.streamIdleTimeoutMs,\n                managedByCodMate: provider.managedByCodMate\n            )\n            try replaceProviders(with: [codexProvider])\n            try setActiveProvider(provider.id)\n        } else {\n            try replaceProviders(with: [])\n            try setActiveProvider(nil)\n        }\n    }\n\n    func applyLocalProxyProvider(\n        providerId: String = \"codmate-proxy\",\n        port: Int,\n        apiKey: String?,\n        modelId: String?\n    ) throws {\n        let baseURL = \"http://127.0.0.1:\\(port)/v1\"\n        var headers: [String: String] = [:]\n        if let key = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines), !key.isEmpty {\n            headers[\"Authorization\"] = key.hasPrefix(\"Bearer \") ? key : \"Bearer \\(key)\"\n        }\n        let provider = CodexProvider(\n            id: providerId,\n            name: \"CLI Proxy API\",\n            baseURL: baseURL,\n            envKey: nil,\n            // CLI Proxy API supports Responses; default to the modern wire API.\n            wireAPI: \"responses\",\n            queryParamsRaw: nil,\n            httpHeadersRaw: headers.isEmpty ? nil : renderInlineTable(headers),\n            envHttpHeadersRaw: nil,\n            requestMaxRetries: nil,\n            streamMaxRetries: nil,\n            streamIdleTimeoutMs: nil,\n            managedByCodMate: true\n        )\n        try replaceProviders(with: [provider])\n        try setActiveProvider(providerId)\n        try setTopLevelString(\"model\", value: modelId)\n    }\n\n    // MARK: - Runtime: model, reasoning, sandbox, approvals\n\n    func getTopLevelString(_ key: String) -> String? {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return parseTopLevelString(key: key, from: text)\n    }\n\n    func setTopLevelString(_ key: String, value: String?) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = upsertTopLevelString(key: key, value: value, in: text)\n        try writeConfig(text)\n    }\n\n    func setSandboxMode(_ mode: String?) throws { try setTopLevelString(\"sandbox_mode\", value: mode) }\n    func setApprovalPolicy(_ policy: String?) throws { try setTopLevelString(\"approval_policy\", value: policy) }\n\n    // MARK: - Features overrides\n\n    func featureOverrides() -> [String: Bool] {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return parseFeatureOverrides(from: text)\n    }\n\n    func setFeatureOverride(name: String, value: Bool?) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        var overrides = parseFeatureOverrides(from: text)\n        if let value {\n            overrides[name] = value\n        } else {\n            overrides.removeValue(forKey: name)\n        }\n        text = rewriteFeaturesBlock(in: text, overrides: overrides)\n        try writeConfig(text)\n    }\n\n    // MARK: - TUI notifications and notify bridge\n\n    func getTuiNotifications() -> Bool {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return (parseTableKeyValue(table: \"[tui]\", key: \"notifications\", from: text) ?? \"false\")\n            .trimmingCharacters(in: .whitespacesAndNewlines) == \"true\"\n    }\n\n    func setTuiNotifications(_ enabled: Bool) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = upsertTableKeyValue(table: \"[tui]\", key: \"notifications\", valueText: enabled ? \"true\" : \"false\", in: text)\n        try writeConfig(text)\n    }\n\n    func getNotifyArray() -> [String] {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        if let v = parseTopLevelArray(key: \"notify\", from: text) { return v }\n        return []\n    }\n\n    func setNotifyArray(_ arr: [String]?) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = upsertTopLevelArray(key: \"notify\", values: arr, in: text)\n        try writeConfig(text)\n    }\n\n    // MARK: - Hooks (CodMate-managed)\n    // Codex currently exposes a legacy `notify = [\"cmd\", \"args...\"]` mechanism that runs after a turn completes.\n    // We treat a single `Stop` hook (single command) as a mapping target for this legacy interface.\n    func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] {\n        var warnings: [HookSyncWarning] = []\n        let targeted = rules.filter { $0.isEnabled(for: .codex) }\n        var eligible: [HookRule] = []\n        for rule in targeted {\n            let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !rawEvent.isEmpty else { continue }\n            let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .codex)\n            if resolution.isKnown, !resolution.isSupported {\n                warnings.append(HookSyncWarning(\n                    provider: .codex,\n                    message: \"Codex does not support hook event \\\"\\(rawEvent)\\\"; skipping \\\"\\(rule.name)\\\".\"\n                ))\n                continue\n            }\n            if resolution.name != \"Stop\" {\n                warnings.append(HookSyncWarning(\n                    provider: .codex,\n                    message: \"Codex currently supports only Stop via `notify`; skipping \\\"\\(rule.name)\\\".\"\n                ))\n                continue\n            }\n            eligible.append(rule)\n        }\n\n        guard !eligible.isEmpty else {\n            let existing = getNotifyArray()\n            if shouldClearNotify(existing: existing, rules: rules) {\n                try setNotifyArray(nil)\n            }\n            return warnings\n        }\n\n        guard eligible.count == 1 else {\n            warnings.append(HookSyncWarning(\n                provider: .codex,\n                message: \"Codex currently supports only one Stop hook via `notify`. Multiple CodMate rules target Codex; skipping apply.\"\n            ))\n            return warnings\n        }\n\n        let rule = eligible[0]\n        guard rule.commands.count == 1 else {\n            warnings.append(HookSyncWarning(\n                provider: .codex,\n                message: \"Codex `notify` supports only a single command. Hook \\\"\\(rule.name)\\\" has \\(rule.commands.count) command(s); skipping apply.\"\n            ))\n            return warnings\n        }\n\n        let cmd = rule.commands[0]\n        let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !program.isEmpty else {\n            warnings.append(HookSyncWarning(provider: .codex, message: \"Hook \\\"\\(rule.name)\\\" has an empty command; skipping apply.\"))\n            return warnings\n        }\n        if let env = cmd.env, !env.isEmpty {\n            warnings.append(HookSyncWarning(\n                provider: .codex,\n                message: \"Codex `notify` does not support per-hook environment variables; ignoring env for \\\"\\(rule.name)\\\".\"\n            ))\n        }\n\n        let previous = getNotifyArray()\n        if let existing = previous.first, existing.contains(\"codmate-notify\") {\n            warnings.append(HookSyncWarning(\n                provider: .codex,\n                message: \"Applying this hook will overwrite CodMate's notify bridge and may disable CodMate system notifications for Codex.\"\n            ))\n        }\n\n        var argv: [String] = [program]\n        if let args = cmd.args, !args.isEmpty {\n            argv.append(contentsOf: args)\n        }\n        try setNotifyArray(argv)\n        return warnings\n    }\n\n    private func shouldClearNotify(existing: [String], rules: [HookRule]) -> Bool {\n        guard !existing.isEmpty else { return false }\n        let candidates = rules.filter { rule in\n            let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !rawEvent.isEmpty else { return false }\n            let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .codex)\n            return resolution.name == \"Stop\"\n        }\n        for rule in candidates {\n            for cmd in rule.commands {\n                let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !program.isEmpty else { continue }\n                var argv: [String] = [program]\n                if let args = cmd.args, !args.isEmpty {\n                    argv.append(contentsOf: args)\n                }\n                if argv == existing {\n                    return true\n                }\n            }\n        }\n        return false\n    }\n\n    func ensureNotifyBridgeInstalled() throws -> URL {\n        let bin = paths.home.deletingLastPathComponent()\n            .appendingPathComponent(\"Library\", isDirectory: true)\n            .appendingPathComponent(\"Application Support\", isDirectory: true)\n            .appendingPathComponent(\"CodMate\", isDirectory: true)\n            .appendingPathComponent(\"bin\", isDirectory: true)\n        try fm.createDirectory(at: bin, withIntermediateDirectories: true)\n        let target = bin.appendingPathComponent(\"codmate-notify\")\n        guard let bundled = Self.bundledNotifyBinaryURL() else {\n            if fm.fileExists(atPath: target.path) {\n                return target\n            }\n            throw NSError(domain: \"CodMate\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"Bundled codmate-notify helper not found\"])\n        }\n        if fm.fileExists(atPath: target.path) {\n            try? fm.removeItem(at: target)\n        }\n        try fm.copyItem(at: bundled, to: target)\n        try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: target.path)\n        return target\n    }\n\n    private static func bundledNotifyBinaryURL() -> URL? {\n        #if os(macOS)\n            if let url = Bundle.main.url(forResource: \"codmate-notify\", withExtension: nil, subdirectory: \"bin\") {\n                return url\n            }\n            return Bundle.main.url(forResource: \"codmate-notify\", withExtension: nil)\n        #else\n            return nil\n        #endif\n    }\n\n    // MARK: - Raw config helpers\n    func configFileURL() -> URL { paths.configURL }\n    func readRawConfigText() -> String {\n        (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n    }\n\n    // MARK: - Providers Diagnostics\n    func diagnoseProviders() -> ProviderDiagnostics {\n        let raw = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let list = parseProviders(from: raw)\n        var counts: [String: Int] = [:]\n        // Count headers occurrences\n        for line in raw.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init) {\n            let t = line.trimmingCharacters(in: .whitespaces)\n            if let id = matchProviderHeader(t) { counts[id, default: 0] += 1 }\n        }\n        let dups = counts.filter { $0.value > 1 }.map { $0.key }.sorted()\n        let stray = countStrayProviderBodies(in: raw)\n        // Build canonical region (with leading blank line per style)\n        var canonical = raw\n        canonical = rewriteProvidersRegion(in: canonical, with: list)\n        let region: String = {\n            // extract only the appended canonical region portion by rebuilding from empty baseline\n            var s = \"\"\n            for p in list {\n                if !s.hasSuffix(\"\\n\\n\") { if !s.isEmpty { s += \"\\n\" } else { s += \"\\n\\n\" } }\n                s += \"[model_providers.\\(p.id)]\\n\"\n                s += renderProviderBody(p)\n                s += \"\\n\"\n            }\n            return s\n        }()\n        return ProviderDiagnostics(\n            configPath: paths.configURL.path,\n            providers: list,\n            headerCounts: counts,\n            duplicateIDs: dups,\n            strayManagedBodies: stray,\n            canonicalRegion: region\n        )\n    }\n\n    // MARK: - MCP Servers (managed region)\n    private let mcpBeginMarker = \"# codmate-mcp begin\"\n    private let mcpEndMarker = \"# codmate-mcp end\"\n\n    func applyMCPServers(_ servers: [MCPServer]) throws {\n        if !SessionPreferencesStore.isCLIEnabled(.codex) { return }\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        // Strip previous managed region if present\n        if let begin = text.range(of: mcpBeginMarker), let end = text.range(of: mcpEndMarker) {\n            text.removeSubrange(begin.lowerBound..<(end.upperBound))\n        }\n        // Normalize trailing whitespace/newlines to avoid accumulating blank lines\n        while let last = text.last, last == \"\\n\" || last == \"\\r\" || last == \"\\t\" || last == \" \" {\n            text.removeLast()\n        }\n        // Build new region with enabled servers only\n        let enabled = servers.enabledServers(for: .codex)\n        guard !enabled.isEmpty else {\n            try writeConfig(text)\n            return\n        }\n        // Ensure at most a single empty line before the managed block\n        var region = (text.isEmpty ? \"\" : \"\\n\\n\") + \"\\(mcpBeginMarker)\\n\"\n        for s in enabled {\n            // Single canonical block per server: [mcp_servers.<name>]\n            var body: [String] = []\n            body.append(\"kind = \\\"\\(s.kind.rawValue)\\\"\")\n            if let url = s.url, !url.isEmpty { body.append(\"url = \\\"\\(url)\\\"\") }\n            if let cmd = s.command, !cmd.isEmpty { body.append(\"command = \\\"\\(cmd)\\\"\") }\n            if let args = s.args, !args.isEmpty {\n                let quoted = args.map { \"\\\"\\($0)\\\"\" }.joined(separator: \", \")\n                body.append(\"args = [ \\(quoted) ]\")\n            }\n            if let env = s.env, !env.isEmpty { body.append(\"env = \\(renderInlineTable(env))\") }\n            if let headers = s.headers, !headers.isEmpty { body.append(\"headers = \\(renderInlineTable(headers))\") }\n            region += \"[mcp_servers.\\(s.name)]\\n\" + body.joined(separator: \"\\n\") + \"\\n\\n\"\n        }\n        region += \"\\(mcpEndMarker)\\n\"\n        text += region\n        try writeConfig(text)\n    }\n\n    // MARK: - Privacy: shell_environment_policy\n\n    struct ShellEnvironmentPolicy {\n        var inherit: String? // all|core|none\n        var ignoreDefaultExcludes: Bool?\n        var includeOnly: [String]?\n        var exclude: [String]?\n        var set: [String:String]?\n    }\n\n    func getShellEnvironmentPolicy() -> ShellEnvironmentPolicy {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let body = parseTableBody(table: \"[shell_environment_policy]\", from: text)\n        var policy = ShellEnvironmentPolicy(inherit: nil, ignoreDefaultExcludes: nil, includeOnly: nil, exclude: nil, set: nil)\n        for line in body {\n            let t = line.trimmingCharacters(in: .whitespaces)\n            guard !t.hasPrefix(\"#\"), let eq = t.firstIndex(of: \"=\") else { continue }\n            let key = t[..<eq].trimmingCharacters(in: .whitespaces)\n            let value = String(t[t.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n            switch key {\n            case \"inherit\": policy.inherit = unquote(value)\n            case \"ignore_default_excludes\": policy.ignoreDefaultExcludes = (value == \"true\")\n            case \"include_only\": policy.includeOnly = parseArrayLiteral(value)\n            case \"exclude\": policy.exclude = parseArrayLiteral(value)\n            case \"set\": policy.set = parseInlineTable(value)\n            default: break\n            }\n        }\n        return policy\n    }\n\n    func setShellEnvironmentPolicy(_ p: ShellEnvironmentPolicy) throws {\n        var body: [String] = [\"# managed-by=codmate\"]\n        if let v = p.inherit { body.append(\"inherit = \\\"\\(v)\\\"\") }\n        if let v = p.ignoreDefaultExcludes { body.append(\"ignore_default_excludes = \\(v ? \"true\" : \"false\")\") }\n        if let v = p.includeOnly { body.append(\"include_only = \\(renderArrayLiteral(v))\") }\n        if let v = p.exclude { body.append(\"exclude = \\(renderArrayLiteral(v))\") }\n        if let v = p.set, !v.isEmpty { body.append(\"set = \\(renderInlineTable(v))\") }\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = replaceTableBlock(header: \"[shell_environment_policy]\", body: body.joined(separator: \"\\n\") + \"\\n\", in: text)\n        try writeConfig(text)\n    }\n\n    // MARK: - Privacy: reasoning toggles & file opener\n\n    func getBool(_ key: String) -> Bool {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let val = parseTopLevelString(key: key, from: text) ?? \"false\"\n        return val == \"true\"\n    }\n\n    func setBool(_ key: String, _ value: Bool) throws {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = upsertTopLevelBool(key: key, value: value, in: text)\n        try writeConfig(text)\n    }\n\n    func setFileOpener(_ opener: String?) throws { try setTopLevelString(\"file_opener\", value: opener) }\n\n    // MARK: - Privacy: OTEL (simplified)\n\n    enum OtelExporterKind: String { case none, otlpHttp = \"otlp-http\", otlpGrpc = \"otlp-grpc\" }\n    struct OtelConfig { var environment: String?; var exporterKind: OtelExporterKind; var endpoint: String? }\n\n    func getOtelConfig() -> OtelConfig {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let body = parseTableBody(table: \"[otel]\", from: text)\n        var env: String?; var kind: OtelExporterKind = .none; var endpoint: String? = nil\n        for raw in body {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            if line.hasPrefix(\"environment\") {\n                if let eq = line.firstIndex(of: \"=\") {\n                    env = unquote(String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces))\n                }\n            }\n            if line.hasPrefix(\"exporter\") {\n                if line.contains(\"otlp-http\") { kind = .otlpHttp }\n                else if line.contains(\"otlp-grpc\") { kind = .otlpGrpc }\n                else if line.contains(\"none\") { kind = .none }\n                if let e = extractInlineEndpoint(from: line) { endpoint = e }\n            }\n        }\n        return OtelConfig(environment: env, exporterKind: kind, endpoint: endpoint)\n    }\n\n    func setOtelConfig(_ oc: OtelConfig) throws {\n        var lines: [String] = [\"# managed-by=codmate\"]\n        if let env = oc.environment, !env.isEmpty { lines.append(\"environment = \\\"\\(env)\\\"\") }\n        switch oc.exporterKind {\n        case .none:\n            lines.append(\"exporter = \\\"none\\\"\")\n        case .otlpHttp:\n            let endpoint = oc.endpoint ?? \"\"\n            lines.append(\"exporter = { otlp-http = { endpoint = \\\"\\(endpoint)\\\", protocol = \\\"binary\\\" } }\")\n        case .otlpGrpc:\n            let endpoint = oc.endpoint ?? \"\"\n            lines.append(\"exporter = { otlp-grpc = { endpoint = \\\"\\(endpoint)\\\" } }\")\n        }\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        text = replaceTableBlock(header: \"[otel]\", body: lines.joined(separator: \"\\n\") + \"\\n\", in: text)\n        try writeConfig(text)\n    }\n\n    // MARK: - File IO helpers\n\n    private func writeConfig(_ text: String) throws {\n        try fm.createDirectory(at: paths.home, withIntermediateDirectories: true)\n        // Backup existing\n        if fm.fileExists(atPath: paths.configURL.path) {\n            let bak = paths.home.appendingPathComponent(\"config.toml.bak\")\n            try? fm.removeItem(at: bak)\n            try fm.copyItem(at: paths.configURL, to: bak)\n        }\n        try text.write(to: paths.configURL, atomically: true, encoding: .utf8)\n    }\n\n    // MARK: - Parsing (naïve, line-based; tolerant by design)\n\n    private func parseProviders(from text: String) -> [CodexProvider] {\n        // Parse and deduplicate by id. If the same id appears multiple times,\n        // keep the LAST occurrence in the file (common when blocks were rewritten).\n        var map: [String: CodexProvider] = [:]\n        var order: [String] = []\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n\n        var i = 0\n        while i < lines.count {\n            let line = lines[i].trimmingCharacters(in: .whitespaces)\n            if let id = matchProviderHeader(line) {\n                var j = i + 1\n                var body: [String] = []\n                while j < lines.count {\n                    let l = lines[j]\n                    if l.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") { break }\n                    body.append(l)\n                    j += 1\n                }\n                let p = parseProviderBody(id: id, body: body)\n                map[id] = p\n                if !order.contains(id) { order.append(id) }\n                i = j\n                continue\n            }\n            i += 1\n        }\n\n        return order.compactMap { map[$0] }\n    }\n\n    private func matchProviderHeader(_ line: String) -> String? {\n        // [model_providers.<id>]\n        guard line.hasPrefix(\"[model_providers.\") && line.hasSuffix(\"]\") else { return nil }\n        let start = \"[model_providers.\".count\n        let endIndex = line.index(before: line.endIndex)\n        let id = String(line[line.index(line.startIndex, offsetBy: start)..<endIndex])\n        return id.trimmingCharacters(in: .whitespaces)\n    }\n\n    private func parseProviderBody(id: String, body: [String]) -> CodexProvider {\n        var p = CodexProvider(id: id, name: nil, baseURL: nil, envKey: nil, wireAPI: nil,\n                              queryParamsRaw: nil, httpHeadersRaw: nil, envHttpHeadersRaw: nil,\n                              requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n                              managedByCodMate: false)\n        for raw in body {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            if line.contains(\"managed-by=codmate\") { p.managedByCodMate = true }\n            guard !line.hasPrefix(\"#\"), let eq = line.firstIndex(of: \"=\") else { continue }\n            let key = line[..<eq].trimmingCharacters(in: .whitespaces)\n            let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)\n\n            switch key {\n            case \"name\": p.name = unquote(value)\n            case \"base_url\": p.baseURL = unquote(value)\n            case \"env_key\": p.envKey = unquote(value)\n            case \"wire_api\": p.wireAPI = unquote(value)\n            case \"query_params\": p.queryParamsRaw = value\n            case \"http_headers\": p.httpHeadersRaw = value\n            case \"env_http_headers\": p.envHttpHeadersRaw = value\n            case \"request_max_retries\": p.requestMaxRetries = Int(value.filter { !$0.isWhitespace })\n            case \"stream_max_retries\": p.streamMaxRetries = Int(value.filter { !$0.isWhitespace })\n            case \"stream_idle_timeout_ms\": p.streamIdleTimeoutMs = Int(value.filter { !$0.isWhitespace })\n            default: break\n            }\n        }\n        return p\n    }\n\n    private func unquote(_ v: String) -> String {\n        var s = v.trimmingCharacters(in: .whitespaces)\n        if s.hasPrefix(\"\\\"\") && s.hasSuffix(\"\\\"\") { s.removeFirst(); s.removeLast() }\n        return s\n    }\n\n    private func escapeTomlString(_ value: String) -> String {\n        var escaped = value.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\")\n        escaped = escaped.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n        return escaped\n    }\n\n    private func projectHeader(for path: String, in text: String) -> String {\n        let projects = parseProjects(from: text)\n        if let match = projects.first(where: { project in\n            let dir = project.directory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n            let idUnquoted = unquote(project.id)\n            return dir == path || idUnquoted == path || project.id == path\n        }) {\n            return \"[projects.\\(match.id)]\"\n        }\n        let escapedPath = escapeTomlString(path)\n        return \"[projects.\\\"\\(escapedPath)\\\"]\"\n    }\n\n    private func headerHasQuotedPathKey(_ header: String, path: String) -> Bool {\n        let quotedPath = \"\\\"\\(escapeTomlString(path))\\\"\"\n        return header == \"[projects.\\(quotedPath)]\"\n    }\n\n    private func parseTopLevelString(key: String, from text: String) -> String? {\n        // naive: find first line starting with key =\n        for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init) {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            guard line.hasPrefix(key + \" \") || line.hasPrefix(key + \"=\") else { continue }\n            guard let eq = line.firstIndex(of: \"=\") else { continue }\n            let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)\n            return unquote(value)\n        }\n        return nil\n    }\n\n    private func parseTopLevelArray(key: String, from text: String) -> [String]? {\n        for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init) {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            guard line.hasPrefix(key + \" \") || line.hasPrefix(key + \"=\") else { continue }\n            guard let eq = line.firstIndex(of: \"=\") else { continue }\n            let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces)\n            return parseArrayLiteral(value)\n        }\n        return nil\n    }\n\n    // MARK: - Writing helpers\n\n    private func upsertTopLevelString(key: String, value: String?, in text: String) -> String {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var found = false\n        for i in lines.indices {\n            let t = lines[i].trimmingCharacters(in: .whitespaces)\n            if t.hasPrefix(key + \" \") || t.hasPrefix(key + \"=\") {\n                if let value {\n                    lines[i] = \"\\(key) = \\\"\\(value)\\\"\"\n                } else {\n                    lines.remove(at: i)\n                }\n                found = true\n                break\n            }\n        }\n        if !found, let value {\n            // insert near top (before first table)\n            var insertIndex = lines.count\n            for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") {\n                insertIndex = idx\n                break\n            }\n            lines.insert(\"\\(key) = \\\"\\(value)\\\"\", at: insertIndex)\n        }\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func upsertTopLevelBool(key: String, value: Bool, in text: String) -> String {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var found = false\n        for i in lines.indices {\n            let t = lines[i].trimmingCharacters(in: .whitespaces)\n            if t.hasPrefix(key + \" \") || t.hasPrefix(key + \"=\") {\n                lines[i] = \"\\(key) = \\(value ? \"true\" : \"false\")\"\n                found = true\n                break\n            }\n        }\n        if !found {\n            var insertIndex = lines.count\n            for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") {\n                insertIndex = idx\n                break\n            }\n            lines.insert(\"\\(key) = \\(value ? \"true\" : \"false\")\", at: insertIndex)\n        }\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func upsertTopLevelArray(key: String, values: [String]?, in text: String) -> String {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var foundIndex: Int? = nil\n        for i in lines.indices {\n            let t = lines[i].trimmingCharacters(in: .whitespaces)\n            if t.hasPrefix(key + \" \") || t.hasPrefix(key + \"=\") { foundIndex = i; break }\n        }\n        if let arr = values {\n            let literal = renderArrayLiteral(arr)\n            if let i = foundIndex {\n                lines[i] = \"\\(key) = \\(literal)\"\n            } else {\n                var insertIndex = lines.count\n                for (idx, l) in lines.enumerated() where l.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") { insertIndex = idx; break }\n                lines.insert(\"\\(key) = \\(literal)\", at: insertIndex)\n            }\n        } else if let i = foundIndex {\n            lines.remove(at: i)\n        }\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func upsertProviderBlock(_ p: CodexProvider, in text: String) -> String {\n        let header = \"[model_providers.\\(p.id)]\"\n        let body = renderProviderBody(p)\n        return replaceTableBlock(header: header, body: body, in: text)\n    }\n\n    private func removeProviderBlock(id: String, in text: String) -> String {\n        // Remove ALL occurrences of the provider block with this id to avoid leftovers\n        let header = \"[model_providers.\\(id)]\"\n        var result = text\n        while true {\n            let newText = replaceTableBlock(header: header, body: nil, in: result)\n            if newText == result { break }\n            result = newText\n        }\n        return result\n    }\n\n    private func replaceTableBlock(header: String, body: String?, in text: String) -> String {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var start: Int? = nil\n        var end: Int? = nil\n        for (idx, raw) in lines.enumerated() {\n            let t = raw.trimmingCharacters(in: .whitespaces)\n            if t == header { start = idx; continue }\n            if start != nil && (t.hasPrefix(\"[\") || t == mcpBeginMarker || t == mcpEndMarker) {\n                end = idx\n                break\n            }\n        }\n\n        if let start {\n            let stop = end ?? lines.count\n            if let body {\n                var newBlock = [header]\n                newBlock.append(contentsOf: body.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init))\n                lines.replaceSubrange(start..<stop, with: newBlock)\n            } else {\n                lines.removeSubrange(start..<(end ?? lines.count))\n            }\n        } else if let body {\n            var newBlock = [header]\n            newBlock.append(contentsOf: body.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init))\n            if let idx = lines.lastIndex(where: { $0.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") }) {\n                lines.insert(contentsOf: newBlock + [\"\"], at: idx + 1)\n            } else {\n                if !lines.isEmpty { lines.append(\"\") }\n                lines.append(contentsOf: newBlock)\n                lines.append(\"\")\n            }\n        }\n\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func renderProviderBody(_ p: CodexProvider) -> String {\n        var out: [String] = []\n        out.append(\"# managed-by=codmate\")\n        if let name = p.name { out.append(\"name = \\\"\\(name)\\\"\") }\n        if let baseURL = p.baseURL, !baseURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"base_url = \\\"\\(baseURL)\\\"\")\n        }\n        if let envKey = p.envKey, !envKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"env_key = \\\"\\(envKey)\\\"\")\n        }\n        if let wire0 = p.wireAPI?.trimmingCharacters(in: .whitespacesAndNewlines), !wire0.isEmpty {\n            out.append(\"wire_api = \\\"\\(wire0)\\\"\")\n        }\n        if let qp = p.queryParamsRaw, !qp.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"query_params = \\(qp)\")\n        }\n        if let hh = p.httpHeadersRaw, !hh.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"http_headers = \\(hh)\")\n        }\n        if let ehh = p.envHttpHeadersRaw, !ehh.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"env_http_headers = \\(ehh)\")\n        }\n        if let r = p.requestMaxRetries { out.append(\"request_max_retries = \\(r)\") }\n        if let r = p.streamMaxRetries { out.append(\"stream_max_retries = \\(r)\") }\n        if let r = p.streamIdleTimeoutMs { out.append(\"stream_idle_timeout_ms = \\(r)\") }\n        return out.joined(separator: \"\\n\") + \"\\n\"\n    }\n\n    // MARK: - Projects (config-backed)\n\n    func listProjects() -> [Project] {\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        return parseProjects(from: text)\n    }\n\n    func ensureProjectTrusted(directory: URL, trustLevel: String = \"trusted\") throws {\n        let trimmed = trustLevel.trimmingCharacters(in: .whitespacesAndNewlines)\n        let level = trimmed.isEmpty ? \"trusted\" : trimmed\n        let path = directory.standardizedFileURL.path\n        let text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let header = projectHeader(for: path, in: text)\n        var updated = upsertTableKeyValue(\n            table: header,\n            key: \"trust_level\",\n            valueText: \"\\\"\\(level)\\\"\",\n            in: text\n        )\n        if !headerHasQuotedPathKey(header, path: path) {\n            updated = upsertTableKeyValue(\n                table: header,\n                key: \"directory\",\n                valueText: \"\\\"\\(escapeTomlString(path))\\\"\",\n                in: updated\n            )\n        }\n        try writeConfig(updated)\n    }\n\n    // Deprecated: CodMate no longer writes projects to config.toml.\n    // Kept for API compatibility; acts as a no-op.\n    func upsertProject(_ project: Project) throws {\n        _ = project\n        return\n    }\n\n    // Deprecated: CodMate no longer writes projects to config.toml.\n    // Kept for API compatibility; acts as a no-op.\n    func deleteProject(id: String) throws { _ = id }\n\n    // MARK: - Canonical providers region rewriter\n    private func rewriteProvidersRegion(in text: String, with providers: [CodexProvider]) -> String {\n        // 1) Remove all provider blocks (and any stray managed provider bodies without header)\n        let stripped = stripProviderLikeBlocks(from: text)\n        // 2) Append canonical providers region at the end (if any)\n        guard !providers.isEmpty else { return stripped }\n        var out = stripped\n        // Ensure there is an empty line separator before providers region\n        if !out.hasSuffix(\"\\n\") { out += \"\\n\" }\n        if !out.hasSuffix(\"\\n\\n\") { out += \"\\n\" }\n        for p in providers {\n            out += \"[model_providers.\\(p.id)]\\n\"\n            out += renderProviderBody(p)\n            out += \"\\n\"\n        }\n        return out\n    }\n\n    private func rewriteFeaturesBlock(in text: String, overrides: [String: Bool]) -> String {\n        var stripped = replaceTableBlock(header: \"[features]\", body: nil, in: text)\n        let entries = overrides.sorted(by: { $0.key < $1.key }).map { key, value in\n            \"\\(key) = \\(value ? \"true\" : \"false\")\"\n        }\n        guard !entries.isEmpty else { return stripped }\n        if !stripped.hasSuffix(\"\\n\") { stripped += \"\\n\" }\n        if !stripped.hasSuffix(\"\\n\\n\") { stripped += \"\\n\" }\n        stripped += \"[features]\\n\"\n        stripped += \"# managed-by=codmate\\n\"\n        stripped += entries.joined(separator: \"\\n\") + \"\\n\\n\"\n        return stripped\n    }\n\n    private func parseFeatureOverrides(from text: String) -> [String: Bool] {\n        let body = parseTableBody(table: \"[features]\", from: text)\n        var overrides: [String: Bool] = [:]\n        for raw in body {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            guard !line.isEmpty, !line.hasPrefix(\"#\"), let eq = line.firstIndex(of: \"=\") else { continue }\n            let key = line[..<eq].trimmingCharacters(in: .whitespaces)\n            let value = line[line.index(after: eq)...].trimmingCharacters(in: .whitespaces).lowercased()\n            if value == \"true\" {\n                overrides[key] = true\n            } else if value == \"false\" {\n                overrides[key] = false\n            }\n        }\n        return overrides\n    }\n\n    private func stripProviderLikeBlocks(from text: String) -> String {\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var keep: [String] = []\n        var i = 0\n        func isHeader(_ t: String) -> Bool { t.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") }\n        let providerKeys: Set<String> = [\n            \"name\", \"base_url\", \"env_key\", \"wire_api\", \"query_params\", \"http_headers\",\n            \"env_http_headers\", \"request_max_retries\", \"stream_max_retries\",\n            \"stream_idle_timeout_ms\",\n        ]\n        while i < lines.count {\n            let t = lines[i].trimmingCharacters(in: .whitespaces)\n            // Remove normal provider blocks\n            if t.hasPrefix(\"[model_providers.\") && t.hasSuffix(\"]\") {\n                // skip until next header or EOF\n                i += 1\n                while i < lines.count {\n                    let t2 = lines[i].trimmingCharacters(in: .whitespaces)\n                    if isHeader(t2) { break }\n                    i += 1\n                }\n                continue\n            }\n            // Remove stray managed provider bodies without header (best-effort):\n            // A sequence starting with '# managed-by=codmate' followed by lines whose keys\n            // are all within providerKeys constitutes such a body.\n            if t.contains(\"managed-by=codmate\") {\n                _ = i\n                var j = i + 1\n                var looksLikeProvider = false\n                while j < lines.count {\n                    let raw = lines[j]\n                    let tr = raw.trimmingCharacters(in: .whitespaces)\n                    if tr.isEmpty { j += 1; continue }\n                    if isHeader(tr) { break }\n                    // key check\n                    if let eq = tr.firstIndex(of: \"=\") {\n                        let key = tr[..<eq].trimmingCharacters(in: .whitespaces)\n                        if providerKeys.contains(key) { looksLikeProvider = true; j += 1; continue }\n                    }\n                    // encountered a non-provider-looking line — stop\n                    break\n                }\n                if looksLikeProvider {\n                    // drop [start, j)\n                    i = j\n                    continue\n                }\n            }\n            // Keep line\n            keep.append(lines[i])\n            i += 1\n        }\n        return keep.joined(separator: \"\\n\")\n    }\n\n    // MARK: - Projects region rewriter and parser\n\n    private func parseProjects(from text: String) -> [Project] {\n        var map: [String: Project] = [:]\n        var order: [String] = []\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var i = 0\n        while i < lines.count {\n            let line = lines[i].trimmingCharacters(in: .whitespaces)\n            if let id = matchProjectHeader(line) {\n                var j = i + 1\n                var body: [String] = []\n                while j < lines.count {\n                    let l = lines[j]\n                    if l.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") { break }\n                    body.append(l)\n                    j += 1\n                }\n                let p = parseProjectBody(id: id, body: body)\n                map[id] = p\n                if !order.contains(id) { order.append(id) }\n                i = j\n                continue\n            }\n            i += 1\n        }\n        return order.compactMap { map[$0] }\n    }\n\n    private func matchProjectHeader(_ line: String) -> String? {\n        // [projects.<id>]\n        guard line.hasPrefix(\"[projects.\") && line.hasSuffix(\"]\") else { return nil }\n        let start = \"[projects.\".count\n        let endIndex = line.index(before: line.endIndex)\n        let id = String(line[line.index(line.startIndex, offsetBy: start)..<endIndex])\n        return id.trimmingCharacters(in: .whitespaces)\n    }\n\n    private func parseProjectBody(id: String, body: [String]) -> Project {\n        var name: String = id\n        var directory: String? = nil\n        var trust: String? = nil\n        var overview: String? = nil\n        var instructions: String? = nil\n        var profile: String? = nil\n        for raw in body {\n            let line = raw.trimmingCharacters(in: .whitespaces)\n            guard !line.hasPrefix(\"#\"), let eq = line.firstIndex(of: \"=\") else { continue }\n            let key = line[..<eq].trimmingCharacters(in: .whitespaces)\n            let value = String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n            switch key {\n            case \"name\": name = unquote(value)\n            case \"directory\": directory = unquote(value)\n            case \"path\": if directory == nil || directory == \"\" { directory = unquote(value) }\n            case \"trust_level\": trust = unquote(value)\n            case \"overview\": overview = unquote(value)\n            case \"instructions\": instructions = unquote(value)\n            case \"profile\": profile = unquote(value)\n            default: break\n            }\n        }\n        // Normalize directory: treat empty string as nil\n        let dirNorm: String? = {\n            guard let d = directory?.trimmingCharacters(in: .whitespacesAndNewlines) else { return nil }\n            return d.isEmpty ? nil : d\n        }()\n        return Project(id: id, name: name, directory: dirNorm, trustLevel: trust, overview: overview, instructions: instructions, profileId: profile)\n    }\n\n    private func renderProjectBody(_ p: Project) -> String {\n        var out: [String] = []\n        out.append(\"# managed-by=codmate\")\n        if !p.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { out.append(\"name = \\\"\\(p.name)\\\"\") }\n        if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            out.append(\"directory = \\\"\\(dir)\\\"\")\n        }\n        if let v = p.trustLevel, !v.isEmpty { out.append(\"trust_level = \\\"\\(v)\\\"\") }\n        if let v = p.overview, !v.isEmpty { out.append(\"overview = \\\"\\(v)\\\"\") }\n        if let v = p.instructions, !v.isEmpty { out.append(\"instructions = \\\"\\(v)\\\"\") }\n        if let v = p.profileId, !v.isEmpty { out.append(\"profile = \\\"\\(v)\\\"\") }\n        return out.joined(separator: \"\\n\") + \"\\n\"\n    }\n\n    private func rewriteProjectsRegion(in text: String, with projects: [Project]) -> String {\n        // Remove all project blocks\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        // Pass 1: strip all [projects.*] blocks\n        do {\n            var i = 0\n            while i < lines.count {\n                let t = lines[i].trimmingCharacters(in: .whitespaces)\n                if t.hasPrefix(\"[projects.\") && t.hasSuffix(\"]\") {\n                    var j = i + 1\n                    while j < lines.count {\n                        let tt = lines[j].trimmingCharacters(in: .whitespaces)\n                        if tt.hasPrefix(\"[\") { break }\n                        j += 1\n                    }\n                    lines.removeSubrange(i..<j)\n                    continue\n                }\n                i += 1\n            }\n        }\n        var out = lines.joined(separator: \"\\n\")\n        guard !projects.isEmpty else { return out }\n        if !out.hasSuffix(\"\\n\") { out += \"\\n\" }\n        if !out.hasSuffix(\"\\n\\n\") { out += \"\\n\" }\n        for p in projects {\n            out += \"[projects.\\(p.id)]\\n\"\n            out += renderProjectBody(p)\n            out += \"\\n\"\n        }\n        return out\n    }\n\n    private func countStrayProviderBodies(in text: String) -> Int {\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var i = 0\n        var count = 0\n        func isHeader(_ t: String) -> Bool { t.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") }\n        let providerKeys: Set<String> = [\n            \"name\", \"base_url\", \"env_key\", \"wire_api\", \"query_params\", \"http_headers\",\n            \"env_http_headers\", \"request_max_retries\", \"stream_max_retries\",\n            \"stream_idle_timeout_ms\",\n        ]\n        while i < lines.count {\n            let t = lines[i].trimmingCharacters(in: .whitespaces)\n            if t.contains(\"managed-by=codmate\") {\n                var j = i + 1\n                var looksLikeProvider = false\n                while j < lines.count {\n                    let tr = lines[j].trimmingCharacters(in: .whitespaces)\n                    if tr.isEmpty { j += 1; continue }\n                    if isHeader(tr) { break }\n                    if let eq = tr.firstIndex(of: \"=\") {\n                        let key = tr[..<eq].trimmingCharacters(in: .whitespaces)\n                        if providerKeys.contains(key) { looksLikeProvider = true; j += 1; continue }\n                    }\n                    break\n                }\n                if looksLikeProvider { count += 1; i = j; continue }\n            }\n            i += 1\n        }\n        return count\n    }\n\n    // MARK: - Migration helpers\n    // Ensure boolean keys are written as bare booleans (true/false), not quoted strings.\n    func sanitizeQuotedBooleans() -> Bool {\n        var text = (try? String(contentsOf: paths.configURL, encoding: .utf8)) ?? \"\"\n        let keys = [\n            \"show_raw_agent_reasoning\",\n            \"hide_agent_reasoning\",\n            \"suppress_unstable_features_warning\",\n        ]\n        var changed = false\n        for key in keys {\n            let (newText, didChange) = ensureTopLevelBoolLiteral(key: key, in: text)\n            if didChange { text = newText; changed = true }\n        }\n        if changed {\n            do { try writeConfig(text) } catch { /* ignore */ }\n        }\n        return changed\n    }\n\n    private func ensureTopLevelBoolLiteral(key: String, in text: String) -> (String, Bool) {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var changed = false\n        for i in lines.indices {\n            let raw = lines[i]\n            let t = raw.trimmingCharacters(in: .whitespaces)\n            guard t.hasPrefix(key + \" \") || t.hasPrefix(key + \"=\") else { continue }\n            guard let eq = raw.firstIndex(of: \"=\") else { continue }\n            let prefix = String(raw[..<eq]).trimmingCharacters(in: .whitespaces)\n            let value = String(raw[raw.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n            // If value starts with a quote, rewrite as bare boolean when it matches true/false\n            if value.hasPrefix(\"\\\"\") && value.hasSuffix(\"\\\"\") {\n                let unq = unquote(value).lowercased()\n                if unq == \"true\" || unq == \"false\" {\n                    lines[i] = \"\\(prefix) = \\(unq)\"\n                    changed = true\n                }\n            }\n            break\n        }\n        return (lines.joined(separator: \"\\n\"), changed)\n    }\n\n    private func parseTableBody(table: String, from text: String) -> [String] {\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var start: Int?; var end: Int?\n        for (idx, raw) in lines.enumerated() {\n            if raw.trimmingCharacters(in: .whitespaces) == table { start = idx + 1; continue }\n            if let _ = start, raw.trimmingCharacters(in: .whitespaces).hasPrefix(\"[\") { end = idx; break }\n        }\n        guard let s = start else { return [] }\n        let e = end ?? lines.count\n        return Array(lines[s..<e])\n    }\n\n    private func parseTableKeyValue(table: String, key: String, from text: String) -> String? {\n        let body = parseTableBody(table: table, from: text)\n        for raw in body {\n            let t = raw.trimmingCharacters(in: .whitespaces)\n            guard !t.hasPrefix(\"#\"), let eq = t.firstIndex(of: \"=\") else { continue }\n            let k = t[..<eq].trimmingCharacters(in: .whitespaces)\n            if k == key { return String(t[t.index(after: eq)...]).trimmingCharacters(in: .whitespaces) }\n        }\n        return nil\n    }\n\n    private func upsertTableKeyValue(table: String, key: String, valueText: String, in text: String) -> String {\n        var lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        var start: Int?; var end: Int?\n        for (idx, raw) in lines.enumerated() {\n            let t = raw.trimmingCharacters(in: .whitespaces)\n            if t == table { start = idx; continue }\n            if let _ = start, t.hasPrefix(\"[\") { end = idx; break }\n        }\n        if let s = start {\n            let e = end ?? lines.count\n            var replaced = false\n            if e > s {\n                for i in (s+1)..<e {\n                    let t = lines[i].trimmingCharacters(in: .whitespaces)\n                    guard !t.hasPrefix(\"#\"), let eq = t.firstIndex(of: \"=\") else { continue }\n                    let k = t[..<eq].trimmingCharacters(in: .whitespaces)\n                    if k == key {\n                        lines[i] = \"\\(key) = \\(valueText)\"\n                        replaced = true\n                        break\n                    }\n                }\n            }\n            if !replaced { lines.insert(\"\\(key) = \\(valueText)\", at: e) }\n            return lines.joined(separator: \"\\n\")\n        } else {\n            let block = [table, \"# managed-by=codmate\", \"\\(key) = \\(valueText)\", \"\"]\n            if !lines.isEmpty { lines.append(\"\") }\n            lines.append(contentsOf: block)\n            return lines.joined(separator: \"\\n\")\n        }\n    }\n\n    private func parseArrayLiteral(_ value: String) -> [String]? {\n        var s = value.trimmingCharacters(in: .whitespaces)\n        guard s.hasPrefix(\"[\") && s.hasSuffix(\"]\") else { return nil }\n        s.removeFirst(); s.removeLast()\n        if s.trimmingCharacters(in: .whitespaces).isEmpty { return [] }\n        let parts = s.split(separator: \",\").map { unquote(String($0)).trimmingCharacters(in: .whitespaces) }\n        return parts\n    }\n\n    private func renderArrayLiteral(_ arr: [String]) -> String {\n        let quoted = arr.map { \"\\\"\\($0)\\\"\" }.joined(separator: \", \")\n        return \"[\\(quoted)]\"\n    }\n\n    private func parseInlineTable(_ value: String) -> [String:String]? {\n        var s = value.trimmingCharacters(in: .whitespaces)\n        guard s.hasPrefix(\"{\") && s.hasSuffix(\"}\") else { return nil }\n        s.removeFirst(); s.removeLast()\n        if s.trimmingCharacters(in: .whitespaces).isEmpty { return [:] }\n        var dict: [String:String] = [:]\n        for part in s.split(separator: \",\") {\n            let seg = String(part)\n            guard let eq = seg.firstIndex(of: \"=\") else { continue }\n            let k = seg[..<eq].trimmingCharacters(in: .whitespaces).replacingOccurrences(of: \"\\\"\", with: \"\")\n            let v = seg[seg.index(after: eq)...].trimmingCharacters(in: .whitespaces).replacingOccurrences(of: \"\\\"\", with: \"\")\n            dict[k] = v\n        }\n        return dict\n    }\n\n    private func renderInlineTable(_ dict: [String:String]) -> String {\n        let parts = dict.map { key, val in \"\\\"\\(key)\\\" = \\\"\\(val)\\\"\" }.sorted().joined(separator: \", \")\n        return \"{ \\(parts) }\"\n    }\n\n    private func extractInlineEndpoint(from exporterLine: String) -> String? {\n        guard let r = exporterLine.range(of: \"endpoint\") else { return nil }\n        let sub = exporterLine[r.lowerBound...]\n        if let eq = sub.firstIndex(of: \"=\") {\n            let after = sub[sub.index(after: eq)...]\n            let trimmed = String(after).trimmingCharacters(in: .whitespaces)\n            if trimmed.hasPrefix(\"\\\"\") {\n                let s = trimmed\n                if let q2 = s.dropFirst().firstIndex(of: \"\\\"\") {\n                    let val = s[s.index(after: s.startIndex)..<q2]\n                    return String(val)\n                }\n            }\n        }\n        return nil\n    }\n}\n"
  },
  {
    "path": "services/CodexFeaturesService.swift",
    "content": "import Foundation\n\nstruct CodexFeatureInfo: Identifiable, Equatable {\n    let name: String\n    let stage: String\n    let enabled: Bool\n\n    var id: String { name }\n}\n\nactor CodexFeaturesService {\n    enum Error: Swift.Error, LocalizedError {\n        case cliFailed(stderr: String)\n        case parseFailed\n\n        var errorDescription: String? {\n            switch self {\n            case .cliFailed(let stderr):\n                let trimmed = stderr.trimmingCharacters(in: .whitespacesAndNewlines)\n                return trimmed.isEmpty ? \"Failed to invoke codex features list\" : trimmed\n            case .parseFailed:\n                return \"Unable to parse codex features output\"\n            }\n        }\n    }\n\n    func listFeatures() throws -> [CodexFeatureInfo] {\n        let env = [\n            \"PATH\": CLIEnvironment.buildBasePATH(),\n            \"NO_COLOR\": \"1\"\n        ]\n        do {\n            let result = try ShellCommandRunner.run(\n                executable: \"/usr/bin/env\",\n                arguments: [\"codex\", \"features\", \"list\"],\n                environment: env\n            )\n            return try Self.parseFeatures(from: result.stdout)\n        } catch let ShellCommandError.commandFailed(_, _, stderr, _) {\n            throw Error.cliFailed(stderr: stderr)\n        } catch {\n            throw error\n        }\n    }\n\n    private static func parseFeatures(from stdout: String) throws -> [CodexFeatureInfo] {\n        var features: [CodexFeatureInfo] = []\n        for rawLine in stdout.split(separator: \"\\n\") {\n            let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { continue }\n            let columns = trimmed.split(omittingEmptySubsequences: true) { ch in\n                ch == \"\\t\" || ch == \" \"\n            }\n            guard columns.count >= 3 else { continue }\n            guard let enabledToken = columns.last?.lowercased() else { continue }\n            let enabled: Bool\n            switch enabledToken {\n            case \"true\":\n                enabled = true\n            case \"false\":\n                enabled = false\n            default:\n                continue\n            }\n            let name = String(columns[0])\n            let stage = columns.dropFirst().dropLast().map(String.init).joined(separator: \" \")\n            features.append(CodexFeatureInfo(name: name, stage: stage, enabled: enabled))\n        }\n        if features.isEmpty { throw Error.parseFailed }\n        return features\n    }\n}\n"
  },
  {
    "path": "services/CodexOAuthUsageFetcher.swift",
    "content": "import Foundation\n\n// MARK: - Codex OAuth Credentials\n\n/// OAuth credentials from Codex auth.json file\nstruct CodexOAuthCredentials: Sendable {\n  let accessToken: String\n  let refreshToken: String\n  let idToken: String?\n  let accountId: String?\n  let lastRefresh: Date?\n\n  var needsRefresh: Bool {\n    guard let lastRefresh else { return true }\n    // Tokens typically last 14 days; refresh after 8 days to be safe\n    let eightDays: TimeInterval = 8 * 24 * 60 * 60\n    return Date().timeIntervalSince(lastRefresh) > eightDays\n  }\n}\n\nenum CodexOAuthCredentialsError: LocalizedError, Sendable {\n  case notFound\n  case decodeFailed(String)\n  case missingTokens\n\n  var errorDescription: String? {\n    switch self {\n    case .notFound:\n      return \"Codex auth.json not found. Run `codex` to log in.\"\n    case .decodeFailed(let message):\n      return \"Failed to decode Codex credentials: \\(message)\"\n    case .missingTokens:\n      return \"Codex auth.json exists but contains no tokens.\"\n    }\n  }\n}\n\n/// Storage for Codex OAuth credentials (reads/writes auth.json)\nenum CodexOAuthCredentialsStore {\n  private static var authFilePath: URL {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    if let codexHome = ProcessInfo.processInfo.environment[\"CODEX_HOME\"]?.trimmingCharacters(\n      in: .whitespacesAndNewlines),\n      !codexHome.isEmpty\n    {\n      return URL(fileURLWithPath: codexHome).appendingPathComponent(\"auth.json\")\n    }\n    return home.appendingPathComponent(\".codex/auth.json\")\n  }\n\n  static func load() throws -> CodexOAuthCredentials {\n    let url = authFilePath\n    guard FileManager.default.fileExists(atPath: url.path) else {\n      throw CodexOAuthCredentialsError.notFound\n    }\n\n    let data = try Data(contentsOf: url)\n    return try parse(data: data)\n  }\n\n  static func parse(data: Data) throws -> CodexOAuthCredentials {\n    guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      throw CodexOAuthCredentialsError.decodeFailed(\"Invalid JSON\")\n    }\n\n    // Check for API key auth (non-OAuth)\n    if let apiKey = json[\"OPENAI_API_KEY\"] as? String,\n       !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n    {\n      return CodexOAuthCredentials(\n        accessToken: apiKey,\n        refreshToken: \"\",\n        idToken: nil,\n        accountId: nil,\n        lastRefresh: nil)\n    }\n\n    // Look for OAuth tokens\n    guard let tokens = json[\"tokens\"] as? [String: Any] else {\n      throw CodexOAuthCredentialsError.missingTokens\n    }\n    guard let accessToken = tokens[\"access_token\"] as? String,\n          let refreshToken = tokens[\"refresh_token\"] as? String,\n          !accessToken.isEmpty\n    else {\n      throw CodexOAuthCredentialsError.missingTokens\n    }\n\n    let idToken = tokens[\"id_token\"] as? String\n    let accountId = tokens[\"account_id\"] as? String\n    let lastRefresh = parseLastRefresh(from: json[\"last_refresh\"])\n\n    return CodexOAuthCredentials(\n      accessToken: accessToken,\n      refreshToken: refreshToken,\n      idToken: idToken,\n      accountId: accountId,\n      lastRefresh: lastRefresh)\n  }\n\n  static func save(_ credentials: CodexOAuthCredentials) throws {\n    let url = authFilePath\n\n    var json: [String: Any] = [:]\n    if let data = try? Data(contentsOf: url),\n       let existing = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n    {\n      json = existing\n    }\n\n    var tokens: [String: Any] = [\n      \"access_token\": credentials.accessToken,\n      \"refresh_token\": credentials.refreshToken,\n    ]\n    if let idToken = credentials.idToken {\n      tokens[\"id_token\"] = idToken\n    }\n    if let accountId = credentials.accountId {\n      tokens[\"account_id\"] = accountId\n    }\n\n    json[\"tokens\"] = tokens\n    json[\"last_refresh\"] = ISO8601DateFormatter().string(from: Date())\n\n    let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys])\n    let directory = url.deletingLastPathComponent()\n    try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)\n    try data.write(to: url, options: .atomic)\n  }\n\n  private static func parseLastRefresh(from raw: Any?) -> Date? {\n    guard let value = raw as? String, !value.isEmpty else { return nil }\n    let formatter = ISO8601DateFormatter()\n    formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    if let date = formatter.date(from: value) { return date }\n    formatter.formatOptions = [.withInternetDateTime]\n    return formatter.date(from: value)\n  }\n}\n\n// MARK: - Codex OAuth Usage Fetcher\n\n/// Fetches Codex usage data directly from ChatGPT OAuth API\n/// This is more reliable than the codex app-server JSON-RPC approach\nenum CodexOAuthUsageFetcher {\n  private static let defaultChatGPTBaseURL = \"https://chatgpt.com/backend-api/\"\n  private static let chatGPTUsagePath = \"/wham/usage\"\n  private static let codexUsagePath = \"/api/codex/usage\"\n\n  enum FetchError: LocalizedError, Sendable {\n    case unauthorized\n    case invalidResponse\n    case serverError(Int, String?)\n    case networkError(Error)\n\n    var errorDescription: String? {\n      switch self {\n      case .unauthorized:\n        return \"Codex OAuth token expired or invalid. Run `codex` to re-authenticate.\"\n      case .invalidResponse:\n        return \"Invalid response from Codex usage API.\"\n      case .serverError(let code, let message):\n        if let message, !message.isEmpty {\n          return \"Codex API error \\(code): \\(message)\"\n        }\n        return \"Codex API error \\(code).\"\n      case .networkError(let error):\n        return \"Network error: \\(error.localizedDescription)\"\n      }\n    }\n  }\n\n  struct UsageResponse: Decodable, Sendable {\n    let planType: PlanType?\n    let rateLimit: RateLimitDetails?\n    let credits: CreditDetails?\n\n    enum CodingKeys: String, CodingKey {\n      case planType = \"plan_type\"\n      case rateLimit = \"rate_limit\"\n      case credits\n    }\n\n    enum PlanType: Sendable, Decodable, Equatable {\n      case guest\n      case free\n      case go\n      case plus\n      case pro\n      case freeWorkspace\n      case team\n      case business\n      case education\n      case quorum\n      case k12\n      case enterprise\n      case edu\n      case unknown(String)\n\n      var rawValue: String {\n        switch self {\n        case .guest: \"guest\"\n        case .free: \"free\"\n        case .go: \"go\"\n        case .plus: \"plus\"\n        case .pro: \"pro\"\n        case .freeWorkspace: \"free_workspace\"\n        case .team: \"team\"\n        case .business: \"business\"\n        case .education: \"education\"\n        case .quorum: \"quorum\"\n        case .k12: \"k12\"\n        case .enterprise: \"enterprise\"\n        case .edu: \"edu\"\n        case let .unknown(value): value\n        }\n      }\n\n      init(from decoder: Decoder) throws {\n        let container = try decoder.singleValueContainer()\n        let value = try container.decode(String.self)\n        switch value {\n        case \"guest\": self = .guest\n        case \"free\": self = .free\n        case \"go\": self = .go\n        case \"plus\": self = .plus\n        case \"pro\": self = .pro\n        case \"free_workspace\": self = .freeWorkspace\n        case \"team\": self = .team\n        case \"business\": self = .business\n        case \"education\": self = .education\n        case \"quorum\": self = .quorum\n        case \"k12\": self = .k12\n        case \"enterprise\": self = .enterprise\n        case \"edu\": self = .edu\n        default:\n          self = .unknown(value)\n        }\n      }\n    }\n\n    struct RateLimitDetails: Decodable, Sendable {\n      let primaryWindow: WindowSnapshot?\n      let secondaryWindow: WindowSnapshot?\n\n      enum CodingKeys: String, CodingKey {\n        case primaryWindow = \"primary_window\"\n        case secondaryWindow = \"secondary_window\"\n      }\n    }\n\n    struct WindowSnapshot: Decodable, Sendable {\n      let usedPercent: Int\n      let resetAt: Int\n      let limitWindowSeconds: Int\n\n      enum CodingKeys: String, CodingKey {\n        case usedPercent = \"used_percent\"\n        case resetAt = \"reset_at\"\n        case limitWindowSeconds = \"limit_window_seconds\"\n      }\n    }\n\n    struct CreditDetails: Decodable, Sendable {\n      let hasCredits: Bool\n      let unlimited: Bool\n      let balance: Double?\n\n      enum CodingKeys: String, CodingKey {\n        case hasCredits = \"has_credits\"\n        case unlimited\n        case balance\n      }\n\n      init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        hasCredits = (try? container.decode(Bool.self, forKey: .hasCredits)) ?? false\n        unlimited = (try? container.decode(Bool.self, forKey: .unlimited)) ?? false\n        if let balance = try? container.decode(Double.self, forKey: .balance) {\n          self.balance = balance\n        } else if let balance = try? container.decode(String.self, forKey: .balance),\n                  let value = Double(balance)\n        {\n          self.balance = value\n        } else {\n          balance = nil\n        }\n      }\n    }\n  }\n\n  static func fetchUsage(accessToken: String, accountId: String?) async throws -> UsageResponse {\n    var request = URLRequest(url: resolveUsageURL())\n    request.httpMethod = \"GET\"\n    request.timeoutInterval = 30\n    request.setValue(\"Bearer \\(accessToken)\", forHTTPHeaderField: \"Authorization\")\n    request.setValue(\"CodMate\", forHTTPHeaderField: \"User-Agent\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n\n    if let accountId, !accountId.isEmpty {\n      request.setValue(accountId, forHTTPHeaderField: \"ChatGPT-Account-Id\")\n    }\n\n    do {\n      let (data, response) = try await URLSession.shared.data(for: request)\n      guard let http = response as? HTTPURLResponse else {\n        throw FetchError.invalidResponse\n      }\n\n      switch http.statusCode {\n      case 200...299:\n        do {\n          return try JSONDecoder().decode(UsageResponse.self, from: data)\n        } catch {\n          throw FetchError.invalidResponse\n        }\n      case 401, 403:\n        throw FetchError.unauthorized\n      default:\n        let body = String(data: data, encoding: .utf8)\n        throw FetchError.serverError(http.statusCode, body)\n      }\n    } catch let error as FetchError {\n      throw error\n    } catch {\n      throw FetchError.networkError(error)\n    }\n  }\n\n  /// Fetch usage and return the plan type directly\n  static func fetchPlanType() async throws -> String? {\n    let credentials = try CodexOAuthCredentialsStore.load()\n    let response = try await fetchUsage(accessToken: credentials.accessToken, accountId: credentials.accountId)\n    let planTypeRaw = response.planType?.rawValue\n    return planTypeRaw\n  }\n\n  /// Check if OAuth credentials are available\n  static func hasCredentials() -> Bool {\n    (try? CodexOAuthCredentialsStore.load()) != nil\n  }\n\n  private static func resolveUsageURL() -> URL {\n    resolveUsageURL(env: ProcessInfo.processInfo.environment, configContents: nil)\n  }\n\n  private static func resolveUsageURL(env: [String: String], configContents: String?) -> URL {\n    let baseURL = resolveChatGPTBaseURL(env: env, configContents: configContents)\n    let normalized = normalizeChatGPTBaseURL(baseURL)\n    let path = normalized.contains(\"/backend-api\") ? chatGPTUsagePath : codexUsagePath\n    let full = normalized + path\n    return URL(string: full) ?? URL(string: defaultChatGPTBaseURL + chatGPTUsagePath)!\n  }\n\n  private static func resolveChatGPTBaseURL(env: [String: String], configContents: String?) -> String {\n    if let configContents, let parsed = parseChatGPTBaseURL(from: configContents) {\n      return parsed\n    }\n    if let contents = loadConfigContents(env: env),\n       let parsed = parseChatGPTBaseURL(from: contents)\n    {\n      return parsed\n    }\n    return defaultChatGPTBaseURL\n  }\n\n  private static func normalizeChatGPTBaseURL(_ value: String) -> String {\n    var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n    if trimmed.isEmpty { trimmed = defaultChatGPTBaseURL }\n    while trimmed.hasSuffix(\"/\") {\n      trimmed.removeLast()\n    }\n    if trimmed.hasPrefix(\"https://chatgpt.com\") || trimmed.hasPrefix(\"https://chat.openai.com\"),\n       !trimmed.contains(\"/backend-api\")\n    {\n      trimmed += \"/backend-api\"\n    }\n    return trimmed\n  }\n\n  private static func parseChatGPTBaseURL(from contents: String) -> String? {\n    for rawLine in contents.split(whereSeparator: \\.isNewline) {\n      let line = rawLine.split(separator: \"#\", maxSplits: 1, omittingEmptySubsequences: true).first\n      let trimmed = line?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n      guard !trimmed.isEmpty else { continue }\n      let parts = trimmed.split(separator: \"=\", maxSplits: 1, omittingEmptySubsequences: true)\n      guard parts.count == 2 else { continue }\n      let key = parts[0].trimmingCharacters(in: .whitespacesAndNewlines)\n      guard key == \"chatgpt_base_url\" else { continue }\n      var value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)\n      if value.hasPrefix(\"\\\"\"), value.hasSuffix(\"\\\"\") {\n        value = String(value.dropFirst().dropLast())\n      } else if value.hasPrefix(\"'\"), value.hasSuffix(\"'\") {\n        value = String(value.dropFirst().dropLast())\n      }\n      return value.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n    return nil\n  }\n\n  private static func loadConfigContents(env: [String: String]) -> String? {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let codexHome = env[\"CODEX_HOME\"]?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let root = (codexHome?.isEmpty == false) ? URL(fileURLWithPath: codexHome!) : home\n      .appendingPathComponent(\".codex\")\n    let url = root.appendingPathComponent(\"config.toml\")\n    return try? String(contentsOf: url, encoding: .utf8)\n  }\n}\n\n// MARK: - Plan type badge conversion\n\nextension CodexOAuthUsageFetcher.UsageResponse.PlanType {\n  /// Convert plan type to display badge\n  var displayBadge: String? {\n    switch self {\n    case .free, .guest:\n      return nil  // No badge for free users\n    case .go:\n      return \"Go\"\n    case .plus:\n      return \"Plus\"\n    case .pro:\n      return \"Pro\"\n    case .team:\n      return \"Team\"\n    case .business, .enterprise:\n      return \"Ent\"\n    case .freeWorkspace:\n      return nil\n    case .education, .edu, .k12, .quorum:\n      return \"Edu\"\n    case .unknown(let value):\n      // Show first letter capitalized for unknown types\n      return value.isEmpty ? nil : value.prefix(1).uppercased() + value.dropFirst()\n    }\n  }\n}\n\n// MARK: - JWT-based plan type extraction (more reliable than API)\n\nextension CodexOAuthUsageFetcher {\n  /// Fetch plan type from JWT token in auth.json (primary, most reliable)\n  /// This mirrors CodexBar's approach for consistency\n  static func fetchPlanTypeFromJWT() -> String? {\n    do {\n      let credentials = try CodexOAuthCredentialsStore.load()\n      guard let idToken = credentials.idToken, !idToken.isEmpty else {\n        return nil\n      }\n      guard let payload = parseJWT(idToken) else {\n        return nil\n      }\n\n      // Extract plan type from JWT payload (same fields as CodexBar)\n      let authDict = payload[\"https://api.openai.com/auth\"] as? [String: Any]\n      let planFromAuth = authDict?[\"chatgpt_plan_type\"] as? String\n      let planFromRoot = payload[\"chatgpt_plan_type\"] as? String\n      let plan = planFromAuth ?? planFromRoot\n      let trimmedPlan = plan?.trimmingCharacters(in: .whitespacesAndNewlines)\n      return trimmedPlan\n    } catch {\n      return nil\n    }\n  }\n\n  /// Parse JWT token to extract payload\n  private static func parseJWT(_ token: String) -> [String: Any]? {\n    let parts = token.split(separator: \".\")\n    guard parts.count >= 2 else { return nil }\n    let payloadPart = parts[1]\n\n    var padded = String(payloadPart)\n      .replacingOccurrences(of: \"-\", with: \"+\")\n      .replacingOccurrences(of: \"_\", with: \"/\")\n    while padded.count % 4 != 0 {\n      padded.append(\"=\")\n    }\n    guard let data = Data(base64Encoded: padded) else { return nil }\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      return nil\n    }\n    return json\n  }\n}\n"
  },
  {
    "path": "services/CommandsImportService.swift",
    "content": "import Foundation\n\nenum CommandsImportService {\n  struct SourceDescriptor {\n    let label: String\n    let directory: URL\n    let fileExtension: String\n    let format: CommandSourceFormat\n  }\n\n  enum CommandSourceFormat {\n    case markdown\n    case toml\n  }\n\n  static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default) -> [CommandImportCandidate] {\n    let home: URL\n    switch scope {\n    case .home:\n      home = SessionPreferencesStore.getRealUserHomeURL()\n    case .project:\n      return []\n    }\n    let sources: [SourceDescriptor] = [\n      SourceDescriptor(\n        label: \"Codex\",\n        directory: home.appendingPathComponent(\".codex\", isDirectory: true)\n          .appendingPathComponent(\"prompts\", isDirectory: true),\n        fileExtension: \"md\",\n        format: .markdown\n      ),\n      SourceDescriptor(\n        label: \"Claude\",\n        directory: home.appendingPathComponent(\".claude\", isDirectory: true)\n          .appendingPathComponent(\"commands\", isDirectory: true),\n        fileExtension: \"md\",\n        format: .markdown\n      ),\n      SourceDescriptor(\n        label: \"Gemini\",\n        directory: home.appendingPathComponent(\".gemini\", isDirectory: true)\n          .appendingPathComponent(\"commands\", isDirectory: true),\n        fileExtension: \"toml\",\n        format: .toml\n      ),\n    ]\n      .filter { source in\n        switch source.label {\n        case \"Codex\": return SessionPreferencesStore.isCLIEnabled(.codex)\n        case \"Claude\": return SessionPreferencesStore.isCLIEnabled(.claude)\n        case \"Gemini\": return SessionPreferencesStore.isCLIEnabled(.gemini)\n        default: return true\n        }\n      }\n\n    var merged: [String: CommandImportCandidate] = [:]\n\n    for source in sources {\n      guard fileManager.fileExists(atPath: source.directory.path) else { continue }\n      guard let entries = try? fileManager.contentsOfDirectory(\n        at: source.directory,\n        includingPropertiesForKeys: [.isRegularFileKey],\n        options: [.skipsHiddenFiles]\n      ) else { continue }\n\n      for entry in entries where entry.pathExtension.lowercased() == source.fileExtension {\n        let id = entry.deletingPathExtension().lastPathComponent\n        guard !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { continue }\n        let marker = source.directory.appendingPathComponent(\".\\(id).codmate\")\n        if fileManager.fileExists(atPath: marker.path) { continue }\n\n        guard let candidate = parseCommandCandidate(id: id, url: entry, source: source.label, format: source.format) else { continue }\n\n        if var existing = merged[id] {\n          if !existing.sources.contains(source.label) {\n            existing.sources.append(source.label)\n          }\n          existing.sourcePaths[source.label] = entry.path\n          merged[id] = existing\n        } else {\n          merged[id] = candidate\n        }\n      }\n    }\n\n    return merged.values.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending }\n  }\n\n  private static func parseCommandCandidate(\n    id: String,\n    url: URL,\n    source: String,\n    format: CommandSourceFormat\n  ) -> CommandImportCandidate? {\n    switch format {\n    case .markdown:\n      guard let record = CommandsStore.parseMarkdownFile(at: url, id: id, source: \"import\") else { return nil }\n      return CommandImportCandidate(\n        id: record.id,\n        name: record.name,\n        description: record.description,\n        prompt: record.prompt,\n        metadata: record.metadata,\n        sources: [source],\n        sourcePaths: [source: url.path],\n        isSelected: true,\n        hasConflict: false,\n        resolution: .overwrite,\n        renameId: record.id\n      )\n    case .toml:\n      guard let record = parseTOMLCommand(at: url, id: id) else { return nil }\n      return CommandImportCandidate(\n        id: record.id,\n        name: record.name,\n        description: record.description,\n        prompt: record.prompt,\n        metadata: record.metadata,\n        sources: [source],\n        sourcePaths: [source: url.path],\n        isSelected: true,\n        hasConflict: false,\n        resolution: .overwrite,\n        renameId: record.id\n      )\n    }\n  }\n\n  private static func parseTOMLCommand(at url: URL, id: String) -> CommandRecord? {\n    guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n\n    let parsedPrompt = extractTOMLBlock(named: \"prompt\", from: content)\n    let prompt = parsedPrompt?.trimmingCharacters(in: .whitespacesAndNewlines)\n      ?? content.trimmingCharacters(in: .whitespacesAndNewlines)\n\n    let description = extractTOMLString(named: \"description\", from: content) ?? \"\"\n\n    return CommandRecord(\n      id: id,\n      name: id,\n      description: description,\n      prompt: prompt,\n      metadata: CommandMetadata(),\n      targets: CommandTargets(codex: true, claude: true, gemini: true),\n      isEnabled: true,\n      source: \"import\",\n      path: \"\",\n      installedAt: Date()\n    )\n  }\n\n  private static func extractTOMLBlock(named key: String, from text: String) -> String? {\n    let pattern = \"^\\\\s*\\(key)\\\\s*=\\\\s*\\\"\\\"\\\"\"\n    guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {\n      return nil\n    }\n\n    let range = NSRange(text.startIndex..<text.endIndex, in: text)\n    guard let match = regex.firstMatch(in: text, options: [], range: range) else { return nil }\n\n    guard let startRange = Range(match.range, in: text) else { return nil }\n    let afterStart = text[startRange.upperBound...]\n\n    if let endRange = afterStart.range(of: \"\\\"\\\"\\\"\") {\n      return String(afterStart[..<endRange.lowerBound])\n    }\n    return nil\n  }\n\n  private static func extractTOMLString(named key: String, from text: String) -> String? {\n    let pattern = \"^\\\\s*\\(key)\\\\s*=\\\\s*\\\"(.*)\\\"\\\\s*$\"\n    guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {\n      return nil\n    }\n\n    let range = NSRange(text.startIndex..<text.endIndex, in: text)\n    guard let match = regex.firstMatch(in: text, options: [], range: range) else { return nil }\n    guard match.numberOfRanges > 1, let valueRange = Range(match.range(at: 1), in: text) else { return nil }\n    return String(text[valueRange])\n  }\n}\n"
  },
  {
    "path": "services/CommandsStore.swift",
    "content": "import Foundation\n\n/// Unified commands store for managing slash commands across AI CLI providers\n/// Follows the same pattern as SkillsStore - uses Markdown files with YAML frontmatter\nactor CommandsStore {\n  struct Paths {\n    let root: URL\n    let libraryDir: URL\n    let indexURL: URL\n\n    static func `default`(fileManager: FileManager = .default) -> Paths {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      let root = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        .appendingPathComponent(\"commands\", isDirectory: true)\n      return Paths(\n        root: root,\n        libraryDir: root.appendingPathComponent(\"library\", isDirectory: true),\n        indexURL: root.appendingPathComponent(\"index.json\", isDirectory: false)\n      )\n    }\n  }\n\n  private let paths: Paths\n  private let fm: FileManager\n\n  init(paths: Paths = .default(), fileManager: FileManager = .default) {\n    self.paths = paths\n    self.fm = fileManager\n  }\n\n  // MARK: - Load/Save\n  func list() -> [CommandRecord] {\n    load()\n  }\n\n  func record(id: String) -> CommandRecord? {\n    load().first(where: { $0.id == id })\n  }\n\n  func saveAll(_ records: [CommandRecord]) {\n    save(records)\n  }\n\n  func upsert(_ record: CommandRecord) {\n    var records = load()\n    let updatedRecord: CommandRecord\n\n    if let idx = records.firstIndex(where: { $0.id == record.id }) {\n      // Update existing: preserve path if not provided\n      let existingPath = records[idx].path\n      updatedRecord = CommandRecord(\n        id: record.id,\n        name: record.name,\n        description: record.description,\n        prompt: record.prompt,\n        metadata: record.metadata,\n        targets: record.targets,\n        isEnabled: record.isEnabled,\n        source: record.source,\n        path: record.path.isEmpty ? existingPath : record.path,\n        installedAt: record.installedAt\n      )\n      records[idx] = updatedRecord\n    } else {\n      // New command: create path if empty\n      let commandPath = record.path.isEmpty\n        ? paths.libraryDir.appendingPathComponent(\"\\(record.id).md\").path\n        : record.path\n      updatedRecord = CommandRecord(\n        id: record.id,\n        name: record.name,\n        description: record.description,\n        prompt: record.prompt,\n        metadata: record.metadata,\n        targets: record.targets,\n        isEnabled: record.isEnabled,\n        source: record.source,\n        path: commandPath,\n        installedAt: record.installedAt\n      )\n      records.append(updatedRecord)\n    }\n\n    // Write Markdown file\n    writeMarkdownFile(for: updatedRecord)\n    save(records)\n  }\n\n  func update(id: String, mutate: (inout CommandRecord) -> Void) {\n    var records = load()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return }\n    let baseRecord = loadCommandFromMarkdown(records[idx]) ?? records[idx]\n    var updatedRecord = baseRecord\n    mutate(&updatedRecord)\n\n    // Keep markdown in sync for target/metadata changes without clobbering prompt.\n    writeMarkdownFile(for: updatedRecord)\n\n    records[idx] = CommandRecord(\n      id: updatedRecord.id,\n      name: updatedRecord.id,\n      description: \"\",\n      prompt: \"\",\n      isEnabled: updatedRecord.isEnabled,\n      source: updatedRecord.source,\n      path: updatedRecord.path,\n      installedAt: updatedRecord.installedAt\n    )\n    save(records)\n  }\n\n  func delete(id: String) {\n    var records = load()\n    guard let record = records.first(where: { $0.id == id }) else { return }\n\n    // Delete Markdown file\n    let url = URL(fileURLWithPath: record.path)\n    try? fm.removeItem(at: url)\n\n    records.removeAll(where: { $0.id == id })\n    save(records)\n  }\n\n  // MARK: - Payload Commands\n  /// Load default commands from the bundled payload directory\n  private static func loadPayloadCommands(fm: FileManager = .default) -> [CommandRecord] {\n    let bundle = Bundle.main\n    var commands: [CommandRecord] = []\n\n    // Try loading index.json from bundle\n    var indexURL: URL?\n    if let url = bundle.url(forResource: \"commands/index\", withExtension: \"json\") {\n      indexURL = url\n    }\n    if indexURL == nil, let url = bundle.url(forResource: \"index\", withExtension: \"json\", subdirectory: \"payload/commands\") {\n      indexURL = url\n    }\n\n    guard let indexURL = indexURL,\n          let data = try? Data(contentsOf: indexURL) else {\n      return []\n    }\n\n    // Parse lightweight index\n    struct IndexEntry: Codable {\n      let id: String\n      let path: String\n      let source: String\n      let isEnabled: Bool\n      let installedAt: String\n    }\n\n    let decoder = JSONDecoder()\n    guard let indexEntries = try? decoder.decode([IndexEntry].self, from: data) else {\n      return []\n    }\n\n    let indexDir = indexURL.deletingLastPathComponent()\n\n    // Load each Markdown file\n    for entry in indexEntries {\n      let mdURL = indexDir.appendingPathComponent(entry.path)\n      guard let content = try? String(contentsOf: mdURL, encoding: .utf8) else { continue }\n      guard let record = parseMarkdownContent(content, id: entry.id, source: entry.source, isEnabled: entry.isEnabled, installedAt: entry.installedAt, path: mdURL.path) else { continue }\n      commands.append(record)\n    }\n\n    return commands\n  }\n\n  /// Parse Markdown content with YAML frontmatter\n  static func parseMarkdownContent(_ content: String, id: String, source: String, isEnabled: Bool, installedAt: String, path: String) -> CommandRecord? {\n    let lines = content.components(separatedBy: .newlines)\n    guard lines.first?.trimmingCharacters(in: .whitespaces) == \"---\" else { return nil }\n\n    var frontmatter: [String] = []\n    var promptLines: [String] = []\n    var inFrontmatter = false\n    var foundSecondDash = false\n\n    for (index, line) in lines.enumerated() {\n      if index == 0 {\n        inFrontmatter = true\n        continue\n      }\n      if line.trimmingCharacters(in: .whitespaces) == \"---\" && inFrontmatter {\n        foundSecondDash = true\n        inFrontmatter = false\n        continue\n      }\n      if inFrontmatter {\n        frontmatter.append(line)\n      } else if foundSecondDash {\n        promptLines.append(line)\n      }\n    }\n\n    // Parse YAML frontmatter\n    var name = id\n    var description = \"\"\n    var argumentHint: String?\n    var model: String?\n    var allowedTools: [String]?\n    var tags: [String] = []\n    var codex = true\n    var claude = true\n    var gemini = false\n\n    for line in frontmatter {\n      let trimmed = line.trimmingCharacters(in: .whitespaces)\n      if trimmed.hasPrefix(\"name:\") {\n        name = trimmed.replacingOccurrences(of: \"name:\", with: \"\").trimmingCharacters(in: .whitespaces)\n      } else if trimmed.hasPrefix(\"description:\") {\n        description = trimmed.replacingOccurrences(of: \"description:\", with: \"\").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n      } else if trimmed.hasPrefix(\"argument-hint:\") {\n        argumentHint = trimmed.replacingOccurrences(of: \"argument-hint:\", with: \"\").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n      } else if trimmed.hasPrefix(\"model:\") {\n        let value = trimmed.replacingOccurrences(of: \"model:\", with: \"\").trimmingCharacters(in: .whitespaces)\n        if value != \"null\" && !value.isEmpty {\n          model = value.trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n        }\n      } else if trimmed.hasPrefix(\"allowed-tools:\") {\n        // Simple array parsing\n        let arrayStr = trimmed.replacingOccurrences(of: \"allowed-tools:\", with: \"\").trimmingCharacters(in: .whitespaces)\n        if arrayStr.hasPrefix(\"[\") {\n          let cleaned = arrayStr.replacingOccurrences(of: \"[\", with: \"\").replacingOccurrences(of: \"]\", with: \"\")\n          allowedTools = cleaned.components(separatedBy: \",\").map {\n            $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n          }.filter { !$0.isEmpty }\n        }\n      } else if trimmed.hasPrefix(\"tags:\") {\n        // Simple array parsing\n        let arrayStr = trimmed.replacingOccurrences(of: \"tags:\", with: \"\").trimmingCharacters(in: .whitespaces)\n        if arrayStr.hasPrefix(\"[\") {\n          let cleaned = arrayStr.replacingOccurrences(of: \"[\", with: \"\").replacingOccurrences(of: \"]\", with: \"\")\n          tags = cleaned.components(separatedBy: \",\").map {\n            $0.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n          }.filter { !$0.isEmpty }\n        }\n      } else if trimmed == \"targets:\" {\n        // Next lines will be target values\n      } else if trimmed.hasPrefix(\"codex:\") {\n        codex = trimmed.replacingOccurrences(of: \"codex:\", with: \"\").trimmingCharacters(in: .whitespaces) == \"true\"\n      } else if trimmed.hasPrefix(\"claude:\") {\n        claude = trimmed.replacingOccurrences(of: \"claude:\", with: \"\").trimmingCharacters(in: .whitespaces) == \"true\"\n      } else if trimmed.hasPrefix(\"gemini:\") {\n        gemini = trimmed.replacingOccurrences(of: \"gemini:\", with: \"\").trimmingCharacters(in: .whitespaces) == \"true\"\n      } else if trimmed.hasPrefix(\"-\") && frontmatter.last?.contains(\"tags\") == true {\n        // Handle YAML array items\n        let item = trimmed.replacingOccurrences(of: \"-\", with: \"\").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n        if !item.isEmpty {\n          tags.append(item)\n        }\n      } else if trimmed.hasPrefix(\"-\") && frontmatter.last?.contains(\"allowed-tools\") == true {\n        // Handle YAML array items\n        let item = trimmed.replacingOccurrences(of: \"-\", with: \"\").trimmingCharacters(in: .whitespaces).trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"\"))\n        if !item.isEmpty {\n          if allowedTools == nil { allowedTools = [] }\n          allowedTools?.append(item)\n        }\n      }\n    }\n\n    let prompt = promptLines.joined(separator: \"\\n\").trimmingCharacters(in: .whitespacesAndNewlines)\n    let dateFormatter = ISO8601DateFormatter()\n    let date = dateFormatter.date(from: installedAt) ?? Date()\n\n    return CommandRecord(\n      id: id,\n      name: name,\n      description: description,\n      prompt: prompt,\n      metadata: CommandMetadata(\n        argumentHint: argumentHint,\n        model: model,\n        allowedTools: allowedTools,\n        tags: tags\n      ),\n      targets: CommandTargets(codex: codex, claude: claude, gemini: gemini),\n      isEnabled: isEnabled,\n      source: source,\n      path: path,\n      installedAt: date\n    )\n  }\n\n  static func parseMarkdownFile(at url: URL, id: String, source: String) -> CommandRecord? {\n    guard let content = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n    let dateFormatter = ISO8601DateFormatter()\n    let installedAt = dateFormatter.string(from: Date())\n    return parseMarkdownContent(\n      content,\n      id: id,\n      source: source,\n      isEnabled: true,\n      installedAt: installedAt,\n      path: url.path\n    )\n  }\n\n  /// List all commands, initializing from payload if needed\n  func listWithBuiltIns() -> [CommandRecord] {\n    // Initialize from payload on first run\n    initializeFromPayloadIfNeeded()\n\n    // Load from user directory\n    let userCommands = load()\n\n    // Load command content from Markdown files\n    var fullCommands: [CommandRecord] = []\n    for record in userCommands {\n      if let loaded = loadCommandFromMarkdown(record) {\n        fullCommands.append(loaded)\n      }\n    }\n\n    return fullCommands.sorted { $0.name < $1.name }\n  }\n\n  /// Initialize commands from payload (one-time only)\n  private func initializeFromPayloadIfNeeded() {\n    // Check if already initialized\n    if fm.fileExists(atPath: paths.indexURL.path) {\n      return\n    }\n\n    // Load payload commands\n    let payloadCommands = Self.loadPayloadCommands(fm: fm)\n    guard !payloadCommands.isEmpty else { return }\n\n    // Create library directory\n    try? fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true)\n\n    // Copy commands to user library with source: \"library\"\n    var userCommands: [CommandRecord] = []\n    for payloadCmd in payloadCommands {\n      let userPath = paths.libraryDir.appendingPathComponent(\"\\(payloadCmd.id).md\").path\n      let userCmd = CommandRecord(\n        id: payloadCmd.id,\n        name: payloadCmd.name,\n        description: payloadCmd.description,\n        prompt: payloadCmd.prompt,\n        metadata: payloadCmd.metadata,\n        targets: payloadCmd.targets,\n        isEnabled: payloadCmd.isEnabled,\n        source: \"library\",  // Mark as library, not payload\n        path: userPath,\n        installedAt: payloadCmd.installedAt\n      )\n\n      // Write Markdown file\n      writeMarkdownFile(for: userCmd)\n      userCommands.append(userCmd)\n    }\n\n    // Save index\n    save(userCommands)\n  }\n\n  // MARK: - Private Helpers\n\n  /// Load command details from Markdown file\n  private func loadCommandFromMarkdown(_ indexRecord: CommandRecord) -> CommandRecord? {\n    let path = indexRecord.path\n    guard !path.isEmpty else { return indexRecord }\n    let url = URL(fileURLWithPath: path)\n    guard let content = try? String(contentsOf: url, encoding: .utf8) else { return indexRecord }\n\n    let dateFormatter = ISO8601DateFormatter()\n    let installedAtStr = dateFormatter.string(from: indexRecord.installedAt)\n\n    return Self.parseMarkdownContent(content, id: indexRecord.id, source: indexRecord.source, isEnabled: indexRecord.isEnabled, installedAt: installedAtStr, path: path)\n  }\n\n  /// Write command to Markdown file\n  private func writeMarkdownFile(for record: CommandRecord) {\n    let url = URL(fileURLWithPath: record.path)\n    try? fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)\n\n    var content = \"---\\n\"\n    content += \"id: \\(record.id)\\n\"\n    content += \"name: \\(record.name)\\n\"\n    content += \"description: \\\"\\(record.description)\\\"\\n\"\n\n    if let hint = record.metadata.argumentHint {\n      content += \"argument-hint: \\\"\\(hint)\\\"\\n\"\n    }\n    if let model = record.metadata.model {\n      content += \"model: \\\"\\(model)\\\"\\n\"\n    }\n    if let tools = record.metadata.allowedTools, !tools.isEmpty {\n      content += \"allowed-tools: [\\\"\\(tools.joined(separator: \"\\\", \\\"\"))\\\"]\\n\"\n    }\n    if !record.metadata.tags.isEmpty {\n      content += \"tags: [\\\"\\(record.metadata.tags.joined(separator: \"\\\", \\\"\"))\\\"]\\n\"\n    }\n\n    content += \"targets:\\n\"\n    content += \"  codex: \\(record.targets.codex)\\n\"\n    content += \"  claude: \\(record.targets.claude)\\n\"\n    content += \"  gemini: \\(record.targets.gemini)\\n\"\n    content += \"---\\n\\n\"\n    content += record.prompt\n\n    try? content.write(to: url, atomically: true, encoding: .utf8)\n  }\n\n  /// Load index (lightweight metadata only)\n  private func load() -> [CommandRecord] {\n    guard fm.fileExists(atPath: paths.indexURL.path) else { return [] }\n    guard let data = try? Data(contentsOf: paths.indexURL) else { return [] }\n\n    struct IndexEntry: Codable {\n      let id: String\n      let path: String\n      let source: String\n      let isEnabled: Bool\n      let installedAt: Date\n    }\n\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    guard let entries = try? decoder.decode([IndexEntry].self, from: data) else { return [] }\n\n    return entries.map { entry in\n      CommandRecord(\n        id: entry.id,\n        name: entry.id,\n        description: \"\",\n        prompt: \"\",\n        isEnabled: entry.isEnabled,\n        source: entry.source,\n        path: entry.path,\n        installedAt: entry.installedAt\n      )\n    }\n  }\n\n  /// Save index (lightweight metadata only)\n  private func save(_ records: [CommandRecord]) {\n    struct IndexEntry: Codable {\n      let id: String\n      let path: String\n      let source: String\n      let isEnabled: Bool\n      let installedAt: Date\n    }\n\n    let entries = records.map { record in\n      IndexEntry(\n        id: record.id,\n        path: record.path,\n        source: record.source,\n        isEnabled: record.isEnabled,\n        installedAt: record.installedAt\n      )\n    }\n\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes, .sortedKeys]\n    encoder.dateEncodingStrategy = .iso8601\n    guard let data = try? encoder.encode(entries) else { return }\n    try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true)\n    try? data.write(to: paths.indexURL, options: .atomic)\n  }\n}\n"
  },
  {
    "path": "services/CommandsSyncService.swift",
    "content": "import Foundation\n\n/// Service for syncing commands from the unified store to provider-specific formats\n/// Follows the same pattern as SkillsSyncService and MCPServersStore export functions\nactor CommandsSyncService {\n  private let fm: FileManager\n\n  init(fileManager: FileManager = .default) {\n    self.fm = fileManager\n  }\n\n  // MARK: - Sync to All Providers\n  func syncGlobal(commands: [CommandRecord]) -> [CommandSyncWarning] {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let codexDir = home.appendingPathComponent(\".codex\", isDirectory: true)\n      .appendingPathComponent(\"prompts\", isDirectory: true)\n    let claudeDir = home.appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"commands\", isDirectory: true)\n    let geminiDir = home.appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"commands\", isDirectory: true)\n\n    let targets: [(CommandTarget, URL)] = [\n      (.codex, codexDir),\n      (.claude, claudeDir),\n      (.gemini, geminiDir)\n    ]\n    var warnings: [CommandSyncWarning] = []\n    for (target, destination) in targets\n    where SessionPreferencesStore.isCLIEnabled(target.baseKind) {\n      warnings.append(contentsOf: syncCommands(commands: commands, target: target, destination: destination))\n    }\n    return warnings\n  }\n\n  // MARK: - Private Sync Logic\n  private func syncCommands(\n    commands: [CommandRecord],\n    target: CommandTarget,\n    destination: URL\n  ) -> [CommandSyncWarning] {\n    let selected = commands.filter { $0.isEnabled && $0.targets.isEnabled(for: target) }\n\n    if selected.isEmpty {\n      removeManagedCommands(at: destination)\n      return []\n    }\n\n    try? fm.createDirectory(at: destination, withIntermediateDirectories: true)\n\n    var warnings: [CommandSyncWarning] = []\n    for command in selected {\n      do {\n        try writeCommand(command, to: destination, target: target)\n      } catch {\n        warnings.append(CommandSyncWarning(\n          message: \"\\(command.id) could not sync to \\(destination.path): \\(error.localizedDescription)\"\n        ))\n      }\n    }\n\n    removeManagedCommands(at: destination, keeping: Set(selected.map { $0.id }))\n    return warnings\n  }\n\n  // MARK: - Format Writers\n  private func writeCommand(_ command: CommandRecord, to directory: URL, target: CommandTarget) throws {\n    let fileURL: URL\n    let content: String\n\n    switch target {\n    case .codex, .claude:\n      // Both use Markdown + YAML frontmatter\n      fileURL = directory.appendingPathComponent(\"\\(command.id).md\", isDirectory: false)\n      content = generateMarkdownFormat(command, for: target)\n\n    case .gemini:\n      // Gemini uses TOML\n      fileURL = directory.appendingPathComponent(\"\\(command.id).toml\", isDirectory: false)\n      content = generateTOMLFormat(command)\n    }\n\n    // Write content\n    try content.write(to: fileURL, atomically: true, encoding: .utf8)\n\n    // Write marker file for CodMate management\n    try writeMarker(to: directory, id: command.id, target: target)\n  }\n\n  // MARK: - Markdown Format (Claude Code & Codex CLI)\n  private func generateMarkdownFormat(_ command: CommandRecord, for target: CommandTarget) -> String {\n    var frontmatter: [String] = []\n\n    // Description (required)\n    frontmatter.append(\"description: \\\"\\(escapeYAML(command.description))\\\"\")\n\n    // Argument hint (optional)\n    if let hint = command.metadata.argumentHint, !hint.isEmpty {\n      frontmatter.append(\"argument-hint: \\(hint)\")\n    }\n\n    // Model (Claude Code only)\n    if target == .claude, let model = command.metadata.model, !model.isEmpty {\n      frontmatter.append(\"model: \\(model)\")\n    }\n\n    // Allowed tools (Claude Code only)\n    if target == .claude, let tools = command.metadata.allowedTools, !tools.isEmpty {\n      frontmatter.append(\"allowed-tools: \\(tools.joined(separator: \", \"))\")\n    }\n\n    // Build final markdown\n    var lines: [String] = [\"---\"]\n    lines.append(contentsOf: frontmatter)\n    lines.append(\"---\")\n    lines.append(\"\")\n    lines.append(command.prompt)\n    lines.append(\"\")\n\n    return lines.joined(separator: \"\\n\")\n  }\n\n  // MARK: - TOML Format (Gemini CLI)\n  private func generateTOMLFormat(_ command: CommandRecord) -> String {\n    var lines: [String] = []\n\n    // Multi-line prompt\n    lines.append(\"prompt = \\\"\\\"\\\"\")\n    lines.append(command.prompt)\n    lines.append(\"\\\"\\\"\\\"\")\n\n    // Description\n    lines.append(\"description = \\\"\\(escapeTOML(command.description))\\\"\")\n\n    return lines.joined(separator: \"\\n\") + \"\\n\"\n  }\n\n  // MARK: - Marker Management\n  private func writeMarker(to directory: URL, id: String, target: CommandTarget) throws {\n    let markerFile = directory.appendingPathComponent(\".\\(id).codmate\", isDirectory: false)\n\n    let marker: [String: Any] = [\n      \"managedByCodMate\": true,\n      \"id\": id,\n      \"syncedAt\": ISO8601DateFormatter().string(from: Date())\n    ]\n\n    let data = try JSONSerialization.data(withJSONObject: marker, options: [.prettyPrinted])\n    try data.write(to: markerFile, options: .atomic)\n  }\n\n  private func removeManagedCommands(at directory: URL, keeping ids: Set<String> = []) {\n    guard fm.fileExists(atPath: directory.path) else { return }\n    guard let entries = try? fm.contentsOfDirectory(\n      at: directory,\n      includingPropertiesForKeys: [.isRegularFileKey],\n      options: [.skipsHiddenFiles]\n    ) else { return }\n\n    for entry in entries {\n      let basename = entry.deletingPathExtension().lastPathComponent\n      guard !ids.contains(basename) else { continue }\n\n      // Check if there's a marker file\n      let markerFile = directory.appendingPathComponent(\".\\(basename).codmate\", isDirectory: false)\n      if fm.fileExists(atPath: markerFile.path) {\n        // Remove both the command file and marker\n        try? fm.removeItem(at: entry)\n        try? fm.removeItem(at: markerFile)\n      }\n    }\n  }\n\n  // MARK: - Utility Functions\n  private func escapeYAML(_ string: String) -> String {\n    string\n      .replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\")\n      .replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n      .replacingOccurrences(of: \"\\n\", with: \"\\\\n\")\n  }\n\n  private func escapeTOML(_ string: String) -> String {\n    string\n      .replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\")\n      .replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n  }\n}\n\n// MARK: - Warning\nstruct CommandSyncWarning {\n  var message: String\n}\n"
  },
  {
    "path": "services/ContextTreeshaker.swift",
    "content": "import Foundation\n\nstruct TreeshakeOptions: Sendable, Equatable {\n    var includeReasoning: Bool = false\n    var includeToolSummary: Bool = false\n    var mergeConsecutiveAssistant: Bool = true\n    var maxMessageBytes: Int = 2 * 1024 // 2KB default (faster preview)\n    // Optional override for visible message kinds; when nil, caller can inject app-wide defaults.\n    var visibleKinds: Set<MessageVisibilityKind>? = nil\n}\n\nactor ContextTreeshaker {\n    private let loader = SessionTimelineLoader()\n    private let geminiParser = GeminiSessionParser()\n    // Simple LRU cache for per-session slim markdown\n    private struct Entry { let version: Date?; let optSig: String; let text: String }\n    private var cache: [String: Entry] = [:]  // session.id -> entry\n    private var lru: [String] = []\n    private let capacity = 32\n\n    private func optSignature(_ o: TreeshakeOptions) -> String {\n        let kindsSig: String = {\n            if let kinds = o.visibleKinds {\n                let items = kinds.map { $0.rawValue }.sorted().joined(separator: \",\")\n                return \"vk:[\\(items)]\"\n            } else { return \"vk:-\" }\n        }()\n        return \"r:\\(o.includeReasoning ? 1 : 0);t:\\(o.includeToolSummary ? 1 : 0);m:\\(o.mergeConsecutiveAssistant ? 1 : 0);b:\\(o.maxMessageBytes);\\(kindsSig)\"\n    }\n\n    private func fileVersion(for s: SessionSummary) -> Date? {\n        if let t = s.lastUpdatedAt { return t }\n        let attrs = (try? FileManager.default.attributesOfItem(atPath: s.fileURL.path)) ?? [:]\n        return attrs[.modificationDate] as? Date\n    }\n\n    private func lruTouch(_ id: String) {\n        if let idx = lru.firstIndex(of: id) { lru.remove(at: idx) }\n        lru.insert(id, at: 0)\n        if lru.count > capacity, let evict = lru.popLast() { cache.removeValue(forKey: evict) }\n    }\n\n    private func slim(for s: SessionSummary, options: TreeshakeOptions) -> String {\n        let ver = fileVersion(for: s)\n        let sig = optSignature(options)\n        if let e = cache[s.id], e.version == ver, e.optSig == sig { lruTouch(s.id); return e.text }\n\n        // Build slim markdown for a single session (no header)\n        let turns: [ConversationTurn]\n        if let loaded = loadTurns(for: s) {\n            if let kinds = options.visibleKinds { turns = loaded.filtering(visibleKinds: kinds) } else { turns = loaded }\n        } else { turns = [] }\n\n        var out: [String] = []\n        var prevWasAssistant = false\n        let allowReasoning = options.includeReasoning && (options.visibleKinds?.contains(.reasoning) ?? true)\n        let allowInfoSummary = options.includeToolSummary && (options.visibleKinds?.contains(.infoOther) ?? true)\n        for turn in turns {\n            if Task.isCancelled { break }\n            if let user = turn.userMessage, let text = user.text, !text.isEmpty {\n                out.append(\"**User** · \\(user.timestamp)\")\n                out.append(trim(text, limit: options.maxMessageBytes))\n                out.append(\"\")\n                prevWasAssistant = false\n            }\n            // Optional: Reasoning block (if available and allowed)\n            if allowReasoning {\n                if let r = turn.outputs.last(where: { isReasoning($0) })?.text, !r.isEmpty {\n                    out.append(\"**Reasoning** · \\(turn.timestamp)\")\n                    out.append(trim(r, limit: options.maxMessageBytes))\n                    out.append(\"\")\n                    prevWasAssistant = false\n                }\n            }\n            var assistantText: String? = nil\n            for event in turn.outputs.reversed() {\n                if event.actor == .assistant, let t = event.text, !t.isEmpty { assistantText = t; break }\n            }\n            if let a = assistantText {\n                let body = trim(a, limit: options.maxMessageBytes)\n                if options.mergeConsecutiveAssistant && prevWasAssistant {\n                    if let last = out.last, !last.isEmpty { out[out.count - 1] = last + \"\\n\\n\" + body } else { out.append(body) }\n                } else {\n                    out.append(\"**Assistant** · \\(turn.timestamp)\")\n                    out.append(body)\n                }\n                out.append(\"\")\n                prevWasAssistant = true\n            }\n            // Optional: Info/Tool summary (best-effort from remaining info events)\n            if allowInfoSummary {\n                if let info = turn.outputs.last(where: { isInfoSummary($0) })?.text, !info.isEmpty {\n                    out.append(\"**Info** · \\(turn.timestamp)\")\n                    out.append(trim(info, limit: options.maxMessageBytes))\n                    out.append(\"\")\n                    prevWasAssistant = false\n                }\n            }\n        }\n        let text = out.joined(separator: \"\\n\")\n        cache[s.id] = Entry(version: ver, optSig: sig, text: text)\n        lruTouch(s.id)\n        return text\n    }\n\n    private func loadTurns(for summary: SessionSummary) -> [ConversationTurn]? {\n        if summary.source.baseKind == .gemini {\n            return loadGeminiTurns(for: summary)\n        }\n        return try? loader.load(url: summary.fileURL)\n    }\n\n    private func loadGeminiTurns(for summary: SessionSummary) -> [ConversationTurn]? {\n        guard !summary.isRemote else { return nil }\n        let url = summary.fileURL\n        guard FileManager.default.fileExists(atPath: url.path) else { return nil }\n        guard let hash = geminiProjectHash(from: url) else { return nil }\n        let resolvedPath: String? = {\n            let trimmed = summary.cwd.trimmingCharacters(in: .whitespacesAndNewlines)\n            return trimmed.isEmpty ? nil : trimmed\n        }()\n        guard let parsed = geminiParser.parse(\n            at: url,\n            projectHash: hash,\n            resolvedProjectPath: resolvedPath\n        ) else { return nil }\n        return loader.turns(from: parsed.rows)\n    }\n\n    private func geminiProjectHash(from url: URL) -> String? {\n        let components = url.standardizedFileURL.pathComponents\n        for (index, component) in components.enumerated() where component == \"tmp\" {\n            let candidateIndex = index + 1\n            guard candidateIndex < components.count else { continue }\n            let candidate = components[candidateIndex]\n            if isValidGeminiHash(candidate) { return candidate }\n        }\n        return nil\n    }\n\n    private func isValidGeminiHash(_ value: String) -> Bool {\n        guard value.count == 64 else { return false }\n        let pattern = \"^[0-9a-f]{64}$\"\n        return value.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil\n    }\n\n    func generateMarkdown(for sessions: [SessionSummary], options: TreeshakeOptions = TreeshakeOptions()) -> String {\n        let sorted = sessions.sorted { ($0.startedAt) < ($1.startedAt) }\n        var out: [String] = []\n        let df = DateFormatter(); df.dateStyle = .medium; df.timeStyle = .short\n        let maxTotal = 64 * 1024  // tighter 64KB cap for preview\n        var total = 0\n\n        for s in sorted {\n            if Task.isCancelled { break }\n            let headerTitle = s.effectiveTitle\n            let timeText: String = {\n                let end = s.lastUpdatedAt ?? s.startedAt\n                return df.string(from: end)\n            }()\n            let header = \"# \\(headerTitle) · \\(timeText)\\n\\n\"\n            total += header.utf8.count\n            if total > maxTotal { out.append(\"… [truncated]\"); break }\n            out.append(header)\n\n            let body = slim(for: s, options: options)\n            total += body.utf8.count\n            if total > maxTotal {\n                // keep tail within limit\n                let remaining = max(0, maxTotal - (total - body.utf8.count))\n                let clipped = trim(body, limit: remaining)\n                out.append(clipped)\n                out.append(\"\\n… [truncated]\")\n                break\n            } else {\n                out.append(body)\n                out.append(\"\\n\")\n            }\n        }\n\n        return out.joined(separator: \"\")\n    }\n\n    private func trim(_ text: String, limit: Int) -> String {\n        // Keep within byte limit while respecting Unicode character boundaries\n        guard limit > 0 else { return text }\n        let totalBytes = text.utf8.count\n        guard totalBytes > limit else { return text }\n\n        // Keep head/tail samples to provide surrounding context\n        let headBytes = max(512, limit / 4)\n        let tailBytes = max(512, limit / 4)\n        let headStr = prefixByUTF8(text, maxBytes: headBytes)\n        let tailStr = suffixByUTF8(text, maxBytes: tailBytes)\n        return headStr + \"\\n\\n… [snip] …\\n\\n\" + tailStr\n    }\n\n    // Safe UTF-8 prefix cut at Character boundaries\n    private func prefixByUTF8(_ text: String, maxBytes: Int) -> String {\n        guard maxBytes > 0 else { return \"\" }\n        var used = 0\n        var endIndex = text.startIndex\n        for ch in text { // Character iteration respects extended grapheme clusters\n            let b = String(ch).utf8.count\n            if used + b > maxBytes { break }\n            used += b\n            endIndex = text.index(after: endIndex)\n        }\n        return String(text[..<endIndex])\n    }\n\n    // Safe UTF-8 suffix cut at Character boundaries\n    private func suffixByUTF8(_ text: String, maxBytes: Int) -> String {\n        guard maxBytes > 0 else { return \"\" }\n        var used = 0\n        var charCount = 0\n        for ch in text.reversed() { // reversed Characters\n            let b = String(ch).utf8.count\n            if used + b > maxBytes { break }\n            used += b\n            charCount += 1\n        }\n        if charCount == 0 { return \"\" }\n        // Take last `charCount` Characters\n        var start = text.endIndex\n        for _ in 0..<charCount { start = text.index(before: start) }\n        return String(text[start...])\n    }\n}\n\n// MARK: - Helpers\nprivate func isReasoning(_ e: TimelineEvent) -> Bool {\n    e.visibilityKind == .reasoning\n}\n\nprivate func isInfoSummary(_ e: TimelineEvent) -> Bool {\n    guard e.actor == .info else { return false }\n    switch e.visibilityKind {\n    case .environmentContext, .turnContext, .reasoning, .tokenUsage:\n        return false\n    default:\n        return true\n    }\n}\n"
  },
  {
    "path": "services/DirectoryMonitor.swift",
    "content": "import Foundation\nimport Darwin\n\nfinal class DirectoryMonitor {\n    private var fileDescriptor: CInt = -1\n    private var source: DispatchSourceFileSystemObject?\n    private let queue = DispatchQueue(label: \"io.codmate.directorymonitor\", qos: .utility)\n    private let handler: () -> Void\n\n    init?(url: URL, handler: @escaping () -> Void) {\n        self.handler = handler\n        guard let descriptor = DirectoryMonitor.openDescriptor(at: url) else { return nil }\n        fileDescriptor = descriptor\n        configureSource()\n    }\n\n    func updateURL(_ url: URL) {\n        cancel()\n        guard let descriptor = DirectoryMonitor.openDescriptor(at: url) else { return }\n        fileDescriptor = descriptor\n        configureSource()\n    }\n\n    func cancel() {\n        source?.cancel()\n        source = nil\n        if fileDescriptor != -1 {\n            close(fileDescriptor)\n            fileDescriptor = -1\n        }\n    }\n\n    deinit {\n        cancel()\n    }\n\n    private func configureSource() {\n        guard fileDescriptor != -1 else { return }\n        let newSource = DispatchSource.makeFileSystemObjectSource(\n            fileDescriptor: fileDescriptor,\n            eventMask: [.write, .rename, .delete, .extend],\n            queue: queue\n        )\n        newSource.setEventHandler { [weak self] in\n            self?.handler()\n        }\n        newSource.setCancelHandler { [weak self] in\n            if let fd = self?.fileDescriptor, fd != -1 {\n                close(fd)\n                self?.fileDescriptor = -1\n            }\n        }\n        source = newSource\n        newSource.resume()\n    }\n\n    private static func openDescriptor(at url: URL) -> CInt? {\n        let path = (url as NSURL).fileSystemRepresentation\n        let fd = open(path, O_EVTONLY)\n        guard fd != -1 else { return nil }\n        return fd\n    }\n}\n"
  },
  {
    "path": "services/DockOpenCoordinator.swift",
    "content": "import Foundation\n\n@MainActor\nfinal class DockOpenCoordinator {\n  static let shared = DockOpenCoordinator()\n\n  struct PendingNewProjectRequest: Sendable, Equatable {\n    let directory: String\n    let name: String?\n  }\n\n  private var pendingNewProject: PendingNewProjectRequest? = nil\n  private var isContentViewReady = false\n\n  /// Mark that ContentView has completed initialization and is ready to handle new project requests\n  func markContentViewReady() {\n    isContentViewReady = true\n    // If there's a pending request, notify now that view is ready\n    if let request = pendingNewProject {\n      NotificationCenter.default.post(\n        name: .codMateOpenNewProject,\n        object: nil,\n        userInfo: [\n          \"directory\": request.directory,\n          \"name\": request.name ?? \"\"\n        ]\n      )\n    }\n  }\n\n  func enqueueNewProject(directory: String, name: String?) {\n    let trimmedDir = directory.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmedDir.isEmpty else { return }\n    let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let request = PendingNewProjectRequest(\n      directory: trimmedDir,\n      name: (trimmedName?.isEmpty == false) ? trimmedName : nil\n    )\n    pendingNewProject = request\n\n    // Only send notification if ContentView is ready (runtime scenario)\n    // Otherwise queue it for onAppear consumption (first launch scenario)\n    if isContentViewReady {\n      NotificationCenter.default.post(\n        name: .codMateOpenNewProject,\n        object: nil,\n        userInfo: [\n          \"directory\": request.directory,\n          \"name\": request.name ?? \"\"\n        ]\n      )\n    }\n  }\n\n  func consumePendingNewProject() -> PendingNewProjectRequest? {\n    let request = pendingNewProject\n    pendingNewProject = nil\n    return request\n  }\n}\n"
  },
  {
    "path": "services/EmbeddedNotifySniffer.swift",
    "content": "import Foundation\n\n/// Lightweight heuristic to detect \"turn complete\" style events from terminal output.\n/// Only used for embedded terminal sessions; external Terminal/TTY paths still rely on `notify` bridge.\nstruct EmbeddedNotifySniffer {\n    /// Returns a short message if the provided line(s) suggest the agent just completed a turn.\n    static func sniff(line: String) -> String? {\n        let s = line.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !s.isEmpty else { return nil }\n        let lower = s.lowercased()\n        // Common variants seen across TUI implementations\n        let needles = [\n            \"agent turn complete\",\n            \"turn complete\",\n            \"agent completed\",\n            \"run complete\",\n            \"session complete\",\n        ]\n        for n in needles {\n            if lower.contains(n) { return \"Turn complete\" }\n        }\n        return nil\n    }\n\n    static func sniff(lines: [String]) -> String? {\n        for l in lines.reversed() { if let m = sniff(line: l) { return m } }\n        return nil\n    }\n}\n"
  },
  {
    "path": "services/ExternalTerminalProfileStore.swift",
    "content": "import Foundation\n\nstruct ExternalTerminalProfileStore {\n    static let shared = ExternalTerminalProfileStore()\n\n    struct Paths {\n        let home: URL\n        let fileURL: URL\n    }\n\n    private let fileManager: FileManager\n    private let paths: Paths\n\n    init(fileManager: FileManager = .default, paths: Paths? = nil) {\n        self.fileManager = fileManager\n        if let paths {\n            self.paths = paths\n        } else {\n            let home = SessionPreferencesStore.getRealUserHomeURL()\n            let dir = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            self.paths = Paths(home: dir, fileURL: dir.appendingPathComponent(\"terminals.json\"))\n        }\n    }\n\n    func seedUserFileIfNeeded() {\n        guard !fileManager.fileExists(atPath: paths.fileURL.path) else { return }\n        guard let bundled = loadBundledProfilesRawData() else { return }\n        do {\n            try fileManager.createDirectory(at: paths.home, withIntermediateDirectories: true)\n            try bundled.write(to: paths.fileURL, options: .atomic)\n        } catch {\n            // Best-effort only; ignore failures to avoid blocking launch.\n        }\n    }\n\n    func loadUserProfiles() -> [ExternalTerminalProfile] {\n        guard let data = try? Data(contentsOf: paths.fileURL) else { return [] }\n        if let decoded = decodeProfiles(from: data) { return decoded }\n        rebuildUserFileFromBundle()\n        guard let rebuilt = try? Data(contentsOf: paths.fileURL) else { return [] }\n        return decodeProfiles(from: rebuilt) ?? []\n    }\n\n    func loadBundledProfiles() -> [ExternalTerminalProfile] {\n        guard let data = loadBundledProfilesRawData() else { return [] }\n        return decodeProfiles(from: data) ?? []\n    }\n\n    func mergedProfiles() -> [ExternalTerminalProfile] {\n        seedUserFileIfNeeded()\n        let protected = Self.protectedIds\n        var merged = Self.builtInProfiles\n        var indexById: [String: Int] = [:]\n        for (idx, profile) in merged.enumerated() {\n            indexById[profile.id] = idx\n        }\n\n        let user = loadUserProfiles().filter { !protected.contains($0.id) }\n        for profile in user {\n            if let idx = indexById[profile.id] {\n                merged[idx] = profile\n            } else {\n                indexById[profile.id] = merged.count\n                merged.append(profile)\n            }\n        }\n        return merged\n    }\n\n    func availableProfiles(includeNone: Bool = true) -> [ExternalTerminalProfile] {\n        let profiles = mergedProfiles().filter { $0.isAvailable }\n        if includeNone { return profiles }\n        return profiles.filter { !$0.isNone }\n    }\n\n    func profile(for id: String) -> ExternalTerminalProfile? {\n        mergedProfiles().first { $0.id == id }\n    }\n\n    func resolvePreferredProfile(id: String?) -> ExternalTerminalProfile? {\n        let profiles = availableProfiles(includeNone: true)\n        if let id, let match = profiles.first(where: { $0.id == id }) {\n            return match\n        }\n        if let terminal = profiles.first(where: { $0.id == \"terminal\" }) { return terminal }\n        return profiles.first\n    }\n\n    func resolvePreferredId(id: String?) -> String {\n        resolvePreferredProfile(id: id)?.id ?? \"terminal\"\n    }\n\n    private func loadBundledProfilesRawData() -> Data? {\n        let bundle = Bundle.main\n        var urls: [URL] = []\n        if let u = bundle.url(forResource: \"terminals\", withExtension: \"json\") { urls.append(u) }\n        if let u = bundle.url(forResource: \"terminals\", withExtension: \"json\", subdirectory: \"payload\") { urls.append(u) }\n        for url in urls {\n            if let data = try? Data(contentsOf: url) { return data }\n        }\n        return nil\n    }\n\n    private struct TerminalsFile: Codable { let terminals: [ExternalTerminalProfile] }\n\n    private func decodeProfiles(from data: Data) -> [ExternalTerminalProfile]? {\n        let decoder = JSONDecoder()\n        if let profiles = try? decoder.decode([ExternalTerminalProfile].self, from: data) {\n            return profiles\n        }\n        if let file = try? decoder.decode(TerminalsFile.self, from: data) {\n            return file.terminals\n        }\n        return nil\n    }\n\n    private func rebuildUserFileFromBundle() {\n        guard let bundled = loadBundledProfilesRawData() else { return }\n        do {\n            try fileManager.createDirectory(at: paths.home, withIntermediateDirectories: true)\n            try bundled.write(to: paths.fileURL, options: .atomic)\n        } catch {\n            return\n        }\n        Self.notifyParseFailureOnce()\n    }\n\n    private static var didNotifyParseFailure = false\n\n    private static func notifyParseFailureOnce() {\n        guard !didNotifyParseFailure else { return }\n        didNotifyParseFailure = true\n        Task { await SystemNotifier.shared.notify(\n            title: \"CodMate\",\n            body: \"Terminals configuration failed to load. Rebuilt defaults.\"\n        ) }\n    }\n\n    private static let builtInProfiles: [ExternalTerminalProfile] = [\n        ExternalTerminalProfile(\n            id: \"none\",\n            title: \"None\",\n            bundleIdentifiers: nil,\n            urlTemplate: nil,\n            supportsCommand: false,\n            supportsDirectory: false,\n            managedByCodMate: true,\n            commandStyle: .standard\n        ),\n        ExternalTerminalProfile(\n            id: \"terminal\",\n            title: \"Terminal\",\n            bundleIdentifiers: [\"com.apple.Terminal\"],\n            urlTemplate: nil,\n            supportsCommand: false,\n            supportsDirectory: true,\n            managedByCodMate: true,\n            commandStyle: .standard\n        ),\n    ]\n\n    private static let protectedIds: Set<String> = [\"none\", \"terminal\"]\n}\n"
  },
  {
    "path": "services/ExternalURLRouter.swift",
    "content": "import Foundation\n\n/// Handles custom codmate:// URLs dispatched via NSWorkspace/open.\n@MainActor\nenum ExternalURLRouter {\n    static func handle(_ urls: [URL]) {\n        for url in urls {\n            handle(url)\n        }\n    }\n\n    static func handle(_ url: URL) {\n        print(\"🔗 [ExternalURLRouter] Handling URL: \\(url.absoluteString)\")\n        guard url.scheme?.lowercased() == \"codmate\" else {\n            print(\"⚠️ [ExternalURLRouter] Invalid scheme: \\(url.scheme ?? \"nil\")\")\n            return\n        }\n        switch (url.host ?? \"\").lowercased() {\n        case \"notify\":\n            print(\"📬 [ExternalURLRouter] Processing notification\")\n            handleNotify(url)\n        default:\n            print(\"⚠️ [ExternalURLRouter] Unknown host: \\(url.host ?? \"nil\")\")\n            break\n        }\n    }\n\n    private static func handleNotify(_ url: URL) {\n        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }\n        let items = components.queryItems ?? []\n        guard let source = NotificationSource(rawValue: (items.first(where: { $0.name == \"source\" })?.value ?? \"\").lowercased()) else { return }\n        let eventName = (items.first(where: { $0.name == \"event\" })?.value ?? \"\").lowercased()\n        let title = decodeQueryValue(items: items, preferred: [\"title\", \"title64\"])\n        let body = decodeQueryValue(items: items, preferred: [\"body\", \"body64\"])\n        let threadId = items.first(where: { $0.name == \"thread\" || $0.name == \"threadId\" })?.value\n        guard let descriptor = NotificationDescriptor.make(\n            source: source,\n            eventName: eventName,\n            providedTitle: title,\n            providedBody: body,\n            providedThreadId: threadId\n        ) else { return }\n        Task { @MainActor in\n            await SystemNotifier.shared.notify(\n                title: descriptor.title,\n                body: descriptor.body,\n                threadId: descriptor.threadId\n            )\n        }\n    }\n\n    private static func decodeQueryValue(items: [URLQueryItem], preferred keys: [String]) -> String? {\n        for key in keys {\n            if let value = items.first(where: { $0.name == key })?.value {\n                if key.hasSuffix(\"64\"), let decoded = decodeBase64(value) { return decoded }\n                if !key.hasSuffix(\"64\") { return value }\n            }\n        }\n        return nil\n    }\n\n    private static func decodeBase64(_ value: String) -> String? {\n        guard let data = Data(base64Encoded: value) else { return nil }\n        return String(data: data, encoding: .utf8)\n    }\n}\n\nprivate enum NotificationSource: String {\n    case claude\n    case codex\n    case gemini\n}\n\nprivate struct NotificationDescriptor {\n    let title: String\n    let body: String\n    let threadId: String?\n\n    static func make(\n        source: NotificationSource,\n        eventName: String,\n        providedTitle: String?,\n        providedBody: String?,\n        providedThreadId: String?\n    ) -> NotificationDescriptor? {\n        switch source {\n        case .claude:\n            return makeClaudeDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId)\n        case .codex:\n            return makeCodexDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId)\n        case .gemini:\n            return makeGeminiDescriptor(eventName: eventName, providedTitle: providedTitle, providedBody: providedBody, providedThreadId: providedThreadId)\n        }\n    }\n\n    private static func makeClaudeDescriptor(\n        eventName: String,\n        providedTitle: String?,\n        providedBody: String?,\n        providedThreadId: String?\n    ) -> NotificationDescriptor? {\n        guard let event = ClaudeEvent(rawValue: eventName) else { return nil }\n        let defaults = event.defaults\n        let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title\n        let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body\n        let thread = providedThreadId?.nonEmpty ?? defaults.threadId\n        return NotificationDescriptor(title: title, body: body, threadId: thread)\n    }\n\n    private static func makeCodexDescriptor(\n        eventName: String,\n        providedTitle: String?,\n        providedBody: String?,\n        providedThreadId: String?\n    ) -> NotificationDescriptor? {\n        guard let event = CodexEvent(rawValue: eventName) else { return nil }\n        let defaults = event.defaults\n        let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title\n        let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body\n        let thread = providedThreadId?.nonEmpty ?? defaults.threadId\n        return NotificationDescriptor(title: title, body: body, threadId: thread)\n    }\n\n    private static func makeGeminiDescriptor(\n        eventName: String,\n        providedTitle: String?,\n        providedBody: String?,\n        providedThreadId: String?\n    ) -> NotificationDescriptor? {\n        guard let event = GeminiEvent(rawValue: eventName) else { return nil }\n        let defaults = event.defaults\n        let title = providedTitle?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.title\n        let body = providedBody?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ?? defaults.body\n        let thread = providedThreadId?.nonEmpty ?? defaults.threadId\n        return NotificationDescriptor(title: title, body: body, threadId: thread)\n    }\n\n    private enum ClaudeEvent: String {\n        case permission\n        case complete\n        case test\n\n        var defaults: (title: String, body: String, threadId: String) {\n            switch self {\n            case .permission:\n                return (\"Claude\", \"Claude requires approval. Return to the Claude window to respond.\", \"claude-permission\")\n            case .complete:\n                return (\"Claude\", \"Claude finished its current task.\", \"claude-complete\")\n            case .test:\n                return (\"CodMate\", \"Claude notifications self-test\", \"claude-test\")\n            }\n        }\n    }\n\n    private enum CodexEvent: String {\n        case turncomplete\n        case test\n\n        var defaults: (title: String, body: String, threadId: String) {\n            switch self {\n            case .turncomplete:\n                return (\"Codex\", \"Codex turn complete.\", \"codex-thread\")\n            case .test:\n                return (\"CodMate\", \"Codex notifications self-test\", \"codex-test\")\n            }\n        }\n    }\n\n    private enum GeminiEvent: String {\n        case permission\n        case test\n\n        var defaults: (title: String, body: String, threadId: String) {\n            switch self {\n            case .permission:\n                return (\"Gemini\", \"Gemini requires approval. Return to the Gemini window to respond.\", \"gemini-permission\")\n            case .test:\n                return (\"CodMate\", \"Gemini notifications self-test\", \"gemini-test\")\n            }\n        }\n    }\n}\n\nprivate extension String {\n    var nonEmpty: String? { isEmpty ? nil : self }\n}\n"
  },
  {
    "path": "services/GeminiSessionParser.swift",
    "content": "import Foundation\n\nstruct GeminiTokenTotals {\n  let input: Int\n  let output: Int\n  let cached: Int\n  let thoughts: Int\n  let tool: Int\n\n  var hasValues: Bool {\n    return input != 0 || output != 0 || cached != 0 || thoughts != 0 || tool != 0\n  }\n}\n\nstruct GeminiParsedLog {\n  let summary: SessionSummary\n  let rows: [SessionRow]\n  let tokens: GeminiTokenTotals?\n}\n\nprivate let geminiAbsolutePathRegex = try! NSRegularExpression(\n  pattern: #\"((?:~|/)[^\\s\"']+)\"#, options: [])\nprivate let geminiPathTrimCharacters = CharacterSet(charactersIn: \",.;:)]}>\\\"'\")\n\nstruct GeminiSessionParser {\n  private struct ConversationRecord: Decodable {\n    struct Message: Decodable {\n      struct ToolCall: Decodable {\n        let id: String?\n        let name: String?\n        let args: JSONValue?\n        let result: JSONValue?\n        let description: String?\n        let displayName: String?\n        let resultDisplay: String?\n        let status: String?\n        let renderOutputAsMarkdown: Bool?\n      }\n\n      struct Thought: Decodable {\n        let subject: String?\n        let description: String?\n        let timestamp: String?\n      }\n\n      struct Tokens: Decodable {\n        let input: Int?\n        let output: Int?\n        let cached: Int?\n        let thoughts: Int?\n        let tool: Int?\n        let total: Int?\n      }\n\n      let id: String\n      let timestamp: String?\n      let type: String\n      let content: JSONValue?\n      let model: String?\n      let toolCalls: [ToolCall]?\n      let thoughts: [Thought]?\n      let tokens: Tokens?\n    }\n\n    let sessionId: String\n    let projectHash: String?\n    let startTime: String\n    let lastUpdated: String?\n    let messages: [Message]\n  }\n\n  private let decoder: JSONDecoder\n  private let isoFormatter: ISO8601DateFormatter\n  private let fallbackFormatter: ISO8601DateFormatter\n\n  init(decoder: JSONDecoder = JSONDecoder()) {\n    self.decoder = decoder\n    self.isoFormatter = ISO8601DateFormatter()\n    self.isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    self.fallbackFormatter = ISO8601DateFormatter()\n    self.fallbackFormatter.formatOptions = [.withInternetDateTime]\n  }\n\n  func parse(\n    at url: URL,\n    projectHash: String,\n    resolvedProjectPath: String?\n  ) -> GeminiParsedLog? {\n    guard\n      let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),\n      let record = try? decoder.decode(ConversationRecord.self, from: data),\n      let startedAt = parseDate(record.startTime)\n    else { return nil }\n\n    let hasUserOrAssistant = record.messages.contains {\n      let kind = $0.type.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n      return kind == \"user\" || kind == \"gemini\"\n    }\n    guard hasUserOrAssistant else { return nil }\n\n    let sessionFileId = url.deletingPathExtension().lastPathComponent\n    let resumeIdentifier = record.sessionId.trimmingCharacters(in: .whitespacesAndNewlines)\n    let sessionId = resumeIdentifier.isEmpty ? sessionFileId : resumeIdentifier\n    let inferredDirectory =\n      resolvedProjectPath\n      ?? inferWorkingDirectory(from: record.messages)\n      ?? defaultProjectPath(forHash: projectHash)\n    let cwd = inferredDirectory\n    var rows: [SessionRow] = []\n\n    // Aggregate per-session token usage from Gemini messages.\n    var totalInput = 0\n    var totalOutput = 0\n    var totalCached = 0\n    var totalThoughts = 0\n    var totalTool = 0\n\n    for message in record.messages where message.type.lowercased() == \"gemini\" {\n      guard let tokens = message.tokens else { continue }\n      if let value = tokens.input, value > 0 {\n        totalInput &+= value\n      }\n      if let value = tokens.output, value > 0 {\n        totalOutput &+= value\n      }\n      if let value = tokens.cached, value > 0 {\n        totalCached &+= value\n      }\n      if let value = tokens.thoughts, value > 0 {\n        totalThoughts &+= value\n      }\n      if let value = tokens.tool, value > 0 {\n        totalTool &+= value\n      }\n    }\n\n    let aggregatedTokens: GeminiTokenTotals? = {\n      let totals = GeminiTokenTotals(\n        input: totalInput,\n        output: totalOutput,\n        cached: totalCached,\n        thoughts: totalThoughts,\n        tool: totalTool\n      )\n      return totals.hasValues ? totals : nil\n    }()\n\n    let meta = SessionMetaPayload(\n      id: sessionId,\n      timestamp: startedAt,\n      cwd: cwd,\n      originator: \"Gemini CLI\",\n      cliVersion: \"Gemini CLI\",\n      instructions: nil\n    )\n    let metaRow = SessionRow(timestamp: startedAt, kind: .sessionMeta(meta))\n    rows.append(metaRow)\n\n    if let model = firstModel(in: record.messages) {\n      let ctx = TurnContextPayload(\n        cwd: cwd,\n        approvalPolicy: nil,\n        model: model,\n        effort: nil,\n        summary: nil\n      )\n      rows.append(SessionRow(timestamp: startedAt, kind: .turnContext(ctx)))\n    }\n\n    var lastTimestamp = startedAt\n    for message in record.messages {\n      let messageRows = self.rows(from: message)\n      rows.append(contentsOf: messageRows)\n      if shouldInsertTurnBoundary(after: message, rows: messageRows),\n        let markerTimestamp = messageRows.last?.timestamp ?? parseDate(message.timestamp)\n      {\n        rows.append(makeTurnBoundaryRow(for: message, timestamp: markerTimestamp))\n      }\n      if let last = messageRows.last?.timestamp, last > lastTimestamp {\n        lastTimestamp = last\n      }\n    }\n\n    let fileSize = resolveFileSize(for: url)\n    var builder = SessionSummaryBuilder()\n    builder.setFileSize(fileSize)\n    builder.setSource(.geminiLocal)\n\n    for row in rows {\n      builder.observe(row)\n    }\n\n    if let updated = parseDate(record.lastUpdated) ?? rows.last?.timestamp {\n      builder.seedLastUpdated(updated)\n    } else {\n      builder.seedLastUpdated(lastTimestamp)\n    }\n\n    guard var summary = builder.build(for: url) else { return nil }\n    summary = summary.overridingSource(.geminiLocal)\n    return GeminiParsedLog(summary: summary, rows: rows, tokens: aggregatedTokens)\n  }\n\n  private func firstModel(in messages: [ConversationRecord.Message]) -> String? {\n    for message in messages {\n      if let model = message.model, !model.isEmpty {\n        return model\n      }\n    }\n    return nil\n  }\n\n  private func parseDate(_ value: String?) -> Date? {\n    guard let value else { return nil }\n    if let date = isoFormatter.date(from: value) { return date }\n    if let date = fallbackFormatter.date(from: value) { return date }\n    if let number = Double(value) {\n      if number > 10_000_000_000 {\n        return Date(timeIntervalSince1970: number / 1000.0)\n      } else {\n        return Date(timeIntervalSince1970: number)\n      }\n    }\n    return nil\n  }\n\n  private func rows(from message: ConversationRecord.Message) -> [SessionRow] {\n    guard let timestamp = parseDate(message.timestamp) else { return [] }\n    var results: [SessionRow] = []\n    let text = renderText(from: message.content) ?? \"\"\n    let loweredType = message.type.lowercased()\n    switch loweredType {\n    case \"user\":\n      if !text.isEmpty {\n        if Self.isControlCommand(text) {\n          return results\n        }\n        results.append(\n          SessionRow(\n            timestamp: timestamp,\n            kind: .eventMessage(\n              EventMessagePayload(\n                type: \"user_message\",\n                message: text,\n                kind: nil,\n                text: text,\n                reason: nil,\n                info: nil,\n                rateLimits: nil\n              )))\n        )\n      }\n    case \"gemini\":\n      if !text.isEmpty {\n        results.append(\n          SessionRow(\n            timestamp: timestamp,\n            kind: .eventMessage(\n              EventMessagePayload(\n                type: \"agent_message\",\n                message: text,\n                kind: nil,\n                text: text,\n                reason: nil,\n                info: nil,\n                rateLimits: nil\n              )))\n        )\n      }\n      if let calls = message.toolCalls, !calls.isEmpty {\n        for call in calls {\n          if let row = toolCallRow(call, timestamp: timestamp) {\n            results.append(row)\n          }\n        }\n      }\n      if let thoughts = message.thoughts {\n        for thought in thoughts {\n          if let row = thoughtRow(thought, fallback: timestamp) {\n            results.append(row)\n          }\n        }\n      }\n      if let tokens = message.tokens {\n        if let row = tokenRow(tokens, timestamp: timestamp) {\n          results.append(row)\n        }\n      }\n    case \"info\", \"warning\":\n      if !text.isEmpty {\n        results.append(\n          SessionRow(\n            timestamp: timestamp,\n            kind: .eventMessage(\n              EventMessagePayload(\n                type: loweredType,\n                message: text,\n                kind: nil,\n                text: text,\n                reason: nil,\n                info: nil,\n                rateLimits: nil\n              )))\n        )\n      }\n    case \"error\":\n      if !text.isEmpty {\n        results.append(\n          SessionRow(\n            timestamp: timestamp,\n            kind: .eventMessage(\n              EventMessagePayload(\n                type: \"error\",\n                message: text,\n                kind: nil,\n                text: text,\n                reason: nil,\n                info: nil,\n                rateLimits: nil\n              )))\n        )\n      }\n    default:\n      if !text.isEmpty {\n        results.append(\n          SessionRow(\n            timestamp: timestamp,\n            kind: .eventMessage(\n              EventMessagePayload(\n                type: loweredType,\n                message: text,\n                kind: nil,\n                text: text,\n                reason: nil,\n                info: nil,\n                rateLimits: nil\n              )))\n        )\n      }\n    }\n    return results\n  }\n\n  private func shouldInsertTurnBoundary(\n    after message: ConversationRecord.Message,\n    rows: [SessionRow]\n  ) -> Bool {\n    guard !rows.isEmpty else { return false }\n    return message.type.lowercased() == \"gemini\"\n  }\n\n  private func makeTurnBoundaryRow(\n    for message: ConversationRecord.Message,\n    timestamp: Date\n  ) -> SessionRow {\n    let payload = EventMessagePayload(\n      type: \"turn_boundary\",\n      message: message.id,\n      kind: message.type.lowercased(),\n      text: nil,\n      reason: nil,\n      info: nil,\n      rateLimits: nil\n    )\n    return SessionRow(timestamp: timestamp, kind: .eventMessage(payload))\n  }\n\n  private func toolCallRow(\n    _ call: ConversationRecord.Message.ToolCall,\n    timestamp: Date\n  ) -> SessionRow? {\n    guard let name = call.name ?? call.displayName else { return nil }\n    let outputValue: JSONValue? = {\n      if let display = call.resultDisplay, !display.isEmpty {\n        return .string(display)\n      }\n      return call.result\n    }()\n    let payload = ResponseItemPayload(\n      type: \"tool_call\",\n      status: call.status,\n      callID: call.id,\n      name: name,\n      content: nil,\n      summary: nil,\n      encryptedContent: nil,\n      role: \"assistant\",\n      arguments: call.args,\n      input: nil,\n      output: outputValue,\n      ghostCommit: nil\n    )\n    return SessionRow(timestamp: timestamp, kind: .responseItem(payload))\n  }\n\n  private func thoughtRow(\n    _ thought: ConversationRecord.Message.Thought,\n    fallback: Date\n  ) -> SessionRow? {\n    let subject = thought.subject?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    guard let description = thought.description else { return nil }\n    var body = description\n    if !subject.isEmpty {\n      body = \"\\(subject): \\(description)\"\n    }\n    guard !body.isEmpty else { return nil }\n    let payload = EventMessagePayload(\n      type: \"agent_reasoning\",\n      message: body,\n      kind: nil,\n      text: body,\n      reason: nil,\n      info: nil,\n      rateLimits: nil\n    )\n    return SessionRow(timestamp: parseDate(thought.timestamp) ?? fallback, kind: .eventMessage(payload))\n  }\n\n  private func tokenRow(\n    _ tokens: ConversationRecord.Message.Tokens,\n    timestamp: Date\n  ) -> SessionRow? {\n    var info: [String: JSONValue] = [:]\n    var hasNonZero = false\n\n    func addNumber(_ key: String, _ value: Int?) {\n      guard let value else { return }\n      info[key] = .number(Double(value))\n      if value > 0 { hasNonZero = true }\n    }\n\n    addNumber(\"input\", tokens.input)\n    addNumber(\"output\", tokens.output)\n    addNumber(\"cached\", tokens.cached)\n    addNumber(\"thoughts\", tokens.thoughts)\n    addNumber(\"tool\", tokens.tool)\n    addNumber(\"total\", tokens.total)\n\n    guard !info.isEmpty, hasNonZero else { return nil }\n    let payload = EventMessagePayload(\n      type: \"token_count\",\n      message: nil,\n      kind: nil,\n      text: nil,\n      reason: nil,\n      info: .object(info),\n      rateLimits: nil\n    )\n    return SessionRow(timestamp: timestamp, kind: .eventMessage(payload))\n  }\n\n  private func renderText(from value: JSONValue?) -> String? {\n    guard let value else { return nil }\n    switch value {\n    case .string(let str):\n      let trimmed = str.trimmingCharacters(in: .whitespacesAndNewlines)\n      return trimmed.isEmpty ? nil : str\n    case .number(let number):\n      return String(number)\n    case .bool(let flag):\n      return flag ? \"true\" : \"false\"\n    case .array(let array):\n      let rendered = array.compactMap { renderText(from: $0) }.joined(separator: \"\\n\")\n      return rendered.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : rendered\n    case .object(let object):\n      let raw = object.mapValues { $0.toAnyValue() }\n      guard\n        JSONSerialization.isValidJSONObject(raw),\n        let data = try? JSONSerialization.data(\n          withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]),\n        let text = String(data: data, encoding: .utf8)\n      else {\n        return nil\n      }\n      return text\n    case .null:\n      return nil\n    }\n  }\n\n  private func resolveFileSize(for url: URL) -> UInt64? {\n    if\n      let values = try? url.resourceValues(forKeys: [.fileSizeKey]),\n      let size = values.fileSize\n    {\n      return UInt64(size)\n    }\n    return nil\n  }\n\n  private func defaultProjectPath(forHash hash: String) -> String {\n    let realHome = SessionPreferencesStore.getRealUserHomeURL().path\n    return \"\\(realHome)/.gemini/tmp/\\(hash)\"\n  }\n\n  // MARK: - Workspace heuristics\n  private func inferWorkingDirectory(from messages: [ConversationRecord.Message]) -> String? {\n    var candidates: [String] = []\n    candidates.reserveCapacity(32)\n\n    func append(paths: [String]) {\n      guard !paths.isEmpty else { return }\n      candidates.append(contentsOf: paths)\n    }\n\n    for message in messages {\n      append(paths: absolutePaths(in: message.content))\n      if let toolCalls = message.toolCalls {\n        for call in toolCalls {\n          append(paths: absolutePaths(in: call.args))\n          append(paths: absolutePaths(in: call.result))\n          if let display = call.resultDisplay {\n            append(paths: absolutePaths(in: display))\n          }\n          if let description = call.description {\n            append(paths: absolutePaths(in: description))\n          }\n        }\n      }\n    }\n\n    let normalized = candidates.compactMap { canonicalAbsolutePath(from: $0) }\n    guard !normalized.isEmpty else { return nil }\n    guard let prefix = commonPathPrefix(for: normalized) else { return nil }\n    return trimmedWorkspacePrefix(for: prefix)\n  }\n\n  private func absolutePaths(in value: JSONValue?) -> [String] {\n    guard let value else { return [] }\n    switch value {\n    case .string(let text):\n      return absolutePaths(in: text)\n    case .array(let array):\n      return array.flatMap { absolutePaths(in: $0) }\n    case .object(let dict):\n      return dict.values.flatMap { absolutePaths(in: $0) }\n    case .number, .bool, .null:\n      return []\n    }\n  }\n\n  private func absolutePaths(in text: String) -> [String] {\n    guard !text.isEmpty else { return [] }\n    let nsText = text as NSString\n    let range = NSRange(location: 0, length: nsText.length)\n    var matches: [String] = []\n    geminiAbsolutePathRegex.enumerateMatches(in: text, options: [], range: range) { result, _, _ in\n      guard let result, result.range.location != NSNotFound else { return }\n      var candidate = nsText.substring(with: result.range)\n      candidate = candidate.trimmingCharacters(in: geminiPathTrimCharacters)\n      guard !candidate.isEmpty else { return }\n      matches.append(candidate)\n    }\n    return matches\n  }\n\n  private func canonicalAbsolutePath(from raw: String) -> String? {\n    let expanded = (raw as NSString).expandingTildeInPath\n    guard expanded.hasPrefix(\"/\") else { return nil }\n    var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path\n    if standardized.count > 1 && standardized.hasSuffix(\"/\") {\n      standardized.removeLast()\n    }\n    return standardized\n  }\n\n  private func commonPathPrefix(for paths: [String]) -> String? {\n    guard let first = paths.first else { return nil }\n    var prefixComponents = Self.pathComponents(for: first)\n    for path in paths.dropFirst() {\n      let comps = Self.pathComponents(for: path)\n      var next: [String] = []\n      for (lhs, rhs) in zip(prefixComponents, comps) {\n        if lhs == rhs {\n          next.append(lhs)\n        } else {\n          break\n        }\n      }\n      prefixComponents = next\n      if prefixComponents.isEmpty { return nil }\n    }\n    guard !prefixComponents.isEmpty else { return nil }\n    return \"/\" + prefixComponents.joined(separator: \"/\")\n  }\n\n  private static func pathComponents(for path: String) -> [String] {\n    path.split(separator: \"/\", omittingEmptySubsequences: true).map(String.init)\n  }\n\n  private func trimmedWorkspacePrefix(for prefix: String) -> String? {\n    guard prefix.count > 1 else { return nil }\n    var path = prefix\n    if path.hasSuffix(\"/\") {\n      path.removeLast()\n    }\n    guard !path.isEmpty else { return nil }\n    let components = Self.pathComponents(for: path)\n    if let last = components.last, last.contains(\".\"), components.count > 1 {\n      let dropped = components.dropLast()\n      if dropped.isEmpty { return nil }\n      return \"/\" + dropped.joined(separator: \"/\")\n    }\n    return \"/\" + components.joined(separator: \"/\")\n  }\n\n  static func isControlCommand(_ rawText: String) -> Bool {\n    let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard trimmed.hasPrefix(\"/\"), trimmed.count > 1 else { return false }\n    if trimmed.dropFirst().contains(\"/\") { return false }\n    if trimmed.contains(\"\\n\") || trimmed.contains(\"\\r\") { return false }\n    let allowed = CharacterSet(charactersIn: \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_- \")\n    let scalars = trimmed.unicodeScalars.dropFirst()\n    return scalars.allSatisfy { allowed.contains($0) }\n  }\n}\n\nprivate extension JSONValue {\n  func toAnyValue() -> Any {\n    switch self {\n    case .string(let str): return str\n    case .number(let number): return number\n    case .bool(let flag): return flag\n    case .array(let array): return array.map { $0.toAnyValue() }\n    case .object(let dict): return dict.mapValues { $0.toAnyValue() }\n    case .null: return NSNull()\n    }\n  }\n}\n"
  },
  {
    "path": "services/GeminiSessionProvider.swift",
    "content": "import CryptoKit\nimport Foundation\n\nactor GeminiSessionProvider {\n  enum SessionProviderCacheError: Error {\n    case cacheUnavailable\n  }\n\n  private struct AggregatedSession {\n    let summary: SessionSummary\n    let rows: [SessionRow]\n    let primaryFileURL: URL\n  }\n\n  private struct GeminiLogEntry: Decodable {\n    let sessionId: String\n    let messageId: Int\n    let type: String\n    let message: String\n    let timestamp: String\n  }\n\n  private struct GeminiSessionValidationRecord: Decodable {\n    struct Message: Decodable {\n      let type: String?\n    }\n    let messages: [Message]?\n  }\n\n  private let parser = GeminiSessionParser()\n  private var projectsStore: ProjectsStore\n  private let fileManager: FileManager\n  private let tmpRoot: URL?\n  private let cacheStore: SessionIndexSQLiteStore?\n\n  private var hashToPath: [String: String] = [:]\n  private var canonicalURLById: [String: URL] = [:]\n  private var rowsCacheBySessionId: [String: [SessionRow]] = [:]\n  private var logCacheByHash: [String: [String: [GeminiLogEntry]]] = [:]\n  private var aggregatedCacheByHash: [String: AggregatedCacheEntry] = [:]\n  private let logDateFormatter: ISO8601DateFormatter\n  private let fallbackLogFormatter: ISO8601DateFormatter\n  private static func hash(for path: String) -> String? {\n    let canonical = (path as NSString).expandingTildeInPath\n    guard let data = canonical.data(using: .utf8) else { return nil }\n    let digest = SHA256.hash(data: data)\n    return digest.map { String(format: \"%02x\", $0) }.joined()\n  }\n\n  private struct AggregatedCacheEntry {\n    let signature: HashSignature\n    let sessions: [AggregatedSession]\n  }\n\n  private struct HashSignature: Equatable {\n    let fileCount: Int\n    let chatsTotalSize: UInt64\n    let latestChatMtime: Date?\n    let logSize: UInt64\n    let logMtime: Date?\n  }\n\n  private struct CachedSummariesResult {\n    let summaries: [SessionSummary]\n    let isComplete: Bool\n  }\n\n  private func cachedSummaries(\n    forHash hash: String,\n    files: [ChatFileInfo],\n    signature: HashSignature\n  ) async throws -> CachedSummariesResult {\n    guard let cacheStore else { throw SessionProviderCacheError.cacheUnavailable }\n    guard let latest = signature.latestChatMtime else {\n      return CachedSummariesResult(summaries: [], isComplete: true)\n    }\n    var bestById: [String: SessionSummary] = [:]\n    var isComplete = true\n    for file in files {\n      let validity = sessionValidity(for: file.url)\n      if validity == .invalid { continue }\n      guard let cached = try await cacheStore.fetch(\n        path: file.url.path,\n        modificationDate: latest,\n        fileSize: signature.chatsTotalSize\n      ) else {\n        isComplete = false\n        continue\n      }\n      let summary = cached.overridingSource(.geminiLocal)\n      canonicalURLById[summary.id] = file.url\n      if let existing = bestById[summary.id] {\n        bestById[summary.id] = prefer(lhs: existing, rhs: summary)\n      } else {\n        bestById[summary.id] = summary\n      }\n    }\n    return CachedSummariesResult(summaries: Array(bestById.values), isComplete: isComplete)\n  }\n\n  private enum GeminiSessionValidity {\n    case valid\n    case invalid\n    case unknown\n  }\n\n  private func sessionValidity(for url: URL) -> GeminiSessionValidity {\n    guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return .unknown }\n    guard let record = try? JSONDecoder().decode(GeminiSessionValidationRecord.self, from: data) else {\n      return .unknown\n    }\n    guard let messages = record.messages, !messages.isEmpty else { return .invalid }\n    for message in messages {\n      let kind = message.type?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n      if kind == \"user\" || kind == \"gemini\" { return .valid }\n    }\n    return .invalid\n  }\n\n  private func persist(summary: SessionSummary, modificationDate: Date?, fileSize: UInt64?) {\n    guard let cacheStore else { return }\n    Task.detached { [cacheStore] in\n      try? await cacheStore.upsert(\n        summary: summary,\n        project: nil,\n        fileModificationTime: modificationDate,\n        fileSize: fileSize,\n        tokenBreakdown: summary.tokenBreakdown,\n        parseError: nil\n      )\n    }\n  }\n\n  init(\n    projectsStore: ProjectsStore,\n    fileManager: FileManager = .default,\n    cacheStore: SessionIndexSQLiteStore? = nil\n  ) {\n    self.projectsStore = projectsStore\n    self.fileManager = fileManager\n    self.cacheStore = cacheStore\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let root = home.appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"tmp\", isDirectory: true)\n    var isDir: ObjCBool = false\n    if fileManager.fileExists(atPath: root.path, isDirectory: &isDir), isDir.boolValue {\n      self.tmpRoot = root\n    } else {\n      self.tmpRoot = nil\n    }\n    self.logDateFormatter = ISO8601DateFormatter()\n    self.logDateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    self.fallbackLogFormatter = ISO8601DateFormatter()\n    self.fallbackLogFormatter.formatOptions = [.withInternetDateTime]\n  }\n\n  func sessions(scope: SessionLoadScope, allowedProjectDirectories: [String]? = nil, ignoredPaths: [String] = []) async throws -> [SessionSummary] {\n    guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable }\n    let preferFullInitialParse = ((try? await cacheStore?.fetchMeta().sessionCount) ?? 0) == 0\n    guard let tmpRoot else { return [] }\n    guard let hashes = try? fileManager.contentsOfDirectory(\n      at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])\n    else { return [] }\n    let allowedHashes: Set<String>? = {\n      guard let allowed = allowedProjectDirectories, !allowed.isEmpty else { return nil }\n      var hashes: Set<String> = []\n      for path in allowed {\n        if let hash = Self.hash(for: path) { hashes.insert(hash) }\n      }\n      return hashes.isEmpty ? nil : hashes\n    }()\n\n    rowsCacheBySessionId.removeAll()\n    var summaries: [SessionSummary] = []\n\n    for hashURL in hashes {\n      guard hashURL.hasDirectoryPath else { continue }\n      // Apply ignore rules\n      if shouldIgnorePath(hashURL.path, ignoredPaths: ignoredPaths) {\n        continue\n      }\n      let hash = hashURL.lastPathComponent\n      guard hash.count == 64,\n        hash.range(of: \"^[0-9a-f]+$\", options: .regularExpression) != nil\n      else { continue }\n      if let allowedHashes, !allowedHashes.contains(hash) { continue }\n      let resolvedPath = await resolveProjectPath(forHash: hash)\n      if !preferFullInitialParse, let fileInfo = chatFilesAndSignature(forHash: hash, hashURL: hashURL) {\n        let cached = try await cachedSummaries(\n          forHash: hash,\n          files: fileInfo.files,\n          signature: fileInfo.signature\n        )\n        if cached.isComplete {\n          if !cached.summaries.isEmpty {\n            for summary in cached.summaries where matches(scope: scope, summary: summary) {\n              summaries.append(summary)\n            }\n          }\n          continue\n        }\n      }\n\n      let aggregated = aggregatedSessions(\n        forHash: hash,\n        hashURL: hashURL,\n        resolvedProjectPath: resolvedPath,\n        cacheResults: true)\n      for session in aggregated where matches(scope: scope, summary: session.summary) {\n        // Check ignore rules against cwd\n        // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries.\n        // This allows sessions to reappear if ignore rules are removed later.\n        // (aggregatedSessions already cached with cacheResults: true)\n        if shouldIgnoreSummary(session.summary, ignoredPaths: ignoredPaths) {\n          continue\n        }\n        summaries.append(session.summary)\n        rowsCacheBySessionId[session.summary.id] = session.rows\n        canonicalURLById[session.summary.id] = session.primaryFileURL\n      }\n    }\n\n    return summaries.sorted {\n      let lhs = $0.lastUpdatedAt ?? $0.startedAt\n      let rhs = $1.lastUpdatedAt ?? $1.startedAt\n      return lhs > rhs\n    }\n  }\n\n  func collectCWDCounts() async -> [String: Int] {\n    guard let tmpRoot else { return [:] }\n    guard let hashes = try? fileManager.contentsOfDirectory(\n      at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])\n    else { return [:] }\n\n    var counts: [String: Int] = [:]\n    for hashURL in hashes {\n      guard hashURL.hasDirectoryPath else { continue }\n      let hash = hashURL.lastPathComponent\n      guard hash.count == 64,\n        hash.range(of: \"^[0-9a-f]+$\", options: .regularExpression) != nil\n      else { continue }\n      let resolved = await resolveProjectPath(forHash: hash)\n      let aggregated = aggregatedSessions(\n        forHash: hash,\n        hashURL: hashURL,\n        resolvedProjectPath: resolved,\n        cacheResults: false)\n      for session in aggregated {\n        counts[session.summary.cwd, default: 0] += 1\n      }\n    }\n    return counts\n  }\n\n  func countAllSessions() async -> Int {\n    guard let tmpRoot else { return 0 }\n    guard let hashes = try? fileManager.contentsOfDirectory(\n      at: tmpRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])\n    else { return 0 }\n    var total = 0\n    for hashURL in hashes {\n      guard hashURL.hasDirectoryPath else { continue }\n      let hash = hashURL.lastPathComponent\n      guard hash.count == 64,\n        hash.range(of: \"^[0-9a-f]+$\", options: .regularExpression) != nil\n      else { continue }\n      let resolved = await resolveProjectPath(forHash: hash)\n      let aggregated = aggregatedSessions(\n        forHash: hash,\n        hashURL: hashURL,\n        resolvedProjectPath: resolved,\n        cacheResults: false)\n      total += aggregated.count\n    }\n    return total\n  }\n\n  func timeline(for summary: SessionSummary) async -> [ConversationTurn]? {\n    guard let rows = await rowsForSession(summary: summary) else { return nil }\n    let loader = SessionTimelineLoader()\n    return loader.turns(from: rows)\n  }\n\n  func environmentContext(for summary: SessionSummary) async -> EnvironmentContextInfo? {\n    guard let rows = await rowsForSession(summary: summary) else { return nil }\n    let loader = SessionTimelineLoader()\n    return loader.loadEnvironmentContext(from: rows)\n  }\n\n  func enrich(summary: SessionSummary) async -> SessionSummary? {\n    guard let rows = await rowsForSession(summary: summary) else { return summary }\n    let loader = SessionTimelineLoader()\n    let turns = loader.turns(from: rows)\n    let activeDuration = computeActiveDuration(turns: turns)\n    return SessionSummary(\n      id: summary.id,\n      fileURL: summary.fileURL,\n      fileSizeBytes: summary.fileSizeBytes,\n      startedAt: summary.startedAt,\n      endedAt: summary.endedAt,\n      activeDuration: activeDuration,\n      cliVersion: summary.cliVersion,\n      cwd: summary.cwd,\n      originator: summary.originator,\n      instructions: summary.instructions,\n      model: summary.model,\n      approvalPolicy: summary.approvalPolicy,\n      userMessageCount: summary.userMessageCount,\n      assistantMessageCount: summary.assistantMessageCount,\n      toolInvocationCount: summary.toolInvocationCount,\n      responseCounts: summary.responseCounts,\n      turnContextCount: summary.turnContextCount,\n      messageTypeCounts: summary.messageTypeCounts,\n      totalTokens: summary.totalTokens,\n      tokenBreakdown: summary.tokenBreakdown,\n      eventCount: summary.eventCount,\n      lineCount: summary.lineCount,\n      lastUpdatedAt: summary.lastUpdatedAt,\n      source: .geminiLocal,\n      remotePath: summary.remotePath,\n      userTitle: summary.userTitle,\n      userComment: summary.userComment,\n      taskId: summary.taskId\n    )\n  }\n\n  func sessions(inProjectDirectory directory: String) async -> [SessionSummary] {\n    guard let hash = directoryHash(for: directory) else { return [] }\n    guard let tmpRoot else { return [] }\n    let hashURL = tmpRoot.appendingPathComponent(hash, isDirectory: true)\n    let aggregated = aggregatedSessions(\n      forHash: hash,\n      hashURL: hashURL,\n      resolvedProjectPath: directory,\n      cacheResults: true)\n    for session in aggregated {\n      rowsCacheBySessionId[session.summary.id] = session.rows\n      canonicalURLById[session.summary.id] = session.primaryFileURL\n    }\n    return aggregated.map { $0.summary }\n  }\n\n  // MARK: - Helpers\n\n  private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool {\n    let calendar = Calendar.current\n    let referenceDates = [summary.startedAt, summary.lastUpdatedAt ?? summary.startedAt]\n    switch scope {\n    case .all:\n      return true\n    case .today:\n      return referenceDates.contains { calendar.isDateInToday($0) }\n    case .day(let day):\n      return referenceDates.contains { calendar.isDate($0, inSameDayAs: day) }\n    case .month(let date):\n      return referenceDates.contains {\n        calendar.isDate($0, equalTo: date, toGranularity: .month)\n      }\n    }\n  }\n\n  private func canonicalURL(for summary: SessionSummary) -> URL? {\n    canonicalURLById[summary.id] ?? summary.fileURL\n  }\n\n  private func projectHash(for url: URL) -> String? {\n    let components = url.pathComponents\n    guard let chatsIndex = components.lastIndex(of: \"chats\"), chatsIndex > 0 else { return nil }\n    return components[chatsIndex - 1]\n  }\n\n  private func resolveProjectPath(forHash hash: String) async -> String? {\n    if let cached = hashToPath[hash] { return cached }\n    let projects = await projectsStore.listProjects()\n    let directories = projects.compactMap { $0.directory }\n    for directory in directories {\n      guard let digest = directoryHash(for: directory), digest == hash else { continue }\n      hashToPath[hash] = normalized(directory)\n      return hashToPath[hash]\n    }\n    return nil\n  }\n\n  private func directoryHash(for directory: String) -> String? {\n    let expanded = (directory as NSString).expandingTildeInPath\n    guard let data = expanded.data(using: .utf8) else { return nil }\n    let digest = SHA256.hash(data: data)\n    return digest.map { String(format: \"%02x\", $0) }.joined()\n  }\n\n  private func normalized(_ directory: String) -> String {\n    let expanded = (directory as NSString).expandingTildeInPath\n    return URL(fileURLWithPath: expanded).standardizedFileURL.path\n  }\n\n  func invalidateProjectMappings() {\n    hashToPath.removeAll()\n  }\n\n  func updateProjectsStore(_ store: ProjectsStore) {\n    projectsStore = store\n    hashToPath.removeAll()\n  }\n\n  private func computeActiveDuration(turns: [ConversationTurn]) -> TimeInterval? {\n    guard !turns.isEmpty else { return nil }\n    let filtered = turns.removingEnvironmentContext()\n    guard !filtered.isEmpty else { return nil }\n    var total: TimeInterval = 0\n    for turn in filtered {\n      let start = turn.userMessage?.timestamp ?? turn.outputs.first?.timestamp\n      guard let s = start, let end = turn.outputs.last?.timestamp else { continue }\n      let delta = end.timeIntervalSince(s)\n      if delta > 0 { total += delta }\n    }\n    return total\n  }\n\n  private func rowsForSession(summary: SessionSummary) async -> [SessionRow]? {\n    if let rows = rowsCacheBySessionId[summary.id] { return rows }\n    guard let url = canonicalURL(for: summary),\n      let hash = projectHash(for: url),\n      let tmpRoot\n    else { return nil }\n    let hashURL = tmpRoot.appendingPathComponent(hash, isDirectory: true)\n    let resolved = await resolveProjectPath(forHash: hash)\n    let aggregated = aggregatedSessions(\n      forHash: hash,\n      hashURL: hashURL,\n      resolvedProjectPath: resolved,\n      cacheResults: true)\n    for session in aggregated {\n      rowsCacheBySessionId[session.summary.id] = session.rows\n      canonicalURLById[session.summary.id] = session.primaryFileURL\n    }\n    return rowsCacheBySessionId[summary.id]\n  }\n\n  private func aggregatedSessions(\n    forHash hash: String,\n    hashURL: URL,\n    resolvedProjectPath: String?,\n    cacheResults: Bool\n  ) -> [AggregatedSession] {\n    guard let fileInfo = chatFilesAndSignature(forHash: hash, hashURL: hashURL) else { return [] }\n    if let cached = aggregatedCacheByHash[hash], cached.signature == fileInfo.signature {\n      if cacheResults {\n        for session in cached.sessions {\n          rowsCacheBySessionId[session.summary.id] = session.rows\n          canonicalURLById[session.summary.id] = session.primaryFileURL\n        }\n      }\n      return cached.sessions\n    }\n\n    var segmentsBySession: [String: [GeminiParsedLog]] = [:]\n    for info in fileInfo.files where info.url.pathExtension.lowercased() == \"json\" {\n      // Note: ignore rules are applied at hash directory level, not individual files\n      guard let parsed = parser.parse(\n        at: info.url, projectHash: hash, resolvedProjectPath: resolvedProjectPath)\n      else { continue }\n      segmentsBySession[parsed.summary.id, default: []].append(parsed)\n    }\n    guard !segmentsBySession.isEmpty else { return [] }\n\n    if cacheResults {\n      logCacheByHash.removeValue(forKey: hash)\n    }\n    let logEntries = logEntriesBySession(forHash: hash)\n    var results: [AggregatedSession] = []\n    for (sessionId, segments) in segmentsBySession {\n      guard let aggregated = aggregate(\n        segments: segments,\n        extraLogEntries: logEntries[sessionId])\n      else { continue }\n      results.append(aggregated)\n      if cacheResults {\n        rowsCacheBySessionId[aggregated.summary.id] = aggregated.rows\n        canonicalURLById[aggregated.summary.id] = aggregated.primaryFileURL\n        persist(summary: aggregated.summary, modificationDate: fileInfo.signature.latestChatMtime, fileSize: fileInfo.signature.chatsTotalSize)\n      }\n    }\n\n    if cacheResults {\n      aggregatedCacheByHash[hash] = AggregatedCacheEntry(signature: fileInfo.signature, sessions: results)\n    }\n    return results\n  }\n\n  private func aggregate(\n    segments: [GeminiParsedLog],\n    extraLogEntries: [GeminiLogEntry]?\n  ) -> AggregatedSession? {\n    guard !segments.isEmpty else { return nil }\n    var rows: [SessionRow] = []\n    let orderedSegments = segments.sorted { lhs, rhs in\n      lhs.summary.startedAt < rhs.summary.startedAt\n    }\n    for segment in orderedSegments { rows.append(contentsOf: segment.rows) }\n    if let extras = extraLogEntries {\n      rows.append(contentsOf: rowsFromLogs(extras))\n    }\n    let normalized = normalize(rows: rows)\n    guard !normalized.isEmpty else { return nil }\n    let timelineLoader = SessionTimelineLoader()\n    let turns = timelineLoader.turns(from: normalized)\n    let conversationCount = turns.count\n    let assistantMessages = turns.reduce(into: 0) { partialResult, turn in\n      partialResult += turn.outputs.filter { $0.actor == .assistant }.count\n    }\n\n    var builder = SessionSummaryBuilder()\n    builder.setSource(.geminiLocal)\n    let totalSize = segments.compactMap { $0.summary.fileSizeBytes }.reduce(0, +)\n    if totalSize > 0 { builder.setFileSize(totalSize) }\n    for row in normalized { builder.observe(row) }\n    if let lastTimestamp = normalized.last?.timestamp {\n      builder.seedLastUpdated(lastTimestamp)\n    }\n    guard let representative = segments.max(by: { ($0.summary.lastUpdatedAt ?? $0.summary.startedAt) < ($1.summary.lastUpdatedAt ?? $1.summary.startedAt) })\n    else { return nil }\n    guard var summary = builder.build(for: representative.summary.fileURL) else { return nil }\n    summary = summary\n      .overridingSource(.geminiLocal)\n      .overridingCounts(userMessages: conversationCount, assistantMessages: assistantMessages)\n\n    // Aggregate token usage across all Gemini segments using the raw chat JSON.\n    var totalInput = 0\n    var totalOutput = 0\n    var totalCached = 0\n    var totalThoughts = 0\n    var totalTool = 0\n\n    for segment in segments {\n      guard let tokens = segment.tokens else { continue }\n      if tokens.input > 0 { totalInput &+= tokens.input }\n      if tokens.output > 0 { totalOutput &+= tokens.output }\n      if tokens.cached > 0 { totalCached &+= tokens.cached }\n      if tokens.thoughts > 0 { totalThoughts &+= tokens.thoughts }\n      if tokens.tool > 0 { totalTool &+= tokens.tool }\n    }\n\n    if totalInput != 0 || totalOutput != 0 || totalCached != 0 || totalThoughts != 0 || totalTool != 0 {\n      // Treat Gemini output as the sum of output, thoughts, and tool tokens.\n      let aggregatedOutput = totalOutput &+ totalThoughts &+ totalTool\n      let aggregatedInput = totalInput\n      let aggregatedCacheRead = totalCached\n      let aggregatedCacheCreation = 0\n\n      let breakdown = SessionTokenBreakdown(\n        input: max(aggregatedInput, 0),\n        output: max(aggregatedOutput, 0),\n        cacheRead: max(aggregatedCacheRead, 0),\n        cacheCreation: max(aggregatedCacheCreation, 0)\n      )\n\n      // Session-wide total tokens = sum of per-message totals (input + output + thoughts + tool).\n      let totalTokens = breakdown.total\n      summary = summary.overridingTokens(\n        totalTokens: totalTokens,\n        tokenBreakdown: breakdown\n      )\n    }\n\n    return AggregatedSession(summary: summary, rows: normalized, primaryFileURL: representative.summary.fileURL)\n  }\n\n  private struct ChatFileInfo {\n    let url: URL\n    let modificationDate: Date?\n    let size: UInt64\n  }\n\n  private func chatFilesAndSignature(\n    forHash hash: String,\n    hashURL: URL\n  ) -> (files: [ChatFileInfo], signature: HashSignature)? {\n    let chatsDir = hashURL.appendingPathComponent(\"chats\", isDirectory: true)\n    var isDir: ObjCBool = false\n    guard fileManager.fileExists(atPath: chatsDir.path, isDirectory: &isDir), isDir.boolValue else {\n      return nil\n    }\n    guard let files = try? fileManager.contentsOfDirectory(\n      at: chatsDir,\n      includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey],\n      options: [.skipsHiddenFiles])\n    else { return nil }\n\n    var infos: [ChatFileInfo] = []\n    var totalSize: UInt64 = 0\n    var latestMtime: Date?\n    var fileCount = 0\n\n    for file in files where file.pathExtension.lowercased() == \"json\" {\n      guard let values = try? file.resourceValues(\n        forKeys: [.isRegularFileKey, .fileSizeKey, .contentModificationDateKey]),\n        values.isRegularFile == true\n      else { continue }\n      fileCount += 1\n      let size = UInt64(values.fileSize ?? 0)\n      totalSize += size\n      if let m = values.contentModificationDate {\n        if latestMtime == nil || m > latestMtime! { latestMtime = m }\n      }\n      infos.append(ChatFileInfo(url: file, modificationDate: values.contentModificationDate, size: size))\n    }\n\n    let logURL = hashURL.appendingPathComponent(\"logs.json\", isDirectory: false)\n    let logValues = try? logURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])\n    let logSize = UInt64(logValues?.fileSize ?? 0)\n    let logMtime = logValues?.contentModificationDate\n    if let logMtime, latestMtime == nil || logMtime > latestMtime! {\n      latestMtime = logMtime\n    }\n\n    let signature = HashSignature(\n      fileCount: fileCount,\n      chatsTotalSize: totalSize,\n      latestChatMtime: latestMtime,\n      logSize: logSize,\n      logMtime: logMtime)\n    return (infos, signature)\n  }\n\n  private func rowsFromLogs(_ logEntries: [GeminiLogEntry]) -> [SessionRow] {\n    logEntries.compactMap { entry in\n      guard entry.type.lowercased() == \"user\" else { return nil }\n      guard let timestamp = parseLogDate(entry.timestamp) else { return nil }\n      let text = entry.message.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !text.isEmpty, !GeminiSessionParser.isControlCommand(text) else { return nil }\n      let payload = EventMessagePayload(\n        type: \"user_message\",\n        message: text,\n        kind: nil,\n        text: text,\n        reason: nil,\n        info: nil,\n        rateLimits: nil\n      )\n      return SessionRow(timestamp: timestamp, kind: .eventMessage(payload))\n    }\n  }\n\n  private func normalize(rows: [SessionRow]) -> [SessionRow] {\n    guard !rows.isEmpty else { return [] }\n    let ordered = rows.enumerated().sorted { lhs, rhs in\n      if lhs.element.timestamp == rhs.element.timestamp { return lhs.offset < rhs.offset }\n      return lhs.element.timestamp < rhs.element.timestamp\n    }\n\n    var deduped: [SessionRow] = []\n    var repeatCountByIndex: [Int: Int] = [:]\n    var userEntryByText: [String: (index: Int, timestamp: Date)] = [:]\n    let duplicateWindow: TimeInterval = 5.0\n\n    for (_, row) in ordered {\n      var shouldAppend = true\n      if case let .eventMessage(payload) = row.kind,\n        payload.type.lowercased() == \"user_message\"\n      {\n        let normalizedText = (payload.message ?? payload.text ?? \"\")\n          .trimmingCharacters(in: .whitespacesAndNewlines)\n        if let existing = userEntryByText[normalizedText],\n          abs(row.timestamp.timeIntervalSince(existing.timestamp)) < duplicateWindow\n        {\n          repeatCountByIndex[existing.index, default: 1] += 1\n          userEntryByText[normalizedText] = (existing.index, row.timestamp)\n          shouldAppend = false\n        } else {\n          let index = deduped.count\n          userEntryByText[normalizedText] = (index, row.timestamp)\n          repeatCountByIndex[index] = 1\n        }\n      }\n\n      if shouldAppend {\n        deduped.append(row)\n      }\n    }\n\n    if !repeatCountByIndex.isEmpty {\n      for (index, count) in repeatCountByIndex where count > 1 {\n        guard index < deduped.count else { continue }\n        deduped[index] = injectRepeatCount(into: deduped[index], count: count)\n      }\n    }\n\n    return deduped\n  }\n\n  private func injectRepeatCount(into row: SessionRow, count: Int) -> SessionRow {\n    guard count > 1 else { return row }\n    guard case let .eventMessage(payload) = row.kind else { return row }\n    var metadata: [String: JSONValue] = [:]\n    if case let .object(existing) = payload.info {\n      metadata = existing\n    }\n    metadata[\"repeat_count\"] = .number(Double(count))\n    let updatedPayload = EventMessagePayload(\n      type: payload.type,\n      message: payload.message,\n      kind: payload.kind,\n      text: payload.text,\n      reason: payload.reason,\n      info: metadata.isEmpty ? nil : .object(metadata),\n      rateLimits: payload.rateLimits\n    )\n    return SessionRow(timestamp: row.timestamp, kind: .eventMessage(updatedPayload))\n  }\n\n  private func prefer(lhs: SessionSummary, rhs: SessionSummary) -> SessionSummary {\n    if lhs.id != rhs.id { return lhs }\n    let lt = lhs.lastUpdatedAt ?? lhs.startedAt\n    let rt = rhs.lastUpdatedAt ?? rhs.startedAt\n    if lt != rt { return lt > rt ? lhs : rhs }\n    let ls = lhs.fileSizeBytes ?? 0\n    let rs = rhs.fileSizeBytes ?? 0\n    if ls != rs { return ls > rs ? lhs : rhs }\n    return lhs.fileURL.lastPathComponent < rhs.fileURL.lastPathComponent ? lhs : rhs\n  }\n\n  private func logEntriesBySession(forHash hash: String) -> [String: [GeminiLogEntry]] {\n    if let cached = logCacheByHash[hash] { return cached }\n    guard let tmpRoot else {\n      logCacheByHash[hash] = [:]\n      return [:]\n    }\n    let logURL = tmpRoot\n      .appendingPathComponent(hash, isDirectory: true)\n      .appendingPathComponent(\"logs.json\", isDirectory: false)\n    guard let data = try? Data(contentsOf: logURL) else {\n      logCacheByHash[hash] = [:]\n      return [:]\n    }\n    guard let entries = try? JSONDecoder().decode([GeminiLogEntry].self, from: data) else {\n      logCacheByHash[hash] = [:]\n      return [:]\n    }\n    var grouped: [String: [GeminiLogEntry]] = [:]\n    for entry in entries {\n      grouped[entry.sessionId, default: []].append(entry)\n    }\n    for key in grouped.keys {\n      grouped[key]?.sort(by: { $0.messageId < $1.messageId })\n    }\n    logCacheByHash[hash] = grouped\n    return grouped\n  }\n\n  private func parseLogDate(_ value: String) -> Date? {\n    if let date = logDateFormatter.date(from: value) { return date }\n    return fallbackLogFormatter.date(from: value)\n  }\n}\n\n// MARK: - SessionProvider\n\nextension GeminiSessionProvider: SessionProvider {\n  nonisolated var kind: SessionSource.Kind { .gemini }\n  nonisolated var identifier: String { \"gemini-local\" }\n  nonisolated var label: String { \"Gemini (local)\" }\n\n  func load(context: SessionProviderContext) async throws -> SessionProviderResult {\n    switch context.cachePolicy {\n    case .cacheOnly:\n      if let cacheStore {\n        let dateColumn = context.dateDimension == .updated ? \"COALESCE(last_updated_at, started_at)\" : \"started_at\"\n        let range = context.dateRange ?? Self.dateRange(for: context.scope)\n        var cached = try await cacheStore.fetchSummaries(\n          kinds: [.gemini],\n          includeRemote: false,\n          dateColumn: dateColumn,\n          dateRange: range,\n          projectIds: context.projectIds\n        )\n        // Apply ignore rules to cached results\n        let originalCount = cached.count\n        if !context.ignoredPaths.isEmpty {\n          cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) }\n          print(\"GeminiSessionProvider: filtered \\(originalCount - cached.count) sessions by ignore rules (\\(cached.count) remain)\")\n        }\n        if !cached.isEmpty {\n          let filtered = cached.filter { sessionValidity(for: $0.fileURL) != .invalid }\n          return SessionProviderResult(summaries: filtered, coverage: nil, cacheHit: true)\n        }\n      }\n      return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true)\n    case .refresh:\n      guard cacheStore != nil else { throw SessionProviderCacheError.cacheUnavailable }\n      let summaries = try await sessions(\n        scope: context.scope,\n        allowedProjectDirectories: context.projectDirectories,\n        ignoredPaths: context.ignoredPaths\n      )\n      return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false)\n    }\n  }\n\n  private static func dateRange(for scope: SessionLoadScope) -> (Date, Date)? {\n    let cal = Calendar.current\n    switch scope {\n    case .all:\n      return nil\n    case .today:\n      let start = cal.startOfDay(for: Date())\n      guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n      return (start, end)\n    case .day(let day):\n      let start = cal.startOfDay(for: day)\n      guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n      return (start, end)\n    case .month(let date):\n      guard\n        let start = cal.date(from: cal.dateComponents([.year, .month], from: date)),\n        let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start)\n      else { return nil }\n      return (start, end)\n    }\n  }\n  \n  // MARK: - Ignore Rules\n  \n  private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths)\n  }\n  \n  private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths)\n  }\n}\n"
  },
  {
    "path": "services/GeminiSettingsService.swift",
    "content": "import Foundation\n\nactor GeminiSettingsService {\n  struct Paths {\n    let directory: URL\n    let file: URL\n\n    static func `default`() -> Paths {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      let dir = home.appendingPathComponent(\".gemini\", isDirectory: true)\n      return Paths(directory: dir, file: dir.appendingPathComponent(\"settings.json\", isDirectory: false))\n    }\n  }\n\n  struct Snapshot: Sendable {\n    var previewFeatures: Bool?\n    var vimMode: Bool?\n    var disableAutoUpdate: Bool?\n    var enablePromptCompletion: Bool?\n    var sessionRetentionEnabled: Bool?\n    var modelName: String?\n    var maxSessionTurns: Int?\n    var compressionThreshold: Double?\n    var skipNextSpeakerCheck: Bool?\n  }\n\n  struct NotificationHooksStatus: Sendable {\n    var hookInstalled: Bool\n    var hooksEnabled: Bool\n  }\n\n  private typealias JSONObject = [String: Any]\n  private let codMateHookURLPrefix = \"codmate://notify?source=gemini&event=\"\n  private let codMateManagedHookNamePrefix = \"codmate-hook:\"\n\n  private enum HookEvent: String {\n    case permission\n  }\n\n  private struct HookPayload {\n    var title: String\n    var body: String\n  }\n\n  private let paths: Paths\n  private let fm: FileManager\n\n  init(paths: Paths = .default(), fileManager: FileManager = .default) {\n    self.paths = paths\n    self.fm = fileManager\n  }\n\n  nonisolated var settingsFileURL: URL { paths.file }\n\n  // MARK: - Public API\n\n  func loadSnapshot() -> Snapshot {\n    let object = loadJSONObject()\n    return Snapshot(\n      previewFeatures: boolValue(in: object, path: [\"general\", \"previewFeatures\"]),\n      vimMode: boolValue(in: object, path: [\"general\", \"vimMode\"]),\n      disableAutoUpdate: boolValue(in: object, path: [\"general\", \"disableAutoUpdate\"]),\n      enablePromptCompletion: boolValue(in: object, path: [\"general\", \"enablePromptCompletion\"]),\n      sessionRetentionEnabled: boolValue(in: object, path: [\"general\", \"sessionRetention\", \"enabled\"]),\n      modelName: stringValue(in: object, path: [\"model\", \"name\"]),\n      maxSessionTurns: intValue(in: object, path: [\"model\", \"maxSessionTurns\"]),\n      compressionThreshold: doubleValue(in: object, path: [\"model\", \"compressionThreshold\"]),\n      skipNextSpeakerCheck: boolValue(in: object, path: [\"model\", \"skipNextSpeakerCheck\"])\n    )\n  }\n\n  func loadRawText() -> String {\n    (try? String(contentsOf: paths.file, encoding: .utf8)) ?? \"\"\n  }\n\n  func setBool(_ value: Bool, at path: [String]) throws {\n    try setValue(value, at: path)\n  }\n\n  func setOptionalBool(_ value: Bool?, at path: [String]) throws {\n    try setValue(value, at: path)\n  }\n\n  func setInt(_ value: Int, at path: [String]) throws {\n    try setValue(value, at: path)\n  }\n\n  func setDouble(_ value: Double, at path: [String]) throws {\n    try setValue(value, at: path)\n  }\n\n  func setOptionalString(_ value: String?, at path: [String]) throws {\n    try setValue(value, at: path)\n  }\n\n  // MARK: - Notification hooks\n\n  func codMateNotificationHooksStatus() -> NotificationHooksStatus {\n    let object = loadJSONObject()\n    let hooksEnabled = boolValue(in: object, path: [\"tools\", \"enableHooks\"]) ?? false\n    guard let hooks = object[\"hooks\"] as? JSONObject else {\n      return NotificationHooksStatus(hookInstalled: false, hooksEnabled: hooksEnabled)\n    }\n    let installed = containsCodMateHook(in: hooks)\n    return NotificationHooksStatus(hookInstalled: installed, hooksEnabled: hooksEnabled)\n  }\n\n  func setCodMateNotificationHooks(enabled: Bool) throws {\n    var object = loadJSONObject()\n    var hooks = object[\"hooks\"] as? JSONObject ?? [:]\n    hooks = updateNotificationHooksContainer(hooks, enabled: enabled)\n    if hooks.isEmpty {\n      object.removeValue(forKey: \"hooks\")\n    } else {\n      object[\"hooks\"] = hooks\n    }\n    if enabled {\n      update(&object, path: [\"tools\", \"enableMessageBusIntegration\"], value: true)\n      update(&object, path: [\"tools\", \"enableHooks\"], value: true)\n    }\n    try writeJSONObject(object)\n  }\n\n  // MARK: - User hooks (CodMate Extensions)\n  func applyHooksFromCodMate(_ rules: [HookRule]) throws -> [HookSyncWarning] {\n    var warnings: [HookSyncWarning] = []\n    var object = loadJSONObject()\n    var hooks = object[\"hooks\"] as? JSONObject ?? [:]\n\n    hooks = pruneCodMateManagedHooks(hooks)\n\n    let filtered = rules.filter { $0.isEnabled(for: .gemini) }\n    for rule in filtered {\n      let rawEvent = rule.event.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !rawEvent.isEmpty else { continue }\n      let resolution = HookEventCatalog.resolveProviderEvent(rawEvent, for: .gemini)\n      if resolution.isKnown, !resolution.isSupported {\n        warnings.append(HookSyncWarning(\n          provider: .gemini,\n          message: \"Gemini CLI does not support hook event \\\"\\(rawEvent)\\\"; skipping \\\"\\(rule.name)\\\".\"\n        ))\n        continue\n      }\n      let event = resolution.name\n\n      let supportsMatcher = HookEventCatalog.supportsMatcher(resolution.canonicalName, provider: .gemini)\n      let matcherText = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines)\n      let matcher: String = {\n        if supportsMatcher {\n          return (matcherText?.isEmpty == false ? matcherText! : \"*\")\n        }\n        if matcherText?.isEmpty == false {\n          warnings.append(HookSyncWarning(\n            provider: .gemini,\n            message: \"Gemini hook event \\\"\\(event)\\\" does not support matcher; ignoring matcher for \\\"\\(rule.name)\\\".\"\n          ))\n        }\n        return \"*\"\n      }()\n\n      var hookObjects: [JSONObject] = []\n      for (index, cmd) in rule.commands.enumerated() {\n        let program = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !program.isEmpty else { continue }\n        var hook: JSONObject = [\n          \"name\": \"\\(codMateManagedHookNamePrefix)\\(rule.id):\\(index)\",\n          \"type\": \"command\",\n          \"command\": program,\n        ]\n        if let args = cmd.args, !args.isEmpty { hook[\"args\"] = args }\n        if let timeout = cmd.timeoutMs { hook[\"timeout\"] = timeout }\n        if let env = cmd.env, !env.isEmpty {\n          warnings.append(HookSyncWarning(\n            provider: .gemini,\n            message: \"Gemini CLI hook commands do not support env in settings.json; ignoring env for \\\"\\(rule.name)\\\".\"\n          ))\n        }\n        hookObjects.append(hook)\n      }\n      guard !hookObjects.isEmpty else { continue }\n\n      var entries = (hooks[event] as? [JSONObject]) ?? []\n      if let idx = entries.firstIndex(where: { entry in\n        let existing = (entry[\"matcher\"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)\n        return (existing?.isEmpty == false ? existing : \"*\") == matcher\n      }) {\n        var entry = entries[idx]\n        var nested = (entry[\"hooks\"] as? [JSONObject]) ?? []\n        nested.append(contentsOf: hookObjects)\n        entry[\"hooks\"] = nested\n        entry[\"matcher\"] = matcher\n        entries[idx] = entry\n      } else {\n        entries.append([\n          \"matcher\": matcher,\n          \"hooks\": hookObjects\n        ])\n      }\n      hooks[event] = entries\n    }\n\n    if hooks.isEmpty {\n      object.removeValue(forKey: \"hooks\")\n    } else {\n      object[\"hooks\"] = hooks\n    }\n\n    if !filtered.isEmpty {\n      update(&object, path: [\"tools\", \"enableMessageBusIntegration\"], value: true)\n      update(&object, path: [\"tools\", \"enableHooks\"], value: true)\n    }\n\n    try writeJSONObject(object)\n    return warnings\n  }\n\n  func importHooksAsCodMateRules() -> [HookRule] {\n    let object = loadJSONObject()\n    guard let hooks = object[\"hooks\"] as? JSONObject else { return [] }\n    var rules: [HookRule] = []\n    for (event, value) in hooks {\n      guard let entries = value as? [JSONObject] else { continue }\n      let canonicalEvent = HookEventCatalog.canonicalName(for: event, provider: .gemini)\n      for entry in entries {\n        let matcher = (entry[\"matcher\"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard let hookList = entry[\"hooks\"] as? [JSONObject] else { continue }\n\n        var commands: [HookCommand] = []\n        for hook in hookList {\n          guard (hook[\"type\"] as? String) == \"command\" else { continue }\n          guard let command = hook[\"command\"] as? String else { continue }\n          if command.contains(codMateHookURLPrefix) { continue } // managed by Notifications UI\n          if (hook[\"name\"] as? String) == \"codmate-notify\" { continue }\n          let args = hook[\"args\"] as? [String]\n          let timeout = (hook[\"timeout\"] as? Int) ?? (hook[\"timeout\"] as? NSNumber)?.intValue\n          commands.append(HookCommand(command: command, args: args, env: nil, timeoutMs: timeout))\n        }\n\n        guard !commands.isEmpty else { continue }\n        let name = HookEventCatalog.defaultName(event: canonicalEvent, matcher: matcher, command: commands.first)\n        let targets = HookTargets(codex: false, claude: false, gemini: true)\n        rules.append(HookRule(\n          name: name,\n          event: canonicalEvent,\n          matcher: (matcher?.isEmpty == false ? matcher : nil),\n          commands: commands,\n          enabled: true,\n          targets: targets,\n          source: \"import\"\n        ))\n      }\n    }\n    return rules\n  }\n\n  // MARK: - MCP Servers\n\n  func applyMCPServers(_ servers: [MCPServer]) throws {\n    if !SessionPreferencesStore.isCLIEnabled(.gemini) { return }\n    var object = loadJSONObject()\n    let enabled = servers.enabledServers(for: .gemini)\n    \n    if enabled.isEmpty {\n      object.removeValue(forKey: \"mcpServers\")\n    } else {\n      var mcpServers: JSONObject = [:]\n      for server in enabled {\n        var config: JSONObject = [:]\n        if let command = server.command {\n          config[\"command\"] = command\n        }\n        if let args = server.args, !args.isEmpty {\n          config[\"args\"] = args\n        }\n        if let env = server.env, !env.isEmpty {\n          config[\"env\"] = env\n        }\n        if let url = server.url {\n          config[\"url\"] = url\n        }\n        if let headers = server.headers, !headers.isEmpty {\n          config[\"headers\"] = headers\n        }\n        mcpServers[server.name] = config\n      }\n      object[\"mcpServers\"] = mcpServers\n    }\n    \n    try writeJSONObject(object)\n  }\n\n  // MARK: - Internal helpers\n\n  private func loadJSONObject() -> JSONObject {\n    guard fm.fileExists(atPath: paths.file.path) else { return [:] }\n    guard let text = try? String(contentsOf: paths.file, encoding: .utf8) else { return [:] }\n    if let object = parseJSONObject(from: text) {\n      return object\n    }\n    return [:]\n  }\n\n  private func parseJSONObject(from text: String) -> JSONObject? {\n    if let data = text.data(using: .utf8),\n      let json = try? JSONSerialization.jsonObject(with: data, options: []),\n      let dict = json as? JSONObject\n    {\n      return dict\n    }\n    let stripped = stripComments(from: text)\n    guard let data = stripped.data(using: .utf8),\n      let json = try? JSONSerialization.jsonObject(with: data, options: []),\n      let dict = json as? JSONObject\n    else {\n      return nil\n    }\n    return dict\n  }\n\n  private func writeJSONObject(_ object: JSONObject) throws {\n    try fm.createDirectory(at: paths.directory, withIntermediateDirectories: true)\n    let data = try JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys])\n    try data.write(to: paths.file, options: .atomic)\n  }\n\n  private func setValue(_ value: Any?, at path: [String]) throws {\n    var object = loadJSONObject()\n    update(&object, path: path, value: value)\n    try writeJSONObject(object)\n  }\n\n  private func update(_ object: inout JSONObject, path: [String], value: Any?) {\n    guard let first = path.first else { return }\n    if path.count == 1 {\n      if let value {\n        object[first] = value\n      } else {\n        object.removeValue(forKey: first)\n      }\n      return\n    }\n    var child = object[first] as? JSONObject ?? JSONObject()\n    update(&child, path: Array(path.dropFirst()), value: value)\n    if child.isEmpty {\n      object.removeValue(forKey: first)\n    } else {\n      object[first] = child\n    }\n  }\n\n  private func value(in object: JSONObject, path: [String]) -> Any? {\n    var current: Any? = object\n    for component in path {\n      guard let dict = current as? JSONObject else { return nil }\n      current = dict[component]\n    }\n    return current\n  }\n\n  private func boolValue(in object: JSONObject, path: [String]) -> Bool? {\n    if let v = value(in: object, path: path) as? Bool {\n      return v\n    }\n    if let str = value(in: object, path: path) as? String {\n      return (str as NSString).boolValue\n    }\n    return nil\n  }\n\n  private func stringValue(in object: JSONObject, path: [String]) -> String? {\n    value(in: object, path: path) as? String\n  }\n\n  private func intValue(in object: JSONObject, path: [String]) -> Int? {\n    if let v = value(in: object, path: path) as? Int { return v }\n    if let number = value(in: object, path: path) as? NSNumber { return number.intValue }\n    if let str = value(in: object, path: path) as? String { return Int(str) }\n    return nil\n  }\n\n  private func doubleValue(in object: JSONObject, path: [String]) -> Double? {\n    if let v = value(in: object, path: path) as? Double { return v }\n    if let number = value(in: object, path: path) as? NSNumber { return number.doubleValue }\n    if let str = value(in: object, path: path) as? String { return Double(str) }\n    return nil\n  }\n\n  private func containsCodMateHook(in hooks: JSONObject) -> Bool {\n    guard let entries = hooks[\"Notification\"] as? [JSONObject] else { return false }\n    let marker = \"\\(codMateHookURLPrefix)\\(HookEvent.permission.rawValue)\"\n    for entry in entries {\n      guard let nested = entry[\"hooks\"] as? [JSONObject] else { continue }\n      if nested.contains(where: { ($0[\"command\"] as? String)?.contains(marker) == true }) {\n        return true\n      }\n    }\n    return false\n  }\n\n  private func pruneCodMateManagedHooks(_ hooks: JSONObject) -> JSONObject {\n    var out: JSONObject = [:]\n    for (event, value) in hooks {\n      guard let entries = value as? [JSONObject] else {\n        out[event] = value\n        continue\n      }\n\n      var newEntries: [JSONObject] = []\n      for var entry in entries {\n        guard var nested = entry[\"hooks\"] as? [JSONObject] else {\n          newEntries.append(entry)\n          continue\n        }\n        nested.removeAll { hook in\n          guard let name = hook[\"name\"] as? String else { return false }\n          return name.hasPrefix(codMateManagedHookNamePrefix)\n        }\n        guard !nested.isEmpty else { continue }\n        entry[\"hooks\"] = nested\n        newEntries.append(entry)\n      }\n      if !newEntries.isEmpty {\n        out[event] = newEntries\n      }\n    }\n    return out\n  }\n\n  private func updateNotificationHooksContainer(_ hooks: JSONObject, enabled: Bool) -> JSONObject {\n    var container = hooks\n    var entries = (container[\"Notification\"] as? [JSONObject]) ?? []\n    let marker = \"\\(codMateHookURLPrefix)\\(HookEvent.permission.rawValue)\"\n    entries.removeAll { entry in\n      guard let nested = entry[\"hooks\"] as? [JSONObject] else { return false }\n      return nested.contains { ($0[\"command\"] as? String)?.contains(marker) == true }\n    }\n    if enabled, let urlString = hookURL(for: .permission) {\n      let command = \"/usr/bin/open -j \\\"\\(urlString)\\\"\"\n      entries.append([\n        \"matcher\": \"*\",\n        \"hooks\": [[\n          \"name\": \"codmate-notify\",\n          \"type\": \"command\",\n          \"command\": command\n        ]]\n      ])\n    }\n    if entries.isEmpty {\n      container.removeValue(forKey: \"Notification\")\n    } else {\n      container[\"Notification\"] = entries\n    }\n    return container\n  }\n\n  private func hookURL(for event: HookEvent) -> String? {\n    let payload = hookPayload(for: event)\n    var comps = URLComponents()\n    comps.scheme = \"codmate\"\n    comps.host = \"notify\"\n    var query: [URLQueryItem] = [\n      URLQueryItem(name: \"source\", value: \"gemini\"),\n      URLQueryItem(name: \"event\", value: event.rawValue)\n    ]\n    if let titleData = payload.title.data(using: .utf8) {\n      query.append(URLQueryItem(name: \"title64\", value: titleData.base64EncodedString()))\n    }\n    if let bodyData = payload.body.data(using: .utf8) {\n      query.append(URLQueryItem(name: \"body64\", value: bodyData.base64EncodedString()))\n    }\n    comps.queryItems = query\n    return comps.url?.absoluteString\n  }\n\n  private func hookPayload(for event: HookEvent) -> HookPayload {\n    switch event {\n    case .permission:\n      return HookPayload(\n        title: \"Gemini CLI\",\n        body: \"Gemini requires approval. Return to the Gemini window to respond.\"\n      )\n    }\n  }\n\n  private func stripComments(from text: String) -> String {\n    let scalars = Array(text.unicodeScalars)\n    var result: [UnicodeScalar] = []\n    var index = 0\n    var inString = false\n    var escapeNext = false\n    let quote: UnicodeScalar = \"\\\"\"\n    let slash: UnicodeScalar = \"/\"\n    let newlineScalar = \"\\n\".unicodeScalars.first!\n\n    while index < scalars.count {\n      let scalar = scalars[index]\n\n      if inString {\n        result.append(scalar)\n        if escapeNext {\n          escapeNext = false\n        } else if scalar == \"\\\\\" {\n          escapeNext = true\n        } else if scalar == quote {\n          inString = false\n        }\n        index += 1\n        continue\n      }\n\n      if scalar == quote {\n        inString = true\n        result.append(scalar)\n        index += 1\n        continue\n      }\n\n      if scalar == slash && index + 1 < scalars.count {\n        let next = scalars[index + 1]\n        if next == slash {\n          index += 2\n          while index < scalars.count, scalars[index] != newlineScalar {\n            index += 1\n          }\n          if index < scalars.count {\n            result.append(scalars[index])\n            index += 1\n          }\n          continue\n        } else if next == \"*\" {\n          index += 2\n          while index + 1 < scalars.count {\n            if scalars[index] == \"*\" && scalars[index + 1] == slash {\n              index += 2\n              break\n            }\n            index += 1\n          }\n          continue\n        }\n      }\n\n      result.append(scalar)\n      index += 1\n    }\n\n    return String(String.UnicodeScalarView(result))\n  }\n}\n"
  },
  {
    "path": "services/GeminiUsageAPIClient.swift",
    "content": "import Foundation\nimport Security\n\nstruct GeminiUsageAPIClient {\n  enum ClientError: Error, LocalizedError {\n    case credentialNotFound\n    case keychainAccess(OSStatus)\n    case malformedCredential\n    case missingAccessToken\n    case credentialExpired(Date)\n    case projectNotFound\n    case unsupportedAuthType(String)\n    case requestFailed(Int)\n    case emptyResponse\n    case decodingFailed\n\n    var errorDescription: String? {\n      switch self {\n      case .credentialNotFound:\n        return \"Gemini credential not found.\"\n      case .keychainAccess(let status):\n        return SecCopyErrorMessageString(status, nil) as String?\n      case .malformedCredential:\n        return \"Gemini credential is invalid.\"\n      case .missingAccessToken:\n        return \"Gemini credential is missing an access token.\"\n      case .credentialExpired(let date):\n        let formatter = DateFormatter()\n        formatter.dateStyle = .medium\n        formatter.timeStyle = .short\n        return \"Gemini credential expired on \\(formatter.string(from: date)).\"\n      case .projectNotFound:\n        return \"Gemini project ID not found. For personal Google accounts, try running gemini CLI to complete onboarding. For workspace accounts, set GOOGLE_CLOUD_PROJECT.\"\n      case .unsupportedAuthType(let authType):\n        return \"Gemini \\(authType) auth not supported. Use Google account (OAuth) instead.\"\n      case .requestFailed(let code):\n        return \"Gemini usage API returned status \\(code).\"\n      case .emptyResponse:\n        return \"Gemini usage API returned no data.\"\n      case .decodingFailed:\n        return \"Failed to decode Gemini usage response.\"\n      }\n    }\n  }\n\n  private struct CredentialEnvelope: Decodable {\n    struct Token: Decodable {\n      let accessToken: String\n      let refreshToken: String?\n      let expiresAt: TimeInterval?\n      let tokenType: String?\n      let idToken: String?\n\n      enum CodingKeys: String, CodingKey {\n        case accessToken = \"access_token\"\n        case refreshToken = \"refresh_token\"\n        case expiresAt = \"expiresAt\"  // Try camelCase first\n        case tokenType = \"token_type\"\n        case idToken = \"id_token\"\n      }\n\n      // Memberwise initializer (needed because we have custom init(from:))\n      init(accessToken: String, refreshToken: String?, expiresAt: TimeInterval?, tokenType: String?, idToken: String?) {\n        self.accessToken = accessToken\n        self.refreshToken = refreshToken\n        self.expiresAt = expiresAt\n        self.tokenType = tokenType\n        self.idToken = idToken\n      }\n\n      init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n        accessToken = try container.decode(String.self, forKey: .accessToken)\n        refreshToken = try? container.decode(String.self, forKey: .refreshToken)\n        tokenType = try? container.decode(String.self, forKey: .tokenType)\n        idToken = try? container.decode(String.self, forKey: .idToken)\n\n        // Handle both expiresAt (camelCase) and expiry_date (snake_case)\n        if let expiresAt = try? container.decode(TimeInterval.self, forKey: .expiresAt) {\n          self.expiresAt = expiresAt\n        } else if let expiryDate = try? decoder.container(keyedBy: LegacyCodingKeys.self).decode(TimeInterval.self, forKey: .expiryDate) {\n          self.expiresAt = expiryDate\n        } else {\n          self.expiresAt = nil\n        }\n      }\n\n      private enum LegacyCodingKeys: String, CodingKey {\n        case expiryDate = \"expiry_date\"\n      }\n    }\n\n    let serverName: String?\n    let token: Token\n    let updatedAt: TimeInterval?\n  }\n\n  private struct LoadCodeAssistResponse: Decodable {\n    struct Tier: Decodable {\n      let id: String?\n      let name: String?\n      let isDefault: Bool?\n    }\n\n    struct Project: Decodable {\n      let id: String?\n      let name: String?\n    }\n\n    let currentTier: Tier?\n    let allowedTiers: [Tier]?\n    let cloudaicompanionProject: String?\n    let cloudaicompanionProjectObject: Project?\n\n    init(from decoder: Decoder) throws {\n      let container = try decoder.container(keyedBy: CodingKeys.self)\n\n      currentTier = try? container.decodeIfPresent(Tier.self, forKey: .currentTier)\n      allowedTiers = try? container.decodeIfPresent([Tier].self, forKey: .allowedTiers)\n\n      // Handle cloudaicompanionProject as string or object\n      if let rawString = try? container.decodeIfPresent(String.self, forKey: .cloudaicompanionProject) {\n        self.cloudaicompanionProject = rawString\n        self.cloudaicompanionProjectObject = nil\n      } else if let obj = try? container.decodeIfPresent(Project.self, forKey: .cloudaicompanionProject) {\n        self.cloudaicompanionProject = obj.id ?? obj.name\n        self.cloudaicompanionProjectObject = obj\n      } else {\n        self.cloudaicompanionProject = nil\n        self.cloudaicompanionProjectObject = nil\n      }\n    }\n\n    private enum CodingKeys: String, CodingKey {\n      case currentTier\n      case allowedTiers\n      case cloudaicompanionProject\n    }\n  }\n\n  private struct OnboardUserRequest: Encodable {\n    let tierId: String\n    let cloudaicompanionProject: String?\n    let metadata: [String: String]\n  }\n\n  private struct OnboardUserResponse: Decodable {\n    struct Project: Decodable {\n      let id: String?\n      let name: String?\n    }\n\n    struct ResponseData: Decodable {\n      let cloudaicompanionProject: Project?\n    }\n\n    let done: Bool?\n    let response: ResponseData?\n  }\n\n  private struct QuotaResponse: Decodable {\n    struct Bucket: Decodable {\n      let remainingAmount: String?\n      let remainingFraction: Double?\n      let resetTime: String?\n      let tokenType: String?\n      let modelId: String?\n    }\n\n    let buckets: [Bucket]?\n  }\n\n  private struct OAuthFile: Decodable {\n    let access_token: String?\n    let refresh_token: String?\n    let expiry_date: TimeInterval?\n    let id_token: String?\n  }\n\n  func fetchUsageStatus(now: Date = Date()) async throws -> GeminiUsageStatus {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let authType = currentAuthType(homeDirectory: home)\n    switch authType {\n    case .apiKey:\n      throw ClientError.unsupportedAuthType(\"API key\")\n    case .vertexAI:\n      throw ClientError.unsupportedAuthType(\"Vertex AI\")\n    case .oauthPersonal, .unknown:\n      break\n    }\n\n    var credential = try fetchCredential()\n\n    // Check token expiration and auto-refresh if needed\n    if let expires = credential.token.expiresAt {\n      let expiry = Date(timeIntervalSince1970: expires / 1000)\n      if expiry.addingTimeInterval(-300) < now {\n        NSLog(\"[GeminiUsage] Token expired at \\(expiry), attempting refresh\")\n\n        // Try to refresh the token\n        guard let refreshToken = credential.token.refreshToken else {\n          NSLog(\"[GeminiUsage] No refresh token available\")\n          throw ClientError.credentialExpired(expiry)\n        }\n\n        do {\n          let newToken = try await refreshAccessToken(refreshToken: refreshToken)\n          credential = CredentialEnvelope(\n            serverName: credential.serverName,\n            token: newToken,\n            updatedAt: credential.updatedAt\n          )\n          NSLog(\"[GeminiUsage] Token refreshed successfully\")\n        } catch {\n          NSLog(\"[GeminiUsage] Token refresh failed: \\(error.localizedDescription)\")\n          throw ClientError.credentialExpired(expiry)\n        }\n      }\n    }\n\n    guard !credential.token.accessToken.isEmpty else { throw ClientError.missingAccessToken }\n    let token = credential.token.accessToken\n\n    let projectId = try await resolveProjectId(token: token)\n    if projectId == nil {\n      NSLog(\"[GeminiUsage] No project ID detected; continuing without project\")\n    }\n    let buckets = try await retrieveQuota(token: token, projectId: projectId)\n\n    let claims = extractClaimsFromToken(credential.token.idToken)\n    let userTier = await fetchUserTier(token: token)\n    var planType: String?\n    switch userTier {\n    case .standard:\n      planType = await detectPlanFromStorage(token: token) ?? \"Pro\"\n    case .free:\n      planType = claims.hostedDomain != nil ? \"Workspace\" : \"Free\"\n    case .legacy:\n      planType = \"Legacy\"\n    case .none:\n      planType = await detectPlanFromStorage(token: token) ?? detectPlanFromModels(buckets)\n    }\n\n    let status = GeminiUsageStatus(\n      updatedAt: now,\n      projectId: projectId,\n      buckets: buckets,\n      planType: planType\n    )\n    return status\n  }\n\n  // MARK: - Credential loading\n\n  private func fetchCredential() throws -> CredentialEnvelope {\n    if let file = fetchCredentialFromPlaintextFile() {\n      return file\n    }\n    throw ClientError.credentialNotFound\n  }\n\n  private func fetchCredentialFromPlaintextFile() -> CredentialEnvelope? {\n    let fm = FileManager.default\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    // Prioritize standard Gemini CLI credentials file\n    let paths = [\n      home.appendingPathComponent(\".gemini/oauth_creds.json\"),\n      home.appendingPathComponent(\".gemini/mcp-oauth-tokens-v2.json\"),\n      home.appendingPathComponent(\".gemini/mcp-oauth-tokens.json\")\n    ]\n\n    for url in paths {\n      guard fm.fileExists(atPath: url.path) else { continue }\n      if let data = try? Data(contentsOf: url) {\n        // Try OAuthCredentials shape first\n        if let envelope = try? JSONDecoder().decode(CredentialEnvelope.self, from: data) {\n          return envelope\n        }\n        // Try legacy google creds (oauth_creds.json)\n        if let legacy = try? JSONDecoder().decode(OAuthFile.self, from: data),\n          let token = legacy.access_token\n        {\n          let expires = legacy.expiry_date\n          let tokenObj = CredentialEnvelope.Token(\n            accessToken: token,\n            refreshToken: legacy.refresh_token,\n            expiresAt: expires,\n            tokenType: \"Bearer\",\n            idToken: legacy.id_token\n          )\n          return CredentialEnvelope(serverName: \"legacy\", token: tokenObj, updatedAt: nil)\n        }\n      }\n    }\n    return nil\n  }\n\n  // MARK: - Network\n\n  private func resolveProjectId(token: String) async throws -> String? {\n    let envProject = ProcessInfo.processInfo.environment[\"GOOGLE_CLOUD_PROJECT\"]\n      ?? ProcessInfo.processInfo.environment[\"GOOGLE_CLOUD_PROJECT_ID\"]\n\n    if let discovered = try? await discoverGeminiProjectId(token: token) {\n      return discovered\n    }\n    return envProject\n  }\n\n  private func onboardUser(\n    token: String,\n    tierId: String,\n    cloudaicompanionProject: String?\n  ) async throws -> String? {\n    guard let url = URL(string: \"https://cloudcode-pa.googleapis.com/v1internal:onboardUser\")\n    else {\n      return nil\n    }\n\n    var metadata: [String: String] = [\n      \"ideType\": \"IDE_UNSPECIFIED\",\n      \"platform\": \"PLATFORM_UNSPECIFIED\",\n      \"pluginType\": \"GEMINI\"\n    ]\n    if let project = cloudaicompanionProject, !project.isEmpty {\n      metadata[\"duetProject\"] = project\n    }\n\n    let requestBody = OnboardUserRequest(\n      tierId: tierId,\n      cloudaicompanionProject: cloudaicompanionProject,\n      metadata: metadata\n    )\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.timeoutInterval = 30\n\n    guard let bodyData = try? JSONEncoder().encode(requestBody) else {\n      throw ClientError.requestFailed(-1)\n    }\n    request.httpBody = bodyData\n\n    // Poll until the long-running operation is complete (max 12 attempts = 60 seconds)\n    let maxAttempts = 12\n    var attempts = 0\n\n    while attempts < maxAttempts {\n      let (data, response) = try await URLSession.shared.data(for: request)\n      guard let http = response as? HTTPURLResponse else {\n        throw ClientError.requestFailed(-1)\n      }\n      guard (200..<300).contains(http.statusCode) else {\n        throw ClientError.requestFailed(http.statusCode)\n      }\n\n      guard let result = try? JSONDecoder().decode(OnboardUserResponse.self, from: data) else {\n        throw ClientError.decodingFailed\n      }\n\n      // Check if operation is complete\n      if result.done == true {\n        let projectId = result.response?.cloudaicompanionProject?.id\n          ?? result.response?.cloudaicompanionProject?.name\n        NSLog(\"[GeminiUsage] Onboarding completed, project ID: \\(projectId ?? \"nil\")\")\n        return projectId\n      }\n\n      // Not done yet, wait and retry\n      attempts += 1\n      NSLog(\"[GeminiUsage] Onboarding not complete, attempt \\(attempts)/\\(maxAttempts), retrying in 5s...\")\n      if attempts < maxAttempts {\n        try await Task.sleep(nanoseconds: 5_000_000_000) // 5 seconds\n      }\n    }\n\n    // Polling timeout\n    NSLog(\"[GeminiUsage] Onboarding polling timeout after \\(maxAttempts) attempts\")\n    throw ClientError.requestFailed(-2)\n  }\n\n  private func retrieveQuota(token: String, projectId: String?) async throws -> [GeminiUsageStatus.Bucket] {\n    guard let url = URL(string: \"https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota\") else {\n      return []\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Accept\")\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.setValue(userAgent(), forHTTPHeaderField: \"User-Agent\")\n    request.timeoutInterval = 10\n\n    var body: [String: Any] = [:]\n    if let projectId, !projectId.isEmpty {\n      body[\"project\"] = projectId\n    }\n    request.httpBody = try? JSONSerialization.data(withJSONObject: body)\n\n    let (data, response) = try await URLSession.shared.data(for: request)\n    guard let http = response as? HTTPURLResponse else { throw ClientError.requestFailed(-1) }\n    guard (200..<300).contains(http.statusCode) else { throw ClientError.requestFailed(http.statusCode) }\n\n    // Debug: Log raw quota response\n    if let rawJSON = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {\n      NSLog(\"[GeminiUsage] retrieveUserQuota response: \\(rawJSON)\")\n    }\n\n    guard let payload = try? JSONDecoder().decode(QuotaResponse.self, from: data) else {\n      throw ClientError.decodingFailed\n    }\n\n    let buckets: [GeminiUsageStatus.Bucket] = (payload.buckets ?? []).map { bucket in\n      let reset: Date? = bucket.resetTime.flatMap { resetTimeString in\n        // Try parsing with fractional seconds first\n        let formatterWithFractional = ISO8601DateFormatter()\n        formatterWithFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        if let date = formatterWithFractional.date(from: resetTimeString) {\n          NSLog(\"[GeminiUsage] Parsed resetTime with fractional seconds: \\(resetTimeString) -> \\(date)\")\n          return date\n        }\n\n        // Fallback: try without fractional seconds\n        let formatterWithoutFractional = ISO8601DateFormatter()\n        formatterWithoutFractional.formatOptions = [.withInternetDateTime]\n        if let date = formatterWithoutFractional.date(from: resetTimeString) {\n          NSLog(\"[GeminiUsage] Parsed resetTime without fractional seconds: \\(resetTimeString) -> \\(date)\")\n          return date\n        }\n\n        NSLog(\"[GeminiUsage] Failed to parse resetTime: \\(resetTimeString)\")\n        return nil\n      }\n\n      return GeminiUsageStatus.Bucket(\n        modelId: bucket.modelId,\n        tokenType: bucket.tokenType,\n        remainingFraction: bucket.remainingFraction,\n        remainingAmount: bucket.remainingAmount,\n        resetTime: reset\n      )\n    }\n\n    NSLog(\"[GeminiUsage] Retrieved \\(buckets.count) buckets, \\(buckets.filter { $0.resetTime != nil }.count) have resetTime\")\n    return buckets\n  }\n\n  private func userAgent() -> String {\n    let version = Bundle.main.shortVersionString\n    let platform = ProcessInfo.processInfo.operatingSystemVersionString\n    return \"CodMate/\\(version) (\\(platform))\"\n  }\n\n  // MARK: - Token Refresh\n\n  private struct OAuthClientCredentials {\n    let clientId: String\n    let clientSecret: String\n  }\n\n  private func refreshAccessToken(refreshToken: String) async throws -> CredentialEnvelope.Token {\n    guard let url = URL(string: \"https://oauth2.googleapis.com/token\") else {\n      throw ClientError.decodingFailed\n    }\n\n    guard let oauthCreds = extractOAuthCredentials() else {\n      NSLog(\"[GeminiUsage] Could not extract OAuth credentials from Gemini CLI\")\n      throw ClientError.decodingFailed\n    }\n\n    return try await refreshWithCredentials(\n      clientId: oauthCreds.clientId,\n      clientSecret: oauthCreds.clientSecret,\n      refreshToken: refreshToken,\n      url: url\n    )\n  }\n\n  private func refreshWithCredentials(\n    clientId: String,\n    clientSecret: String,\n    refreshToken: String,\n    url: URL\n  ) async throws -> CredentialEnvelope.Token {\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"application/x-www-form-urlencoded\", forHTTPHeaderField: \"Content-Type\")\n    request.timeoutInterval = 15\n\n    let body = [\n      \"client_id=\\(clientId)\",\n      \"client_secret=\\(clientSecret)\",\n      \"refresh_token=\\(refreshToken)\",\n      \"grant_type=refresh_token\",\n    ].joined(separator: \"&\")\n    request.httpBody = body.data(using: .utf8)\n\n    let (data, response) = try await URLSession.shared.data(for: request)\n\n    guard let httpResponse = response as? HTTPURLResponse else {\n      throw ClientError.decodingFailed\n    }\n\n    guard httpResponse.statusCode == 200 else {\n      NSLog(\"[GeminiUsage] Token refresh failed with status \\(httpResponse.statusCode)\")\n      throw ClientError.requestFailed(httpResponse.statusCode)\n    }\n\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n      let newAccessToken = json[\"access_token\"] as? String\n    else {\n      throw ClientError.decodingFailed\n    }\n\n    // Update local credentials file\n    try updateStoredCredentials(json)\n\n    NSLog(\"[GeminiUsage] Token refreshed successfully\")\n\n    // Construct new Token object\n    let expiresIn = json[\"expires_in\"] as? TimeInterval ?? 3600\n    let newExpiresAt = Date().timeIntervalSince1970 * 1000 + expiresIn * 1000  // Convert to milliseconds\n\n    return CredentialEnvelope.Token(\n      accessToken: newAccessToken,\n      refreshToken: refreshToken,  // refresh_token stays the same\n      expiresAt: newExpiresAt,\n      tokenType: json[\"token_type\"] as? String,\n      idToken: json[\"id_token\"] as? String\n    )\n  }\n\n\n  private func updateStoredCredentials(_ refreshResponse: [String: Any]) throws {\n    let credsPaths = [\n      \"~/.gemini/oauth_creds.json\",\n      \"~/.gemini/mcp-oauth-tokens-v2.json\",\n      \"~/.gemini/mcp-oauth-tokens.json\",\n    ]\n\n    for path in credsPaths {\n      let expandedPath = (path as NSString).expandingTildeInPath\n      let credsURL = URL(fileURLWithPath: expandedPath)\n\n      guard FileManager.default.fileExists(atPath: expandedPath),\n        let existingData = try? Data(contentsOf: credsURL),\n        var json = try? JSONSerialization.jsonObject(with: existingData) as? [String: Any]\n      else {\n        continue\n      }\n\n      // Update access_token and expiry time\n      if let newAccessToken = refreshResponse[\"access_token\"] as? String {\n        json[\"access_token\"] = newAccessToken\n\n        if let expiresIn = refreshResponse[\"expires_in\"] as? TimeInterval {\n          let newExpiresAt = Date().timeIntervalSince1970 * 1000 + expiresIn * 1000\n          // Update both field names for compatibility\n          json[\"expiresAt\"] = newExpiresAt\n          json[\"expiry_date\"] = newExpiresAt\n        }\n\n        // Also update id_token if present in response\n        if let idToken = refreshResponse[\"id_token\"] {\n          json[\"id_token\"] = idToken\n        }\n\n        // Write back to file\n        let updatedData = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)\n        try updatedData.write(to: credsURL, options: .atomic)\n        NSLog(\"[GeminiUsage] Updated credentials file: \\(path)\")\n        return\n      }\n    }\n\n    NSLog(\"[GeminiUsage] Warning: Could not update any credentials file\")\n  }\n\n  // MARK: - OAuth Credentials Extraction\n\n  /// Extract OAuth credentials from installed Gemini CLI\n  /// Follows CodexBar's approach: find gemini binary, resolve symlinks, locate oauth2.js\n  private func extractOAuthCredentials() -> OAuthClientCredentials? {\n    let fm = FileManager.default\n\n    // Step 1: Find gemini binary using CodMate's CLIEnvironment\n    let path = CLIEnvironment.resolvedPATHForCLI()\n    guard let geminiPath = CLIEnvironment.resolveExecutablePath(\"gemini\", path: path) else {\n      NSLog(\"[GeminiUsage] Could not find gemini binary in PATH\")\n      return nil\n    }\n\n    // Step 2: Resolve symlinks to find actual installation directory\n    var realPath = geminiPath\n    if let resolved = try? fm.destinationOfSymbolicLink(atPath: geminiPath) {\n      if resolved.hasPrefix(\"/\") {\n        realPath = resolved\n      } else {\n        realPath = (geminiPath as NSString).deletingLastPathComponent + \"/\" + resolved\n      }\n    }\n\n    // Step 3: Navigate to gemini-cli package root\n    // realPath might be: /opt/homebrew/lib/node_modules/@google/gemini-cli/dist/index.js\n    // We need to get to: /opt/homebrew/lib/node_modules/@google/gemini-cli\n    var baseDir = realPath\n\n    // If realPath ends with .js, go up to package root (remove /dist/index.js or similar)\n    if realPath.hasSuffix(\".js\") {\n      // Remove filename\n      baseDir = (realPath as NSString).deletingLastPathComponent\n      // If we're in dist/, go up one more level to package root\n      if baseDir.hasSuffix(\"/dist\") {\n        baseDir = (baseDir as NSString).deletingLastPathComponent\n      }\n    } else {\n      // realPath is the binary, navigate from bin to package root\n      let binDir = (realPath as NSString).deletingLastPathComponent\n      baseDir = (binDir as NSString).deletingLastPathComponent\n    }\n\n    let oauthFile = \"dist/src/code_assist/oauth2.js\"\n    let possiblePaths = [\n      // Direct path from package root (most common for Homebrew)\n      \"\\(baseDir)/node_modules/@google/gemini-cli-core/\\(oauthFile)\",\n\n      // Alternative nested structures\n      \"\\(baseDir)/libexec/lib/node_modules/@google/gemini-cli-core/\\(oauthFile)\",\n      \"\\(baseDir)/lib/node_modules/@google/gemini-cli-core/\\(oauthFile)\",\n\n      // Bun/npm sibling structure\n      \"\\(baseDir)/../gemini-cli-core/\\(oauthFile)\",\n    ]\n\n    for path in possiblePaths {\n      if let content = try? String(contentsOfFile: path, encoding: .utf8) {\n        NSLog(\"[GeminiUsage] Found oauth2.js at: \\(path)\")\n        if let creds = parseOAuthCredentials(from: content) {\n          NSLog(\"[GeminiUsage] Successfully extracted OAuth credentials from Gemini CLI\")\n          return creds\n        }\n      }\n    }\n\n    NSLog(\"[GeminiUsage] Could not find oauth2.js in any expected location\")\n    return nil\n  }\n\n  /// Parse OAuth credentials from oauth2.js content\n  /// Matches pattern: const OAUTH_CLIENT_ID = '...';\n  private func parseOAuthCredentials(from content: String) -> OAuthClientCredentials? {\n    // Match: const OAUTH_CLIENT_ID = '...';\n    let clientIdPattern = #\"OAUTH_CLIENT_ID\\s*=\\s*['\"]([\\w\\-\\.]+)['\"]\"#\n    let secretPattern = #\"OAUTH_CLIENT_SECRET\\s*=\\s*['\"]([\\w\\-]+)['\"]\"#\n\n    guard let clientIdRegex = try? NSRegularExpression(pattern: clientIdPattern),\n          let secretRegex = try? NSRegularExpression(pattern: secretPattern)\n    else {\n      return nil\n    }\n\n    let range = NSRange(content.startIndex..., in: content)\n\n    guard let clientIdMatch = clientIdRegex.firstMatch(in: content, range: range),\n          let clientIdRange = Range(clientIdMatch.range(at: 1), in: content),\n          let secretMatch = secretRegex.firstMatch(in: content, range: range),\n          let secretRange = Range(secretMatch.range(at: 1), in: content)\n    else {\n      return nil\n    }\n\n    let clientId = String(content[clientIdRange])\n    let clientSecret = String(content[secretRange])\n\n    return OAuthClientCredentials(clientId: clientId, clientSecret: clientSecret)\n  }\n\n  // MARK: - Plan Detection\n\n  /// Detect plan from Google Drive storage quota (most reliable method).\n  /// 2 TB = AI Pro, 30 TB = AI Ultra\n  private func detectPlanFromStorage(token: String) async -> String? {\n    let endpoint = \"https://www.googleapis.com/drive/v3/about?fields=storageQuota\"\n    guard let url = URL(string: endpoint) else { return nil }\n\n    var request = URLRequest(url: url)\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.timeoutInterval = 15\n\n    guard let (data, response) = try? await URLSession.shared.data(for: request),\n          let httpResponse = response as? HTTPURLResponse,\n          httpResponse.statusCode == 200\n    else {\n      return nil\n    }\n\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n          let storageQuota = json[\"storageQuota\"] as? [String: Any],\n          let limitStr = storageQuota[\"limit\"] as? String,\n          let limit = Int64(limitStr)\n    else {\n      return nil\n    }\n\n    // Storage limits for plan detection\n    let storageLimit2TB: Int64 = 2_199_023_255_552\n    let storageLimit30TB: Int64 = 32_985_348_833_280\n\n    // Detect plan based on storage limit\n    if limit >= storageLimit30TB {\n      return \"AI Ultra\"\n    } else if limit >= storageLimit2TB {\n      return \"AI Pro\"\n    }\n\n    return nil\n  }\n\n  /// Detect plan tier based on model access. Users with Pro models have AI Pro or higher.\n  private func detectPlanFromModels(_ buckets: [GeminiUsageStatus.Bucket]) -> String? {\n    // If user has access to any \"pro\" models, they're on a paid tier (AI Pro, AI Ultra, etc.)\n    let hasProModels = buckets.contains { bucket in\n      bucket.modelId?.lowercased().contains(\"pro\") == true\n    }\n    return hasProModels ? \"AI Pro\" : nil\n  }\n\n  // MARK: - Auth type & project discovery (CodexBar-aligned)\n\n  private enum GeminiAuthType: String {\n    case oauthPersonal = \"oauth-personal\"\n    case apiKey = \"api-key\"\n    case vertexAI = \"vertex-ai\"\n    case unknown\n  }\n\n  private enum GeminiUserTierId: String {\n    case free = \"free-tier\"\n    case legacy = \"legacy-tier\"\n    case standard = \"standard-tier\"\n  }\n\n  private func currentAuthType(homeDirectory: URL) -> GeminiAuthType {\n    let settingsURL = homeDirectory.appendingPathComponent(\".gemini/settings.json\")\n    guard let data = try? Data(contentsOf: settingsURL),\n          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n          let security = json[\"security\"] as? [String: Any],\n          let auth = security[\"auth\"] as? [String: Any],\n          let selectedType = auth[\"selectedType\"] as? String\n    else {\n      return .unknown\n    }\n    return GeminiAuthType(rawValue: selectedType) ?? .unknown\n  }\n\n  private func discoverGeminiProjectId(token: String) async throws -> String? {\n    guard let url = URL(string: \"https://cloudresourcemanager.googleapis.com/v1/projects\") else {\n      return nil\n    }\n\n    var request = URLRequest(url: url)\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.timeoutInterval = 10\n\n    let (data, response) = try await URLSession.shared.data(for: request)\n    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }\n\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n          let projects = json[\"projects\"] as? [[String: Any]]\n    else {\n      return nil\n    }\n\n    for project in projects {\n      guard let projectId = project[\"projectId\"] as? String else { continue }\n      if projectId.hasPrefix(\"gen-lang-client\") {\n        return projectId\n      }\n      if let labels = project[\"labels\"] as? [String: String],\n         labels[\"generative-language\"] != nil {\n        return projectId\n      }\n    }\n\n    return nil\n  }\n\n  private func fetchUserTier(token: String) async -> GeminiUserTierId? {\n    guard let url = URL(string: \"https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist\") else {\n      return nil\n    }\n\n    var request = URLRequest(url: url)\n    request.httpMethod = \"POST\"\n    request.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n    request.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n    request.httpBody = Data(\"{\\\"metadata\\\":{\\\"ideType\\\":\\\"GEMINI_CLI\\\",\\\"pluginType\\\":\\\"GEMINI\\\"}}\".utf8)\n    request.timeoutInterval = 10\n\n    let data: Data\n    let response: URLResponse\n    do {\n      (data, response) = try await URLSession.shared.data(for: request)\n    } catch {\n      return nil\n    }\n\n    guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { return nil }\n\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n          let currentTier = json[\"currentTier\"] as? [String: Any],\n          let tierId = currentTier[\"id\"] as? String\n    else {\n      return nil\n    }\n    return GeminiUserTierId(rawValue: tierId)\n  }\n\n  private struct TokenClaims {\n    let email: String?\n    let hostedDomain: String?\n  }\n\n  private func extractClaimsFromToken(_ idToken: String?) -> TokenClaims {\n    guard let token = idToken else { return TokenClaims(email: nil, hostedDomain: nil) }\n    let parts = token.components(separatedBy: \".\")\n    guard parts.count >= 2 else { return TokenClaims(email: nil, hostedDomain: nil) }\n\n    var payload = parts[1]\n      .replacingOccurrences(of: \"-\", with: \"+\")\n      .replacingOccurrences(of: \"_\", with: \"/\")\n    let remainder = payload.count % 4\n    if remainder > 0 {\n      payload += String(repeating: \"=\", count: 4 - remainder)\n    }\n\n    guard let data = Data(base64Encoded: payload, options: .ignoreUnknownCharacters),\n          let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n    else {\n      return TokenClaims(email: nil, hostedDomain: nil)\n    }\n\n    return TokenClaims(\n      email: json[\"email\"] as? String,\n      hostedDomain: json[\"hd\"] as? String\n    )\n  }\n}\n"
  },
  {
    "path": "services/GhosttySessionManager.swift",
    "content": "import Foundation\nimport GhosttyKit\n\n/// Lightweight Ghostty Terminal session manager\n/// Only handles view caching; process management is handled by libghostty\n@MainActor\nfinal class GhosttySessionManager {\n    static let shared = GhosttySessionManager()\n\n    private var scrollViews: [String: TerminalScrollView] = [:]\n    private var accessOrder: [String] = []\n    private let maxSessions = 50\n\n    private init() {}\n\n    func getScrollView(for key: String) -> TerminalScrollView? {\n        touch(key)\n        return scrollViews[key]\n    }\n\n    func setScrollView(_ view: TerminalScrollView, for key: String) {\n        scrollViews[key] = view\n        touch(key)\n        evictIfNeeded()\n    }\n\n    func removeScrollView(for key: String) {\n        scrollViews.removeValue(forKey: key)\n        accessOrder.removeAll { $0 == key }\n    }\n\n    func removeAll() {\n        scrollViews.removeAll()\n        accessOrder.removeAll()\n    }\n\n    private func touch(_ key: String) {\n        accessOrder.removeAll { $0 == key }\n        accessOrder.append(key)\n    }\n\n    private func evictIfNeeded() {\n        while scrollViews.count > maxSessions, let oldest = accessOrder.first {\n            accessOrder.removeFirst()\n            scrollViews.removeValue(forKey: oldest)\n        }\n    }\n\n    /// Check if there is a running process (for close confirmation)\n    @MainActor\n    func hasRunningProcess(for key: String) -> Bool {\n        guard let scrollView = scrollViews[key] else { return false }\n        return scrollView.surfaceView.needsConfirmQuit\n    }\n\n    /// Check if there are any running processes\n    @MainActor\n    func hasAnyRunningProcesses() -> Bool {\n        for scrollView in scrollViews.values {\n            if scrollView.surfaceView.needsConfirmQuit {\n                return true\n            }\n        }\n        return false\n    }\n}\n"
  },
  {
    "path": "services/GitService.swift",
    "content": "import Darwin\nimport Foundation\nimport OSLog\n\n// Actor responsible for interacting with Git in a given working tree.\n// Uses `/usr/bin/env git` and a robust PATH as per CLI integration guidance.\nactor GitService {\n    private static let log = Logger(subsystem: \"ai.codmate.app\", category: \"Git\")\n    struct Change: Identifiable, Sendable, Hashable {\n        enum Kind: String, Sendable { case modified, added, deleted, untracked }\n        let id = UUID()\n        var path: String\n        var staged: Kind?\n        var worktree: Kind?\n    }\n\n    struct FileChange: Identifiable, Sendable, Hashable {\n        let id = UUID()\n        var path: String\n        var statusCode: String\n        var oldPath: String?\n\n        var statusLetter: String {\n            guard let first = statusCode.first else { return \"?\" }\n            return String(first)\n        }\n    }\n\n    struct Repo: Sendable, Hashable {\n        var root: URL\n    }\n\n    struct VisibleFilesResult: Sendable {\n        var paths: [String]\n        var truncated: Bool\n    }\n\n    private static let realHomeDirectory: String = {\n        let fmHome = FileManager.default.homeDirectoryForCurrentUser.path\n        if !fmHome.isEmpty { return fmHome }\n        if let pwDir = getpwuid(getuid())?.pointee.pw_dir {\n            return String(cString: pwDir)\n        }\n        if let envHome = ProcessInfo.processInfo.environment[\"HOME\"], !envHome.isEmpty {\n            return envHome\n        }\n        return NSHomeDirectory()\n    }()\n\n    private let envPATH = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n    private let gitCandidates: [String] = GitService.detectGitCandidates()\n    private var blockedExecutables: Set<String> = []\n    private var lastFailureDescription: String?\n\n    private static func detectGitCandidates() -> [String] {\n        let fm = FileManager.default\n        var seen: Set<String> = []\n        var out: [String] = []\n        func append(_ path: String) {\n            guard !seen.contains(path) else { return }\n            seen.insert(path)\n            if fm.isExecutableFile(atPath: path) {\n                out.append(path)\n            }\n        }\n        let preferred = [\n            \"/Library/Developer/CommandLineTools/usr/bin/git\",\n            \"/Applications/Xcode.app/Contents/Developer/usr/bin/git\",\n            \"/Applications/Xcode-beta.app/Contents/Developer/usr/bin/git\",\n            \"/usr/bin/git\",\n            \"/opt/homebrew/bin/git\",\n            \"/usr/local/bin/git\",\n        ]\n        for path in preferred { append(path) }\n        if !seen.contains(\"/usr/bin/git\") {\n            append(\"/usr/bin/git\")\n        }\n        return out\n    }\n\n    // Discover the git repository root for a directory, or nil if not a repo\n    func repositoryRoot(for directory: URL) async -> Repo? {\n        guard let out = try? await runGit([\"rev-parse\", \"--show-toplevel\"], cwd: directory),\n              out.exitCode == 0\n        else { return nil }\n        let raw = out.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !raw.isEmpty else { return nil }\n        return Repo(root: URL(fileURLWithPath: raw, isDirectory: true))\n    }\n\n    // Aggregate staged/unstaged/untracked status. Optimized to use a single git call.\n    func status(in repo: Repo) async -> [Change] {\n        // Use status --porcelain=v1 -z which provides stable, machine-readable output for all states\n        guard let out = try? await runGit([\"status\", \"--porcelain\", \"-z\"], cwd: repo.root) else {\n            return []\n        }\n        \n        let (stagedF, worktreeF, untrackedF) = Self.parsePorcelainZ(out.stdout)\n        var map: [String: Change] = [:]\n        func ensure(_ p: String) -> Change { \n            if let c = map[p] { return c }\n            let c = Change(path: p, staged: nil, worktree: nil)\n            map[p] = c\n            return c \n        }\n        \n        for (p, k) in stagedF { var c = ensure(p); c.staged = k; map[p] = c }\n        for (p, k) in worktreeF { var c = ensure(p); c.worktree = k; map[p] = c }\n        for p in untrackedF { var c = ensure(p); c.worktree = .untracked; map[p] = c }\n        \n        return Array(map.values).sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }\n    }\n\n    func listVisibleFiles(in repo: Repo, limit: Int) async -> VisibleFilesResult? {\n        let arguments = [\"ls-files\", \"-co\", \"--exclude-standard\", \"-z\"]\n        guard let out = try? await runGit(arguments, cwd: repo.root),\n              out.exitCode == 0\n        else {\n            return nil\n        }\n        let components = out.stdout.split(separator: \"\\0\", omittingEmptySubsequences: false)\n        let maxEntries = limit > 0 ? limit : Int.max\n        var paths: [String] = []\n        paths.reserveCapacity(min(components.count, maxEntries))\n        var truncated = false\n        for component in components {\n            if component.isEmpty { continue }\n            if paths.count >= maxEntries {\n                truncated = true\n                break\n            }\n            paths.append(String(component))\n        }\n        if components.count > maxEntries {\n            truncated = true\n        }\n        return VisibleFilesResult(paths: paths, truncated: truncated)\n    }\n\n    // Minimal parser for `git diff --name-status -z` output.\n    // Handles: M/A/D/T/U and R/C (renames, copies) by attributing to the new path as modified.\n    private static func parseNameStatusZ(_ stdout: String) -> [String: Change.Kind] {\n        var result: [String: Change.Kind] = [:]\n        let tokens = stdout.split(separator: \"\\0\").map(String.init)\n        var i = 0\n        while i < tokens.count {\n            let status = tokens[i]\n            guard i + 1 < tokens.count else { break }\n            let path1 = tokens[i + 1]\n            var pathOut = path1\n            var kind: Change.Kind = .modified\n            // Normalize leading letter\n            let code = status.first.map(String.init) ?? \"M\"\n            switch code {\n            case \"A\": kind = .added\n            case \"D\": kind = .deleted\n            case \"M\", \"T\", \"U\": kind = .modified\n            case \"R\", \"C\":\n                // Renames/Copies provide an extra path; choose the new path when present\n                if i + 2 < tokens.count {\n                    pathOut = tokens[i + 2]\n                    i += 1 // consume the extra path as well below\n                }\n                kind = .modified\n            default:\n                kind = .modified\n            }\n            result[pathOut] = kind\n            i += 2\n        }\n        return result\n    }\n\n    // Parse `git status --porcelain -z` into staged/worktree/untracked sets\n    private static func parsePorcelainZ(_ stdout: String) -> ([String: Change.Kind], [String: Change.Kind], [String]) {\n        let tokens = stdout.split(separator: \"\\0\").map(String.init)\n        var i = 0\n        var staged: [String: Change.Kind] = [:]\n        var worktree: [String: Change.Kind] = [:]\n        var untracked: [String] = []\n        func kind(for code: Character) -> Change.Kind {\n            switch code {\n            case \"A\": return .added\n            case \"D\": return .deleted\n            case \"M\", \"T\", \"U\": return .modified\n            default: return .modified\n            }\n        }\n        while i < tokens.count {\n            let entry = tokens[i]\n            guard entry.count >= 2 else { break }\n            let x = entry.first!  // index\n            let y = entry.dropFirst().first!  // worktree\n            // Format: XY PATH\\0\n            // Renames: XY NEWPATH\\0OLDPATH\\0. We want NEWPATH.\n            // Usually starts at index 3: \"XY \"\n            let path = String(entry.dropFirst(3)) \n            \n            // Check for renames/copies which consume an extra token (the old path)\n            if x == \"R\" || x == \"C\" || y == \"R\" || y == \"C\" {\n                // The current token 'path' is the NEW path.\n                // The NEXT token is the OLD path. We consume it but ignore it for status mapping.\n                if i + 1 < tokens.count { i += 1 }\n            }\n            \n            if x == \"?\" && y == \"?\" {\n                untracked.append(path)\n            } else {\n                if x != \" \" { staged[path] = kind(for: x) }\n                if y != \" \" { worktree[path] = kind(for: y) }\n            }\n            i += 1\n        }\n        return (staged, worktree, untracked)\n    }\n\n    // Unified diff for the file; staged toggles --cached\n    func diff(in repo: Repo, path: String, staged: Bool) async -> String {\n        let args = [\"diff\", staged ? \"--cached\" : \"\", \"--\", path].filter { !$0.isEmpty }\n        if let out = try? await runGit(args, cwd: repo.root) {\n            return out.stdout\n        }\n        return \"\"\n    }\n\n    // Unified diff for all staged changes (index vs HEAD). Large outputs are returned as-is;\n    // callers should truncate if needed before sending to external systems.\n    func stagedUnifiedDiff(in repo: Repo) async -> String {\n        if let out = try? await runGit([\"diff\", \"--cached\"], cwd: repo.root) {\n            return out.stdout\n        }\n        return \"\"\n    }\n\n    // Read file content from the worktree for preview\n    func readFile(in repo: Repo, path: String, maxBytes: Int = 1_000_000) async -> String {\n        let url = repo.root.appendingPathComponent(path)\n        guard let h = try? FileHandle(forReadingFrom: url) else { return \"\" }\n        defer { try? h.close() }\n        let data = try? h.read(upToCount: maxBytes)\n        if let d = data, let s = String(data: d, encoding: .utf8) { return s }\n        return \"\"\n    }\n\n    // Stage/unstage operations\n    func stage(in repo: Repo, paths: [String]) async {\n        guard !paths.isEmpty else { return }\n        // Use -A to ensure deletions are staged as well\n        _ = try? await runGit([\"add\", \"-A\", \"--\"] + paths, cwd: repo.root)\n    }\n\n    func unstage(in repo: Repo, paths: [String]) async {\n        guard !paths.isEmpty else { return }\n        _ = try? await runGit([\"restore\", \"--staged\", \"--\"] + paths, cwd: repo.root)\n    }\n\n    func commit(in repo: Repo, message: String) async -> Int32 {\n        let msg = message.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !msg.isEmpty else { return -1 }\n        let out = try? await runGit([\"commit\", \"-m\", msg], cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    // Discard only worktree (unstaged) changes for specific paths, preserving the index.\n    func discardWorktree(in repo: Repo, paths: [String]) async -> Int32 {\n        guard !paths.isEmpty else { return 0 }\n        let out = try? await runGit([\"restore\", \"--worktree\", \"--\"] + paths, cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    // Discard tracked changes (both index and worktree) for specific paths\n    func discardTracked(in repo: Repo, paths: [String]) async -> Int32 {\n        guard !paths.isEmpty else { return 0 }\n        let out = try? await runGit([\"restore\", \"--staged\", \"--worktree\", \"--\"] + paths, cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    // Remove untracked files for specific paths\n    func cleanUntracked(in repo: Repo, paths: [String]) async -> Int32 {\n        guard !paths.isEmpty else { return 0 }\n        let out = try? await runGit([\"clean\", \"-f\", \"-d\", \"--\"] + paths, cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    // MARK: - History APIs (lightweight)\n    struct Commit: Identifiable, Sendable, Hashable {\n        let id: String            // full SHA\n        let shortId: String       // short SHA\n        let author: String\n        let date: String          // human friendly (relative)\n        let subject: String\n    }\n\n    struct GraphCommit: Identifiable, Sendable, Hashable {\n        let id: String\n        let shortId: String\n        let author: String\n        let date: String\n        let subject: String\n        let parents: [String]   // full SHAs\n        let decorations: [String] // branch/tag names from %D\n    }\n\n    /// Return recent commits for the repository, newest first.\n    func logCommits(in repo: Repo, limit: Int = 200) async -> [Commit] {\n        // Print one commit per line; fields separated by 0x1F (Unit Separator).\n        // Avoid NULs in arguments and output to keep parsing simple and safe.\n        let fmt = \"%H%x1f%h%x1f%an%x1f%ad%x1f%s\"\n        let args = [\n            \"log\",\n            \"--no-color\",\n            \"--date=relative\",\n            \"--pretty=format:\\(fmt)\",\n            \"-n\", String(max(1, limit))\n        ]\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else {\n            return []\n        }\n        let lines = out.stdout.split(separator: \"\\n\")\n        var commits: [Commit] = []\n        commits.reserveCapacity(lines.count)\n        for line in lines {\n            let parts = line.split(separator: \"\\u{001f}\", omittingEmptySubsequences: false).map(String.init)\n            if parts.count >= 5 {\n                commits.append(Commit(id: parts[0], shortId: parts[1], author: parts[2], date: parts[3], subject: parts[4]))\n            }\n        }\n        return commits\n    }\n\n    /// Files changed in a given commit, including change type/status.\n    func filesChanged(in repo: Repo, commitId: String) async -> [FileChange] {\n        // diff-tree gives reliable name-status output, including renames/copies.\n        let args = [\"diff-tree\", \"--no-commit-id\", \"--name-status\", \"-r\", commitId]\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else {\n            return []\n        }\n        var results: [FileChange] = []\n        for rawLine in out.stdout.split(separator: \"\\n\") {\n            let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)\n            if line.isEmpty { continue }\n            let components = line.split(separator: \"\\t\", omittingEmptySubsequences: false).map(String.init)\n            guard let status = components.first, !status.isEmpty else { continue }\n            let code = status\n            if let first = status.first, first == \"R\" || first == \"C\" {\n                // Rename/Copy: expect \"R100\\told\\tnew\"\n                guard components.count >= 3 else { continue }\n                let oldPath = components[1]\n                let newPath = components[2]\n                results.append(FileChange(path: newPath, statusCode: code, oldPath: oldPath))\n            } else {\n                guard components.count >= 2 else { continue }\n                let path = components[1]\n                results.append(FileChange(path: path, statusCode: code, oldPath: nil))\n            }\n        }\n        return results\n    }\n\n    /// Unified diff patch for a specific commit against its first parent.\n    func commitPatch(in repo: Repo, commitId: String) async -> String {\n        // --pretty=format: to suppress commit header; we render header in UI.\n        // --no-ext-diff to avoid external diff tools, --no-color for clean parsing.\n        let args = [\"show\", \"--pretty=format:\", \"--no-ext-diff\", \"--no-color\", commitId]\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return \"\" }\n        return out.stdout\n    }\n\n    /// Unified diff patch for a specific file in a given commit.\n    func filePatch(in repo: Repo, commitId: String, path: String) async -> String {\n        // Restrict git show to a single path; suppress commit header and external diff tools.\n        let args = [\"show\", \"--pretty=format:\", \"--no-ext-diff\", \"--no-color\", commitId, \"--\", path]\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return \"\" }\n        return out.stdout\n    }\n\n    /// Full commit message (subject + body) for a given commit.\n    func commitMessage(in repo: Repo, commitId: String) async -> String {\n        let args = [\"show\", \"-s\", \"--format=%B\", \"--no-color\", commitId]\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return \"\" }\n        return out.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    /// Fetch all remotes (equivalent to `git fetch --all --prune`).\n    func fetchAllRemotes(in repo: Repo) async -> Int32 {\n        let args = [\"fetch\", \"--all\", \"--prune\"]\n        let out = try? await runGit(args, cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    /// Pull the current branch from its upstream in fast-forward mode.\n    func pullCurrentBranch(in repo: Repo) async -> Int32 {\n        // Prefer fast-forward to avoid interactive merges; users can rebase manually if desired.\n        let args = [\"pull\", \"--ff-only\"]\n        let out = try? await runGit(args, cwd: repo.root)\n        return out?.exitCode ?? -1\n    }\n\n    /// Push the current branch to its upstream. If no upstream is configured, attempts to\n    /// set origin/HEAD as the upstream target automatically.\n    func pushCurrentBranch(in repo: Repo) async -> Int32 {\n        // Check whether an upstream is already configured.\n        let upstream = try? await runGit(\n            [\"rev-parse\", \"--abbrev-ref\", \"--symbolic-full-name\", \"@{u}\"],\n            cwd: repo.root\n        )\n        if upstream?.exitCode == 0 {\n            let out = try? await runGit([\"push\"], cwd: repo.root)\n            return out?.exitCode ?? -1\n        } else {\n            // Determine current branch name; fallback to HEAD if detection fails.\n            let branch = try? await runGit([\"rev-parse\", \"--abbrev-ref\", \"HEAD\"], cwd: repo.root)\n            let name = branch?.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n            if let current = name, !current.isEmpty, current != \"HEAD\" {\n                let out = try? await runGit(\n                    [\"push\", \"--set-upstream\", \"origin\", current],\n                    cwd: repo.root\n                )\n                return out?.exitCode ?? -1\n            } else {\n                let out = try? await runGit(\n                    [\"push\", \"--set-upstream\", \"origin\", \"HEAD\"],\n                    cwd: repo.root\n                )\n                return out?.exitCode ?? -1\n            }\n        }\n    }\n\n    /// Full graph-friendly commit list with parents and decorations. Optional inclusion of remotes.\n    /// If `singleRef` is provided, only that ref is listed (overrides other branch toggles).\n    func logGraphCommits(\n        in repo: Repo,\n        limit: Int = 300,\n        skip: Int = 0,\n        includeAllBranches: Bool = true,\n        includeRemoteBranches: Bool = true,\n        singleRef: String? = nil\n    ) async -> [GraphCommit] {\n        var revArgs: [String] = []\n        if let single = singleRef, !single.isEmpty {\n            revArgs = [single]\n        } else if includeAllBranches {\n            revArgs.append(includeRemoteBranches ? \"--all\" : \"--branches\")\n        } else {\n            // default HEAD current branch only; explicit to be safe\n            revArgs.append(\"HEAD\")\n        }\n\n        let fmt = \"%H%x1f%h%x1f%an%x1f%ad%x1f%s%x1f%P%x1f%D\"\n        var args = [\n            \"log\",\n            \"--no-color\",\n            \"--date=relative\",\n            \"--decorate=short\",\n            \"--topo-order\",\n            \"--pretty=format:\\(fmt)\",\n            \"-n\", String(max(1, limit))\n        ]\n        if skip > 0 {\n            args.append(\"--skip=\\(skip)\")\n        }\n        args += revArgs\n\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else {\n            return []\n        }\n        let lines = out.stdout.split(separator: \"\\n\")\n        var list: [GraphCommit] = []\n        list.reserveCapacity(lines.count)\n        for line in lines {\n            let parts = line.split(separator: \"\\u{001f}\", omittingEmptySubsequences: false).map(String.init)\n            if parts.count >= 7 {\n                let parents = parts[5].split(separator: \" \").map(String.init)\n                let decosRaw = parts[6]\n                let decorations: [String] = decosRaw.split(separator: \",\").map { s in\n                    var t = s.trimmingCharacters(in: .whitespaces)\n                    if t.hasPrefix(\"HEAD -> \") { t = String(t.dropFirst(\"HEAD -> \".count)) }\n                    return t\n                }.filter { !$0.isEmpty }\n                list.append(GraphCommit(\n                    id: parts[0], shortId: parts[1], author: parts[2], date: parts[3], subject: parts[4],\n                    parents: parents, decorations: decorations\n                ))\n            }\n        }\n        return list\n    }\n\n    /// Query commit ids whose subject or body match a case-insensitive query using git --grep.\n    func searchCommitIds(\n        in repo: Repo,\n        query: String,\n        includeAllBranches: Bool = true,\n        includeRemoteBranches: Bool = true,\n        singleRef: String? = nil\n    ) async -> Set<String> {\n        let q = query.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !q.isEmpty else { return [] }\n        var revArgs: [String] = []\n        if let single = singleRef, !single.isEmpty {\n            revArgs = [single]\n        } else if includeAllBranches {\n            revArgs.append(includeRemoteBranches ? \"--all\" : \"--branches\")\n        } else {\n            revArgs.append(\"HEAD\")\n        }\n        var args = [\n            \"log\",\n            \"--no-color\",\n            \"--regexp-ignore-case\",\n            \"--grep\", q,\n            \"--pretty=format:%H\",\n            \"-n\", \"10000\"\n        ]\n        args += revArgs\n        guard let out = try? await runGit(args, cwd: repo.root), out.exitCode == 0 else { return [] }\n        let lines = out.stdout.split(separator: \"\\n\").map(String.init)\n        return Set(lines)\n    }\n\n    /// List branches. Returns short names. Optionally include remote branches.\n    func listBranches(in repo: Repo, includeRemoteBranches: Bool = false) async -> [String] {\n        // Local branches\n        let localArgs = [\n            \"for-each-ref\",\n            \"--format=%(refname:short)\",\n            \"refs/heads\"\n        ]\n        var names: [String] = []\n        if let out = try? await runGit(localArgs, cwd: repo.root), out.exitCode == 0 {\n            names.append(contentsOf: out.stdout.split(separator: \"\\n\").map(String.init))\n        }\n        if includeRemoteBranches {\n            let remoteArgs = [\n                \"for-each-ref\",\n                \"--format=%(refname:short)\",\n                \"refs/remotes\"\n            ]\n            if let out = try? await runGit(remoteArgs, cwd: repo.root), out.exitCode == 0 {\n                names.append(contentsOf: out.stdout.split(separator: \"\\n\").map(String.init))\n            }\n        }\n        // De-duplicate and sort natural-ish\n        let unique = Array(Set(names)).filter { !$0.isEmpty }\n        return unique.sorted { $0.localizedStandardCompare($1) == .orderedAscending }\n    }\n\n    // MARK: - Helpers\n    private struct ProcOut { let stdout: String; let stderr: String; let exitCode: Int32 }\n\n    func takeLastFailureDescription() -> String? {\n        let message = lastFailureDescription\n        lastFailureDescription = nil\n        return message\n    }\n\n    private func runGit(_ args: [String], cwd: URL) async throws -> ProcOut {\n        var lastError: ProcOut? = nil\n        let home = Self.realHomeDirectory\n        #if DEBUG\n        Self.log.debug(\"Running git \\(args.joined(separator: \" \"), privacy: .public) in \\(cwd.path, privacy: .public)\")\n        #endif\n\n        let candidates = gitCandidates + [\"/usr/bin/env\"]\n        for path in candidates {\n            if blockedExecutables.contains(path) {\n                continue\n            }\n            let proc = Process()\n            if path == \"/usr/bin/env\" {\n                proc.executableURL = URL(fileURLWithPath: path)\n                proc.arguments = [\"git\"] + args\n            } else {\n                proc.executableURL = URL(fileURLWithPath: path)\n                proc.arguments = args\n            }\n            proc.currentDirectoryURL = cwd\n\n            var env = ProcessInfo.processInfo.environment\n            // Robust PATH for sandboxed process\n            env[\"PATH\"] = envPATH + \":\" + (env[\"PATH\"] ?? \"\")\n            // Avoid invoking pagers or external tools\n            env[\"GIT_PAGER\"] = \"cat\"\n            env[\"GIT_EDITOR\"] = \":\"\n            env[\"GIT_OPTIONAL_LOCKS\"] = \"0\"\n            // Prevent reading global/system configs that may live outside sandbox\n            env[\"GIT_CONFIG_NOSYSTEM\"] = \"0\"\n            let existingConfigCount = Int(env[\"GIT_CONFIG_COUNT\"] ?? \"0\") ?? 0\n            env[\"GIT_CONFIG_COUNT\"] = String(existingConfigCount + 1)\n            env[\"GIT_CONFIG_KEY_\\(existingConfigCount)\"] = \"safe.directory\"\n            env[\"GIT_CONFIG_VALUE_\\(existingConfigCount)\"] = \"*\"\n            env[\"HOME\"] = home\n            if path.contains(\"/CommandLineTools/\") {\n                env[\"DEVELOPER_DIR\"] = \"/Library/Developer/CommandLineTools\"\n            } else if path.contains(\"/Applications/Xcode\") {\n                env[\"DEVELOPER_DIR\"] = \"/Applications/Xcode.app/Contents/Developer\"\n            }\n            proc.environment = env\n\n            let outPipe = Pipe(); proc.standardOutput = outPipe\n            let errPipe = Pipe(); proc.standardError = errPipe\n\n            do {\n                try proc.run()\n            } catch {\n                #if DEBUG\n                Self.log.debug(\"Failed to launch \\(path, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n                #endif\n                if path != \"/usr/bin/env\" {\n                    blockedExecutables.insert(path)\n                }\n                continue\n            }\n            let outData = try outPipe.fileHandleForReading.readToEnd() ?? Data()\n            let errData = try errPipe.fileHandleForReading.readToEnd() ?? Data()\n            proc.waitUntilExit()\n            let stdout = String(data: outData, encoding: .utf8) ?? \"\"\n            let stderr = String(data: errData, encoding: .utf8) ?? \"\"\n            let out = ProcOut(stdout: stdout, stderr: stderr, exitCode: proc.terminationStatus)\n            if out.exitCode == 0 {\n                #if DEBUG\n                Self.log.debug(\"git succeeded via \\(path, privacy: .public)\")\n                #endif\n                return out\n            }\n            #if DEBUG\n            Self.log.debug(\"git via \\(path, privacy: .public) exited with code \\(out.exitCode, privacy: .public)\")\n            #endif\n            if path != \"/usr/bin/env\",\n               out.stderr.contains(\"App Sandbox\") || out.stderr.contains(\"xcrun: error\")\n            {\n                blockedExecutables.insert(path)\n            }\n            lastError = out\n            // Try next candidate\n        }\n        if let e = lastError {\n            Self.log.error(\"git failed: code=\\(e.exitCode, privacy: .public), stderr=\\(e.stderr, privacy: .public)\")\n            let text = e.stderr.isEmpty ? e.stdout : e.stderr\n            lastFailureDescription = text.isEmpty ? \"git exited with code \\(e.exitCode)\" : text\n            return e\n        }\n        let fallback = ProcOut(stdout: \"\", stderr: \"failed to launch git\", exitCode: -1)\n        Self.log.error(\"git failed to launch via all candidates\")\n        lastFailureDescription = \"git failed to launch via all candidates\"\n        return fallback\n    }\n}\n"
  },
  {
    "path": "services/GlobalSearchService.swift",
    "content": "import Foundation\n\n#if canImport(Darwin)\n  import Darwin\n#endif\n\nactor GlobalSearchService {\n  struct Request: Sendable {\n    let term: String\n    let scope: GlobalSearchScope\n    let paths: GlobalSearchPaths\n    let maxMatchesPerFile: Int\n    let batchSize: Int\n    let limit: Int\n\n    init(\n      term: String,\n      scope: GlobalSearchScope,\n      paths: GlobalSearchPaths,\n      maxMatchesPerFile: Int = 3,\n      batchSize: Int = 12,\n      limit: Int = 200\n    ) {\n      self.term = term\n      self.scope = scope.isEmpty ? .all : scope\n      self.paths = paths\n      self.maxMatchesPerFile = max(maxMatchesPerFile, 1)\n      self.batchSize = max(batchSize, 1)\n      self.limit = max(limit, 1)\n    }\n  }\n\n  private let chunkSize = 128 * 1024\n  private let snippetRadius = 90\n  private let fm = FileManager.default\n  private var ripgrepProcess: Process?\n\n  private struct SearchPattern: Sendable {\n    let raw: String\n    let tokens: [String]\n    let ripgrepPattern: String\n    let requiresPCRE: Bool\n\n    func score(in text: String) -> Double {\n      guard !text.isEmpty else { return 0 }\n      if tokens.isEmpty {\n        return scoreSingleToken(in: text)\n      }\n      return scoreMultiToken(in: text)\n    }\n\n    private func scoreSingleToken(in text: String) -> Double {\n      guard let range = text.range(\n        of: raw,\n        options: [.caseInsensitive, .diacriticInsensitive]\n      ) else { return 0 }\n      let offset = text.distance(from: text.startIndex, to: range.lowerBound)\n      let anchorBoost = 1.0 / Double(offset + 1)\n      return min(1.0, 0.5 + anchorBoost * 0.5)\n    }\n\n    private func scoreMultiToken(in text: String) -> Double {\n      let lowered = text.lowercased()\n      let matches = tokens.compactMap { token -> TokenWindow? in\n        guard let range = lowered.range(of: token) else { return nil }\n        let start = lowered.distance(from: lowered.startIndex, to: range.lowerBound)\n        let end = lowered.distance(from: lowered.startIndex, to: range.upperBound)\n        return TokenWindow(start: start, end: end)\n      }\n      guard !matches.isEmpty else { return 0 }\n      let coverage = Double(matches.count) / Double(tokens.count)\n      let minIndex = matches.map(\\.start).min() ?? 0\n      let maxIndex = matches.map(\\.end).max() ?? minIndex\n      let span = max(1, maxIndex - minIndex)\n      let tightness = Double(matches.count) / Double(span + matches.count)\n      var inversions = 0\n      for pair in zip(matches, matches.dropFirst()) {\n        if pair.1.start < pair.0.start { inversions += 1 }\n      }\n      let orderScore = 1.0 - (Double(inversions) / Double(max(1, matches.count - 1)))\n      let anchor = 1.0 / Double(minIndex + 1)\n      let combined = (coverage * 0.35) + (tightness * 0.35) + (orderScore * 0.2) + (anchor * 0.1)\n      return min(1.0, max(0.0, combined))\n    }\n\n    private struct TokenWindow {\n      let start: Int\n      let end: Int\n    }\n  }\n\n  enum RipgrepError: Error {\n    case executableMissing\n    case failed(String)\n  }\n\n  func cancelRipgrep() {\n    ripgrepProcess?.terminate()\n    ripgrepProcess = nil\n  }\n\n  func search(\n    request: Request,\n    onBatch: @Sendable ([GlobalSearchHit]) async -> Void,\n    onProgress: @Sendable (GlobalSearchProgress) async -> Void,\n    onCompletion: @Sendable () async -> Void\n  ) async {\n    let trimmed = request.term.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else {\n      await onCompletion()\n      return\n    }\n    let pattern = buildSearchPattern(for: trimmed)\n\n    let targets = buildTargets(for: request)\n    guard !targets.allPaths.isEmpty else {\n      await onCompletion()\n      return\n    }\n\n    do {\n      try await runRipgrep(\n        pattern: pattern,\n        request: request,\n        targets: targets,\n        onBatch: onBatch,\n        onProgress: onProgress\n      )\n    } catch RipgrepError.executableMissing {\n      await onProgress(\n        .ripgrep(\n          message: \"ripgrep not found, falling back to built-in scanner\",\n          files: 0,\n          matches: 0,\n          finished: false\n        )\n      )\n      await runFallbackScan(\n        pattern: pattern,\n        request: request,\n        targets: targets,\n        onBatch: onBatch\n      )\n      await onProgress(\n        .ripgrep(\n          message: \"Built-in scan finished\",\n          files: 0,\n          matches: 0,\n          finished: true\n        )\n      )\n    } catch {\n      await onProgress(\n        .ripgrep(\n          message: \"\\(error.localizedDescription)\",\n          files: 0,\n          matches: 0,\n          finished: true\n        )\n      )\n    }\n\n    await onCompletion()\n  }\n\n  // MARK: - Ripgrep integration\n\n  private struct SearchTargets {\n    var sessionRoots: [URL]\n    var noteRoot: URL?\n    var projectMetadataRoot: URL?\n    var taskMetadataRoot: URL?\n\n    var allPaths: [URL] {\n      var paths = sessionRoots\n      if let noteRoot { paths.append(noteRoot) }\n      if let projectMetadataRoot { paths.append(projectMetadataRoot) }\n      if let taskMetadataRoot { paths.append(taskMetadataRoot) }\n      return paths\n    }\n  }\n\n  private func buildTargets(for request: Request) -> SearchTargets {\n    var sessions: [URL] = []\n    if request.scope.contains(.sessions) {\n      sessions = request.paths.sessionRoots.filter { directoryAccessible($0) }\n    }\n\n    var noteRoot: URL? = nil\n    if request.scope.contains(.notes),\n      let candidate = request.paths.notesRoot?.resolvingSymlinksInPath(),\n      directoryAccessible(candidate)\n    {\n      noteRoot = candidate\n    }\n\n    var projectRoot: URL? = nil\n    if request.scope.contains(.projects),\n      let candidate = request.paths.projectMetadataRoot?.resolvingSymlinksInPath(),\n      directoryAccessible(candidate)\n    {\n      projectRoot = candidate\n    }\n\n    var taskRoot: URL? = nil\n    if request.scope.contains(.tasks),\n      let candidate = request.paths.taskMetadataRoot?.resolvingSymlinksInPath(),\n      directoryAccessible(candidate)\n    {\n      taskRoot = candidate\n    }\n\n    return SearchTargets(\n      sessionRoots: sessions, noteRoot: noteRoot, projectMetadataRoot: projectRoot, taskMetadataRoot: taskRoot)\n  }\n\n  private func runRipgrep(\n    pattern: SearchPattern,\n    request: Request,\n    targets: SearchTargets,\n    onBatch: @Sendable ([GlobalSearchHit]) async -> Void,\n    onProgress: @Sendable (GlobalSearchProgress) async -> Void\n  ) async throws {\n    let roots = targets.allPaths\n    guard !roots.isEmpty else { return }\n\n    var env = ProcessInfo.processInfo.environment\n    let defaultPath = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n    let existingPath = env[\"PATH\"] ?? ProcessInfo.processInfo.environment[\"PATH\"]\n    env[\"PATH\"] = [defaultPath, existingPath]\n      .compactMap { $0 }\n      .joined(separator: \":\")\n\n    let process = Process()\n    process.environment = env\n    process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n    var args = [\n      \"rg\",\n      \"--json\",\n      \"--ignore-case\",\n      \"--hidden\",\n      \"--follow\",\n      \"--no-heading\",\n      \"--color\",\n      \"never\",\n    ]\n    if pattern.requiresPCRE {\n      args.append(\"--pcre2\")\n    } else {\n      args.append(\"--fixed-strings\")\n    }\n    args.append(pattern.ripgrepPattern)\n    args.append(contentsOf: roots.map { $0.path })\n    process.arguments = args\n\n    let stdout = Pipe()\n    let stderr = Pipe()\n    process.standardOutput = stdout\n    process.standardError = stderr\n\n    do {\n      try process.run()\n    } catch {\n      if (error as NSError).code == ENOENT {\n        throw RipgrepError.executableMissing\n      }\n      throw error\n    }\n\n    ripgrepProcess = process\n\n    var pending: [GlobalSearchHit] = []\n    var delivered = 0\n    var filesProcessed = 0\n    var matchesFound = 0\n    var cancelled = false\n    var terminatedByLimit = false\n\n    await onProgress(\n      .ripgrep(\n        message: \"Searching with ripgrep…\",\n        files: filesProcessed,\n        matches: matchesFound,\n        finished: false\n      )\n    )\n\n    for try await rawLine in stdout.fileHandleForReading.bytes.lines {\n      if Task.isCancelled {\n        cancelled = true\n        process.terminate()\n        break\n      }\n      let line = String(rawLine)\n      guard !line.isEmpty else { continue }\n      guard\n        let event = parseRipgrepEvent(\n          from: line,\n          request: request,\n          targets: targets,\n          pattern: pattern\n        )\n      else { continue }\n      switch event {\n      case .match(let hit):\n        guard delivered < request.limit else {\n          terminatedByLimit = true\n          process.terminate()\n          break\n        }\n        pending.append(hit)\n        delivered += 1\n        matchesFound += 1\n        if pending.count >= request.batchSize {\n          await onBatch(pending)\n          pending.removeAll(keepingCapacity: true)\n        }\n      case .fileEnd:\n        filesProcessed += 1\n        await onProgress(\n          .ripgrep(\n            message: \"Scanned \\(filesProcessed) files\",\n            files: filesProcessed,\n            matches: matchesFound,\n            finished: false\n          )\n        )\n      }\n    }\n\n    if !pending.isEmpty {\n      await onBatch(pending)\n    }\n\n    process.waitUntilExit()\n    ripgrepProcess = nil\n\n    let normalExit = process.terminationReason == .exit && process.terminationStatus == 0\n    if normalExit || cancelled || terminatedByLimit {\n      await onProgress(\n        .ripgrep(\n          message: cancelled\n            ? \"Search cancelled\"\n            : (terminatedByLimit ? \"Reached result limit\" : \"Search finished\"),\n          files: filesProcessed,\n          matches: matchesFound,\n          finished: true,\n          cancelled: cancelled\n        )\n      )\n      return\n    }\n\n    let errData = try? stderr.fileHandleForReading.readToEnd()\n    let message =\n      errData.flatMap { String(data: $0, encoding: .utf8) }\n      ?? \"ripgrep exit code \\(process.terminationStatus)\"\n    throw RipgrepError.failed(message.trimmingCharacters(in: .whitespacesAndNewlines))\n  }\n\n  private enum RipgrepParsedEvent {\n    case match(GlobalSearchHit)\n    case fileEnd\n  }\n\n  private func parseRipgrepEvent(\n    from line: String,\n    request: Request,\n    targets: SearchTargets,\n    pattern: SearchPattern\n  ) -> RipgrepParsedEvent? {\n    guard let data = line.data(using: .utf8),\n      let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n      let type = root[\"type\"] as? String\n    else { return nil }\n\n    switch type {\n    case \"match\":\n      guard let payload = root[\"data\"] as? [String: Any] else { return nil }\n      guard\n        let pathDict = payload[\"path\"] as? [String: Any],\n        let pathText = pathDict[\"text\"] as? String,\n        let linesDict = payload[\"lines\"] as? [String: Any],\n        let lineText = linesDict[\"text\"] as? String,\n        let submatches = payload[\"submatches\"] as? [[String: Any]],\n        let first = submatches.first,\n        let start = first[\"start\"] as? Int,\n        let end = first[\"end\"] as? Int\n      else { return nil }\n      guard let kind = classify(path: pathText, request: request, targets: targets) else {\n        return nil\n      }\n      let snippet: GlobalSearchSnippet\n      if let range = lineText.rangeFromByteOffsets(start: start, end: end) {\n        snippet = GlobalSearchSnippetFactory.snippet(in: lineText, matchRange: range)\n      } else {\n        snippet = GlobalSearchSnippet(text: lineText.sanitizedSnippetText(), highlightRange: nil)\n      }\n      let fileURL = URL(fileURLWithPath: pathText)\n      switch kind {\n      case .session:\n        let fallback = fileURL.deletingPathExtension().lastPathComponent\n        let lineNumber = payload[\"line_number\"] as? Int ?? 0\n        let id = \"\\(pathText)#\\(lineNumber):\\(start)\"\n        let matchScore = Self.combinedScore(for: lineText, pattern: pattern)\n        let hit = GlobalSearchHit(\n          id: id,\n          kind: .session,\n          fileURL: fileURL,\n          snippet: snippet,\n          fallbackTitle: fallback,\n          metadataDate: nil,\n          score: matchScore\n        )\n        return .match(hit)\n      case .note:\n        guard let note = loadNote(at: fileURL) else { return nil }\n        let matchScore = Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: note.updatedAt\n        )\n        let hit = GlobalSearchHit(\n          id: fileURL.path,\n          kind: .note,\n          fileURL: fileURL,\n          snippet: snippet,\n          fallbackTitle: note.title ?? note.id,\n          note: note,\n          metadataDate: note.updatedAt,\n          score: matchScore\n        )\n        return .match(hit)\n      case .project:\n        guard let projectInfo = loadProject(at: fileURL) else { return nil }\n        let matchScore = Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: projectInfo.updatedAt\n        )\n        let hit = GlobalSearchHit(\n          id: fileURL.path,\n          kind: .project,\n          fileURL: fileURL,\n          snippet: snippet,\n          fallbackTitle: projectInfo.project.name,\n          project: projectInfo.project,\n          metadataDate: projectInfo.updatedAt,\n          score: matchScore\n        )\n        return .match(hit)\n      case .task:\n        guard let taskInfo = loadTask(at: fileURL) else { return nil }\n        let matchScore = Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: taskInfo.updatedAt\n        )\n        let hit = GlobalSearchHit(\n          id: fileURL.path,\n          kind: .task,\n          fileURL: fileURL,\n          snippet: snippet,\n          fallbackTitle: taskInfo.title,\n          task: taskInfo,\n          metadataDate: taskInfo.updatedAt,\n          score: matchScore\n        )\n        return .match(hit)\n      }\n    case \"end\":\n      return .fileEnd\n    default:\n      return nil\n    }\n  }\n\n  private func classify(path: String, request: Request, targets: SearchTargets)\n    -> GlobalSearchResultKind?\n  {\n    if let noteRoot = targets.noteRoot?.path.normalizedDirectoryPath,\n      path.hasPrefix(noteRoot)\n    {\n      return request.scope.contains(.notes) ? .note : nil\n    }\n    if let projectRoot = targets.projectMetadataRoot?.path.normalizedDirectoryPath,\n      path.hasPrefix(projectRoot)\n    {\n      return request.scope.contains(.projects) ? .project : nil\n    }\n    if let taskRoot = targets.taskMetadataRoot?.path.normalizedDirectoryPath,\n      path.hasPrefix(taskRoot)\n    {\n      return request.scope.contains(.tasks) ? .task : nil\n    }\n    return request.scope.contains(.sessions) ? .session : nil\n  }\n\n  // MARK: - Fallback scanner\n\n  private func runFallbackScan(\n    pattern: SearchPattern,\n    request: Request,\n    targets: SearchTargets,\n    onBatch: @Sendable ([GlobalSearchHit]) async -> Void\n  ) async {\n    let workItems = fallbackWorkItems(for: request, targets: targets)\n    guard !workItems.isEmpty else { return }\n\n    await withTaskGroup(of: [GlobalSearchHit].self) { group in\n      for item in workItems {\n        group.addTask { [chunkSize, snippetRadius] in\n          if Task.isCancelled { return [] }\n          switch item {\n          case .session(let url):\n            return Self.scanSession(\n              url: url,\n              pattern: pattern,\n              chunkSize: chunkSize,\n              snippetRadius: snippetRadius,\n              maxMatches: request.maxMatchesPerFile\n            )\n          case .note(let url):\n            return Self.scanNote(url: url, pattern: pattern)\n          case .project(let url):\n            return Self.scanProject(url: url, pattern: pattern)\n          case .task(let url):\n            return Self.scanTask(url: url, pattern: pattern)\n          }\n        }\n      }\n      var delivered = 0\n      var pending: [GlobalSearchHit] = []\n      for await var hits in group {\n        if hits.isEmpty { continue }\n        let remaining = request.limit - delivered - pending.count\n        if remaining <= 0 {\n          group.cancelAll()\n          break\n        }\n        if hits.count > remaining { hits = Array(hits.prefix(remaining)) }\n        pending.append(contentsOf: hits)\n        if pending.count >= request.batchSize {\n          delivered += pending.count\n          await onBatch(pending)\n          pending.removeAll(keepingCapacity: true)\n        }\n      }\n      if !pending.isEmpty {\n        await onBatch(pending)\n      }\n    }\n  }\n\n  private enum WorkItem: Hashable {\n    case session(URL)\n    case note(URL)\n    case project(URL)\n    case task(URL)\n  }\n\n  private func fallbackWorkItems(for request: Request, targets: SearchTargets) -> [WorkItem] {\n    var items: [WorkItem] = []\n    var seen = Set<String>()\n\n    if request.scope.contains(.sessions) {\n      for root in targets.sessionRoots {\n        guard\n          let enumerator = fm.enumerator(\n            at: root,\n            includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey],\n            options: [.skipsHiddenFiles]\n          )\n        else { continue }\n        for case let url as URL in enumerator {\n          let ext = url.pathExtension.lowercased()\n          if ext != \"jsonl\" && ext != \"json\" { continue }\n          let path = url.path\n          if seen.contains(path) { continue }\n          seen.insert(path)\n          items.append(.session(url))\n        }\n      }\n    }\n\n    if request.scope.contains(.notes), let noteRoot = targets.noteRoot,\n      let enumerator = fm.enumerator(\n        at: noteRoot,\n        includingPropertiesForKeys: [.isRegularFileKey],\n        options: [.skipsHiddenFiles]\n      )\n    {\n      for case let url as URL in enumerator {\n        if url.pathExtension.lowercased() != \"json\" { continue }\n        let path = url.path\n        if seen.contains(path) { continue }\n        seen.insert(path)\n        items.append(.note(url))\n      }\n    }\n\n    if request.scope.contains(.projects), let metaRoot = targets.projectMetadataRoot,\n      let enumerator = fm.enumerator(\n        at: metaRoot,\n        includingPropertiesForKeys: [.isRegularFileKey],\n        options: [.skipsHiddenFiles]\n      )\n    {\n      for case let url as URL in enumerator {\n        if url.pathExtension.lowercased() != \"json\" { continue }\n        let path = url.path\n        if seen.contains(path) { continue }\n        seen.insert(path)\n        items.append(.project(url))\n      }\n    }\n\n    if request.scope.contains(.tasks), let metaRoot = targets.taskMetadataRoot,\n      let enumerator = fm.enumerator(\n        at: metaRoot,\n        includingPropertiesForKeys: [.isRegularFileKey],\n        options: [.skipsHiddenFiles]\n      )\n    {\n      for case let url as URL in enumerator {\n        if url.pathExtension.lowercased() != \"json\" { continue }\n        let path = url.path\n        if seen.contains(path) { continue }\n        seen.insert(path)\n        items.append(.task(url))\n      }\n    }\n\n    return items\n  }\n\n  // MARK: - Helpers\n\n  private func directoryAccessible(_ url: URL) -> Bool {\n    var isDir: ObjCBool = false\n    guard fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue else {\n      return false\n    }\n    return true\n  }\n\n  private func loadNote(at url: URL) -> SessionNote? {\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder()\n    return try? decoder.decode(SessionNote.self, from: data)\n  }\n\n  private func loadProject(at url: URL) -> (project: Project, updatedAt: Date?)? {\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    guard let meta = try? decoder.decode(ProjectMeta.self, from: data) else { return nil }\n    return (meta.asProject(), meta.updatedAt)\n  }\n\n  private func loadTask(at url: URL) -> CodMateTask? {\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    return try? decoder.decode(CodMateTask.self, from: data)\n  }\n\n  nonisolated private static func scanSession(\n    url: URL,\n    pattern: SearchPattern,\n    chunkSize: Int,\n    snippetRadius: Int,\n    maxMatches: Int\n  ) -> [GlobalSearchHit] {\n    guard let handle = try? FileHandle(forReadingFrom: url) else { return [] }\n    defer { try? handle.close() }\n    var hits: [GlobalSearchHit] = []\n    var carry = \"\"\n    let fallback = url.deletingPathExtension().lastPathComponent\n    let attributes = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))\n    let modDate = attributes?.contentModificationDate\n\n    var eofReached = false\n    while hits.count < maxMatches && !eofReached {\n      if Task.isCancelled { break }\n      autoreleasepool {\n        guard let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty else {\n          eofReached = true\n          return\n        }\n        guard let string = String(data: chunk, encoding: .utf8) else {\n          carry.removeAll(keepingCapacity: false)\n          eofReached = true\n          return\n        }\n        let buffer = carry + string\n        var searchRange = buffer.startIndex..<buffer.endIndex\n        while hits.count < maxMatches,\n          let match = findMatchRange(in: buffer, range: searchRange, pattern: pattern)\n        {\n          let snippet = GlobalSearchSnippetFactory.snippet(\n            in: buffer, matchRange: match, radius: snippetRadius)\n          let offset = buffer.distance(from: buffer.startIndex, to: match.lowerBound)\n          let id = \"\\(url.path)#\\(offset)\"\n          hits.append(\n            GlobalSearchHit(\n              id: id,\n              kind: .session,\n              fileURL: url,\n              snippet: snippet,\n              fallbackTitle: fallback,\n              metadataDate: modDate,\n              score: Self.combinedScore(\n                for: snippet.text,\n                pattern: pattern,\n                metadataDate: modDate\n              )\n            )\n          )\n          searchRange = match.upperBound..<buffer.endIndex\n          if Task.isCancelled { break }\n        }\n        let keepCount = min(buffer.count, max(pattern.raw.count, snippetRadius))\n        carry = String(buffer.suffix(keepCount))\n      }\n      if eofReached { break }\n    }\n    return hits\n  }\n\n  nonisolated private static func scanNote(url: URL, pattern: SearchPattern) -> [GlobalSearchHit] {\n    guard let note = loadNoteStatic(url: url) else { return [] }\n    let combined = [note.title, note.comment].compactMap { $0 }.joined(separator: \"\\n\")\n    guard let range = findMatchRange(in: combined, pattern: pattern) else { return [] }\n    let snippet = GlobalSearchSnippetFactory.snippet(in: combined, matchRange: range)\n    return [\n      GlobalSearchHit(\n        id: url.path,\n        kind: .note,\n        fileURL: url,\n        snippet: snippet,\n        fallbackTitle: note.title ?? note.id,\n        note: note,\n        metadataDate: note.updatedAt,\n        score: Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: note.updatedAt\n        )\n      )\n    ]\n  }\n\n  nonisolated private static func loadNoteStatic(url: URL) -> SessionNote? {\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder()\n    return try? decoder.decode(SessionNote.self, from: data)\n  }\n\n  nonisolated private static func scanProject(url: URL, pattern: SearchPattern) -> [GlobalSearchHit] {\n    guard let data = try? Data(contentsOf: url) else { return [] }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    guard let meta = try? decoder.decode(ProjectMeta.self, from: data) else { return [] }\n    let project = meta.asProject()\n    let fields = [project.name, project.directory, project.overview, project.instructions]\n      .compactMap { $0 }\n      .joined(separator: \"\\n\")\n    guard let range = findMatchRange(in: fields, pattern: pattern) else { return [] }\n    let snippet = GlobalSearchSnippetFactory.snippet(in: fields, matchRange: range)\n    return [\n      GlobalSearchHit(\n        id: url.path,\n        kind: .project,\n        fileURL: url,\n        snippet: snippet,\n        fallbackTitle: project.name,\n        project: project,\n        metadataDate: meta.updatedAt,\n        score: Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: meta.updatedAt\n        )\n      )\n    ]\n  }\n\n  nonisolated private static func scanTask(url: URL, pattern: SearchPattern) -> [GlobalSearchHit] {\n    guard let data = try? Data(contentsOf: url) else { return [] }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    guard let task = try? decoder.decode(CodMateTask.self, from: data) else { return [] }\n    let fields = [task.title, task.description, task.tags.joined(separator: \" \"), task.agentsConfig]\n      .compactMap { $0 }\n      .joined(separator: \"\\n\")\n    guard let range = findMatchRange(in: fields, pattern: pattern) else { return [] }\n    let snippet = GlobalSearchSnippetFactory.snippet(in: fields, matchRange: range)\n    return [\n      GlobalSearchHit(\n        id: url.path,\n        kind: .task,\n        fileURL: url,\n        snippet: snippet,\n        fallbackTitle: task.title,\n        task: task,\n        metadataDate: task.updatedAt,\n        score: Self.combinedScore(\n          for: snippet.text,\n          pattern: pattern,\n          metadataDate: task.updatedAt\n        )\n      )\n    ]\n  }\n}\n\nextension GlobalSearchScope {\n  fileprivate func contains(kind: GlobalSearchResultKind) -> Bool {\n    switch kind {\n    case .session: return contains(.sessions)\n    case .note: return contains(.notes)\n    case .project: return contains(.projects)\n    case .task: return contains(.tasks)\n    }\n  }\n}\n\nextension String {\n  fileprivate var normalizedDirectoryPath: String {\n    if hasSuffix(\"/\") { return self }\n    return self + \"/\"\n  }\n}\n\n// MARK: - Search pattern helpers\n\nextension GlobalSearchService {\n  private func buildSearchPattern(for term: String) -> SearchPattern {\n    let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)\n    let parts = trimmed.split { $0.isWhitespace }\n      .map { String($0) }\n      .filter { !$0.isEmpty }\n    if parts.count <= 1 {\n      return SearchPattern(raw: trimmed, tokens: [], ripgrepPattern: trimmed, requiresPCRE: false)\n    }\n    let escaped = parts.map { NSRegularExpression.escapedPattern(for: $0) }\n    let regex = escaped.map { \"(?=.*\\($0))\" }.joined() + \".*\"\n    return SearchPattern(\n      raw: trimmed,\n      tokens: parts.map { $0.lowercased() },\n      ripgrepPattern: regex,\n      requiresPCRE: true\n    )\n  }\n\n  private static func findMatchRange(\n    in text: String,\n    pattern: SearchPattern\n  ) -> Range<String.Index>? {\n    return findMatchRange(in: text, range: text.startIndex..<text.endIndex, pattern: pattern)\n  }\n\n  private static func findMatchRange(\n    in text: String,\n    range: Range<String.Index>,\n    pattern: SearchPattern\n  ) -> Range<String.Index>? {\n    if pattern.tokens.isEmpty {\n      return text.range(\n        of: pattern.raw,\n        options: [.caseInsensitive, .diacriticInsensitive],\n        range: range\n      )\n    }\n    let lowered = text.lowercased()\n    guard pattern.tokens.allSatisfy({ lowered.contains($0) }) else { return nil }\n    if let first = pattern.tokens.first {\n      return text.range(\n        of: first,\n        options: [.caseInsensitive, .diacriticInsensitive],\n        range: range\n      )\n    }\n    return nil\n  }\n\n  private static func combinedScore(\n    for text: String,\n    pattern: SearchPattern,\n    metadataDate: Date? = nil,\n    positionalBoost: Double = 0\n  ) -> Double {\n    var score = pattern.score(in: text)\n    if let metadataDate {\n      score += recencyBoost(for: metadataDate)\n    }\n    return score + positionalBoost\n  }\n\n  private static func recencyBoost(for date: Date) -> Double {\n    let elapsed = max(0, Date().timeIntervalSince(date))\n    let days = elapsed / 86_400\n    let normalized = max(0, 1 - min(1, days / 30))\n    return normalized * 0.25\n  }\n}\n"
  },
  {
    "path": "services/HooksImportService.swift",
    "content": "import Foundation\n\nenum HooksImportService {\n  static func scan(scope: ExtensionsImportScope) async -> [HookImportCandidate] {\n    switch scope {\n    case .home:\n      return await scanHome()\n    case .project:\n      return []\n    }\n  }\n\n  private static func scanHome() async -> [HookImportCandidate] {\n    var aggregated: [String: HookImportCandidate] = [:]\n\n    // Codex notify -> Stop\n    if SessionPreferencesStore.isCLIEnabled(.codex) {\n      let codex = CodexConfigService()\n      let notify = await codex.getNotifyArray()\n      if let program = notify.first,\n         !program.isEmpty,\n         !program.contains(\"codmate-notify\")\n      {\n        let args = Array(notify.dropFirst())\n        let rule = HookRule(\n          name: HookEventCatalog.defaultName(\n            event: \"Stop\",\n            matcher: nil,\n            command: HookCommand(command: program, args: args.isEmpty ? nil : args)\n          ),\n          event: \"Stop\",\n          commands: [HookCommand(command: program, args: args.isEmpty ? nil : args)],\n          enabled: true,\n          targets: HookTargets(codex: true, claude: false, gemini: false),\n          source: \"import\"\n        )\n        upsertCandidate(\n          into: &aggregated,\n          rule: rule,\n          provider: \"Codex\",\n          sourcePath: CodexConfigService.Paths.default().configURL.path\n        )\n      }\n    }\n\n    // Claude hooks\n    if SessionPreferencesStore.isCLIEnabled(.claude) {\n      let claude = ClaudeSettingsService()\n      let rules = await claude.importHooksAsCodMateRules()\n      for rule in rules {\n        upsertCandidate(\n          into: &aggregated,\n          rule: rule,\n          provider: \"Claude\",\n          sourcePath: ClaudeSettingsService.Paths.default().file.path\n        )\n      }\n    }\n\n    // Gemini hooks\n    if SessionPreferencesStore.isCLIEnabled(.gemini) {\n      let gemini = GeminiSettingsService()\n      let rules = await gemini.importHooksAsCodMateRules()\n      for rule in rules {\n        upsertCandidate(\n          into: &aggregated,\n          rule: rule,\n          provider: \"Gemini\",\n          sourcePath: gemini.settingsFileURL.path\n        )\n      }\n    }\n\n    var candidates = Array(aggregated.values)\n    // Detect name collisions within import list.\n    let nameCounts = Dictionary(grouping: candidates, by: { $0.rule.name.lowercased() })\n      .mapValues { $0.count }\n    for idx in candidates.indices {\n      let key = candidates[idx].rule.name.lowercased()\n      candidates[idx].hasNameCollision = (nameCounts[key] ?? 0) > 1\n    }\n\n    return candidates.sorted { a, b in\n      a.rule.name.localizedCaseInsensitiveCompare(b.rule.name) == .orderedAscending\n    }\n  }\n\n  private static func upsertCandidate(\n    into aggregated: inout [String: HookImportCandidate],\n    rule: HookRule,\n    provider: String,\n    sourcePath: String\n  ) {\n    let signature = hookSignature(rule)\n    let normalizedRule = normalizedImportRule(rule, provider: provider)\n    if var existing = aggregated[signature] {\n      // Merge sources and targets.\n      if !existing.sources.contains(provider) {\n        existing.sources.append(provider)\n      }\n      existing.sourcePaths[provider] = sourcePath\n      existing.rule.targets = mergeTargets(existing.rule.targets, normalizedRule.targets)\n      aggregated[signature] = existing\n    } else {\n      let candidate = HookImportCandidate(\n        id: UUID(),\n        rule: normalizedRule,\n        sources: [provider],\n        sourcePaths: [provider: sourcePath],\n        isSelected: true,\n        hasConflict: false,\n        hasNameCollision: false,\n        resolution: .skip,\n        renameName: normalizedRule.name,\n        signature: signature\n      )\n      aggregated[signature] = candidate\n    }\n  }\n\n  private static func normalizedImportRule(_ rule: HookRule, provider: String) -> HookRule {\n    var normalized = rule\n    normalized.id = UUID().uuidString\n    normalized.source = \"import\"\n    normalized.createdAt = Date()\n    normalized.updatedAt = Date()\n    if normalized.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      normalized.name = HookEventCatalog.defaultName(\n        event: normalized.event,\n        matcher: normalized.matcher,\n        command: normalized.commands.first\n      )\n    }\n    switch provider {\n    case \"Codex\":\n      normalized.targets = HookTargets(codex: true, claude: false, gemini: false)\n    case \"Claude\":\n      normalized.targets = HookTargets(codex: false, claude: true, gemini: false)\n    case \"Gemini\":\n      normalized.targets = HookTargets(codex: false, claude: false, gemini: true)\n    default:\n      break\n    }\n    return normalized\n  }\n\n  private static func mergeTargets(_ lhs: HookTargets?, _ rhs: HookTargets?) -> HookTargets? {\n    let a = lhs ?? HookTargets()\n    let b = rhs ?? HookTargets()\n    let merged = HookTargets(\n      codex: a.codex || b.codex,\n      claude: a.claude || b.claude,\n      gemini: a.gemini || b.gemini\n    )\n    return merged.allEnabled ? nil : merged\n  }\n\n  static func hookSignature(_ rule: HookRule) -> String {\n    let event = rule.event.trimmingCharacters(in: .whitespacesAndNewlines)\n    let matcher = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n\n    let commands = rule.commands.map { cmd in\n      let command = cmd.command.trimmingCharacters(in: .whitespacesAndNewlines)\n      let args = (cmd.args ?? []).map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.joined(separator: \"\\u{1f}\")\n      let envPairs = (cmd.env ?? [:]).sorted(by: { $0.key < $1.key })\n        .map { \"\\($0.key)=\\($0.value)\" }\n        .joined(separator: \"\\u{1f}\")\n      let timeout = cmd.timeoutMs.map(String.init) ?? \"\"\n      return [command, args, envPairs, timeout].joined(separator: \"\\u{1e}\")\n    }\n    return ([event, matcher] + commands).joined(separator: \"\\u{1d}\")\n  }\n}\n\n"
  },
  {
    "path": "services/HooksStore.swift",
    "content": "import Foundation\n\nactor HooksStore {\n  struct Paths { let home: URL; let fileURL: URL }\n\n  static func defaultPaths(fileManager: FileManager = .default) -> Paths {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n      .appendingPathComponent(\".codmate\", isDirectory: true)\n    return Paths(home: home, fileURL: home.appendingPathComponent(\"hooks.json\", isDirectory: false))\n  }\n\n  private let fm: FileManager\n  private let paths: Paths\n  private var cache: [HookRule]? = nil\n\n  init(paths: Paths = HooksStore.defaultPaths(), fileManager: FileManager = .default) {\n    self.paths = paths\n    self.fm = fileManager\n  }\n\n  func list() -> [HookRule] { load() }\n\n  func upsert(_ rule: HookRule) throws {\n    var list = load()\n    if let idx = list.firstIndex(where: { $0.id == rule.id }) {\n      list[idx] = rule\n    } else {\n      list.append(rule)\n    }\n    try save(list)\n  }\n\n  func upsertMany(_ rules: [HookRule]) throws {\n    var map: [String: HookRule] = [:]\n    for item in load() { map[item.id] = item }\n    for item in rules { map[item.id] = item }\n    // Preserve stable ordering by updatedAt, then name.\n    let sorted = map.values.sorted { lhs, rhs in\n      if lhs.updatedAt != rhs.updatedAt { return lhs.updatedAt > rhs.updatedAt }\n      return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending\n    }\n    try save(sorted)\n  }\n\n  func delete(id: String) throws {\n    var list = load()\n    list.removeAll { $0.id == id }\n    try save(list)\n  }\n\n  func update(id: String, mutate: (inout HookRule) -> Void) throws {\n    var list = load()\n    guard let idx = list.firstIndex(where: { $0.id == id }) else { return }\n    var updated = list[idx]\n    mutate(&updated)\n    list[idx] = updated\n    try save(list)\n  }\n\n  // MARK: - Private\n\n  private func load() -> [HookRule] {\n    if let cache { return cache }\n    guard let data = try? Data(contentsOf: paths.fileURL) else {\n      cache = []\n      return []\n    }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    if let list = try? decoder.decode([HookRule].self, from: data) {\n      cache = list\n      return list\n    }\n    cache = []\n    return []\n  }\n\n  private func save(_ list: [HookRule]) throws {\n    try fm.createDirectory(at: paths.home, withIntermediateDirectories: true)\n    let tmp = paths.fileURL.appendingPathExtension(\"tmp\")\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n    encoder.dateEncodingStrategy = .iso8601\n    let data = try encoder.encode(list)\n    try data.write(to: tmp, options: .atomic)\n    if fm.fileExists(atPath: paths.fileURL.path) { try fm.removeItem(at: paths.fileURL) }\n    try fm.moveItem(at: tmp, to: paths.fileURL)\n    cache = list\n  }\n}\n\n"
  },
  {
    "path": "services/HooksSyncService.swift",
    "content": "import Foundation\n\nactor HooksSyncService {\n  func syncGlobal(rules: [HookRule]) async -> [HookSyncWarning] {\n    var warnings: [HookSyncWarning] = []\n\n    if SessionPreferencesStore.isCLIEnabled(.codex) {\n      let service = CodexConfigService()\n      do {\n        warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules))\n      } catch {\n        warnings.append(HookSyncWarning(provider: .codex, message: \"Failed to apply hooks: \\(error.localizedDescription)\"))\n      }\n    }\n\n    if SessionPreferencesStore.isCLIEnabled(.claude) {\n      let service = ClaudeSettingsService()\n      do {\n        warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules))\n      } catch {\n        warnings.append(HookSyncWarning(provider: .claude, message: \"Failed to apply hooks: \\(error.localizedDescription)\"))\n      }\n    }\n\n    if SessionPreferencesStore.isCLIEnabled(.gemini) {\n      let service = GeminiSettingsService()\n      do {\n        warnings.append(contentsOf: try await service.applyHooksFromCodMate(rules))\n      } catch {\n        warnings.append(HookSyncWarning(provider: .gemini, message: \"Failed to apply hooks: \\(error.localizedDescription)\"))\n      }\n    }\n\n    return warnings\n  }\n}\n"
  },
  {
    "path": "services/InternalSkillRunner.swift",
    "content": "import Foundation\n\nstruct SkillRunResult: Sendable {\n  var outputText: String\n  var stderrText: String\n  var exitCode: Int32\n}\n\nenum InternalSkillRunnerError: LocalizedError {\n  case missingSkill\n  case missingInvocation\n  case invalidInput\n  case executionFailed(String)\n  case outputMissing(String)\n\n  var errorDescription: String? {\n    switch self {\n    case .missingSkill:\n      return \"Internal skill not available.\"\n    case .missingInvocation:\n      return \"No CLI invocation is configured for this provider.\"\n    case .invalidInput:\n      return \"Failed to build skill input.\"\n    case .executionFailed(let message):\n      return \"Skill execution failed: \\(message)\"\n    case .outputMissing(let details):\n      return \"Skill did not return any output.\\n\\(details)\"\n    }\n  }\n}\n\nactor InternalSkillRunner {\n  private let registry = InternalSkillsRegistry()\n  private let docsService = WizardDocsService()\n\n  func run(\n    feature: WizardFeature,\n    provider: SessionSource.Kind,\n    conversation: [WizardMessage],\n    defaultExecutable: String,\n    progress: @escaping (WizardRunEvent) -> Void\n  ) async throws -> SkillRunResult {\n    guard let skill = await registry.skill(for: feature) else {\n      throw InternalSkillRunnerError.missingSkill\n    }\n    guard let invocation = skill.definition.invocations.first(where: { $0.provider == provider }) else {\n      throw InternalSkillRunnerError.missingInvocation\n    }\n\n    let input = try await buildInput(\n      feature: feature,\n      provider: provider,\n      conversation: conversation,\n      skill: skill\n    )\n\n    let tempRoot = FileManager.default.temporaryDirectory\n      .appendingPathComponent(\"codmate-skill-\\(UUID().uuidString)\", isDirectory: true)\n    try FileManager.default.createDirectory(at: tempRoot, withIntermediateDirectories: true)\n\n    let skillDir = tempRoot.appendingPathComponent(\"skill\", isDirectory: true)\n    try FileManager.default.createDirectory(at: skillDir, withIntermediateDirectories: true)\n\n    try writeAssets(skill, to: skillDir)\n\n    let inputURL = tempRoot.appendingPathComponent(\"input.json\")\n    let outputURL = tempRoot.appendingPathComponent(\"output.json\")\n\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n    encoder.dateEncodingStrategy = .iso8601\n    let data = try encoder.encode(input)\n    try data.write(to: inputURL, options: .atomic)\n    let promptText = buildPromptText(payloadData: data)\n    let promptData = promptText.data(using: .utf8) ?? data\n\n    let args = resolveArgs(\n      invocation.args,\n      skillDir: skillDir,\n      inputFile: inputURL,\n      outputFile: outputURL,\n      schemaFile: skillDir.appendingPathComponent(\"schema.json\"),\n      promptFile: skillDir.appendingPathComponent(\"prompt.md\"),\n      skillFile: skillDir.appendingPathComponent(\"SKILL.md\")\n    )\n\n    let workingDirectory = InternalWizardPaths.ensureProjectRootExists()\n    await MainActor.run { progress(WizardRunEvent(message: \"Invoking CLI skill\", kind: .status)) }\n\n    let exec = invocation.executable?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let executable = (exec?.isEmpty == false) ? exec! : defaultExecutable\n\n    let result = try await runProcess(\n      executable: executable,\n      args: args,\n      input: invocation.inputMode == .stdin ? promptData : nil,\n      timeout: invocation.timeoutSeconds,\n      workingDirectory: workingDirectory,\n      progress: progress\n    )\n\n    if result.exitCode != 0 {\n      let debug = formatDebugReport(\n        executable: executable,\n        args: args,\n        workingDirectory: workingDirectory,\n        result: result,\n        outputFile: invocation.outputMode == .file ? outputURL : nil\n      )\n      throw InternalSkillRunnerError.executionFailed(debug)\n    }\n\n    let outputText: String\n    switch invocation.outputMode {\n    case .stdout:\n      outputText = result.outputText\n    case .file:\n      outputText = (try? String(contentsOf: outputURL, encoding: .utf8)) ?? \"\"\n    }\n\n    if outputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      let debug = formatDebugReport(\n        executable: executable,\n        args: args,\n        workingDirectory: workingDirectory,\n        result: result,\n        outputFile: invocation.outputMode == .file ? outputURL : nil\n      )\n      throw InternalSkillRunnerError.outputMissing(debug)\n    }\n\n    return SkillRunResult(outputText: outputText, stderrText: result.stderrText, exitCode: result.exitCode)\n  }\n\n  // MARK: - Input Build\n\n  private struct WizardSkillInput: Codable {\n    var feature: WizardFeature\n    var provider: String\n    var appLanguage: String\n    var appLanguageName: String\n    var request: String\n    var conversation: [WizardMessage]\n    var schema: String?\n    var prompt: String?\n    var catalogs: [String: [String]]?\n    var docs: [WizardDocSnippet]\n  }\n\n  private func buildInput(\n    feature: WizardFeature,\n    provider: SessionSource.Kind,\n    conversation: [WizardMessage],\n    skill: InternalSkillAsset\n  ) async throws -> WizardSkillInput {\n    guard let last = conversation.last(where: { $0.role == .user }) else {\n      throw InternalSkillRunnerError.invalidInput\n    }\n    let language = resolveAppLanguage()\n\n    let catalogs = buildCatalogs(for: feature)\n    let keywords = catalogs.flatMap { $0.value }\n    let docs = await docsService.snippets(\n      feature: feature,\n      provider: provider,\n      overrides: skill.docsOverrides,\n      keywords: keywords\n    )\n\n    return WizardSkillInput(\n      feature: feature,\n      provider: provider.rawValue,\n      appLanguage: language.code,\n      appLanguageName: language.name,\n      request: last.text,\n      conversation: conversation,\n      schema: skill.schema,\n      prompt: skill.prompt,\n      catalogs: catalogs.isEmpty ? nil : catalogs,\n      docs: docs\n    )\n  }\n\n  private func buildCatalogs(for feature: WizardFeature) -> [String: [String]] {\n    switch feature {\n    case .hooks:\n      let events = HookEventCatalog.all.map { $0.name }\n      let vars = HookCommandVariableCatalog.all.map { $0.name }\n      return [\"events\": events, \"variables\": vars]\n    default:\n      return [:]\n    }\n  }\n\n  // MARK: - Assets\n\n  private func writeAssets(_ skill: InternalSkillAsset, to dir: URL) throws {\n    if let skillMarkdown = skill.skillMarkdown {\n      try skillMarkdown.write(to: dir.appendingPathComponent(\"SKILL.md\"), atomically: true, encoding: .utf8)\n    }\n    if let prompt = skill.prompt {\n      try prompt.write(to: dir.appendingPathComponent(\"prompt.md\"), atomically: true, encoding: .utf8)\n    }\n    if let schema = skill.schema {\n      try schema.write(to: dir.appendingPathComponent(\"schema.json\"), atomically: true, encoding: .utf8)\n    }\n  }\n\n  private func buildPromptText(payloadData: Data) -> String {\n    let payload = String(data: payloadData, encoding: .utf8) ?? \"{}\"\n    return \"\"\"\n    You are a CodMate internal wizard skill.\n    Follow the instructions in the JSON payload field \"prompt\".\n    All user-facing text must use the language specified by payload.appLanguage (BCP-47 code)\n    and payload.appLanguageName (English name of the language).\n    You do not have tool access. Do not invoke tools, shell commands, or web browsing.\n    Use the payload field \"schema\" as the required JSON Schema for your output.\n    Use \"docs\" and \"catalogs\" for reference and \"conversation\" for context.\n    If the request is unclear, set mode=\"question\" and provide follow-up questions.\n    Return only JSON. Do not include markdown or extra text.\n\n    JSON payload:\n    \\(payload)\n    \"\"\"\n  }\n\n  private func resolveAppLanguage() -> (code: String, name: String) {\n    let preferred = Bundle.main.preferredLocalizations.first ?? Locale.preferredLanguages.first ?? \"en\"\n    let locale = Locale(identifier: preferred)\n    let languageCode = locale.language.languageCode?.identifier ?? preferred\n    let englishName = Locale(identifier: \"en\").localizedString(forLanguageCode: languageCode) ?? preferred\n    return (preferred, englishName)\n  }\n\n  // MARK: - Args\n\n  private func resolveArgs(\n    _ args: [String],\n    skillDir: URL,\n    inputFile: URL,\n    outputFile: URL,\n    schemaFile: URL,\n    promptFile: URL,\n    skillFile: URL\n  ) -> [String] {\n    args.map { raw in\n      raw\n        .replacingOccurrences(of: \"{{skillDir}}\", with: skillDir.path)\n        .replacingOccurrences(of: \"{{inputFile}}\", with: inputFile.path)\n        .replacingOccurrences(of: \"{{outputFile}}\", with: outputFile.path)\n        .replacingOccurrences(of: \"{{schemaFile}}\", with: schemaFile.path)\n        .replacingOccurrences(of: \"{{promptFile}}\", with: promptFile.path)\n        .replacingOccurrences(of: \"{{skillFile}}\", with: skillFile.path)\n    }\n  }\n\n  private func formatDebugReport(\n    executable: String,\n    args: [String],\n    workingDirectory: URL,\n    result: ProcessResult,\n    outputFile: URL?\n  ) -> String {\n    let commandLine = ([executable] + args).joined(separator: \" \")\n    let stderr = truncate(result.stderrText, limit: 8000)\n    let stdout = truncate(result.outputText, limit: 8000)\n    var fileOutput = \"\"\n    if let outputFile,\n       let text = try? String(contentsOf: outputFile, encoding: .utf8),\n       !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      fileOutput = truncate(text, limit: 8000)\n    }\n\n    var lines: [String] = []\n    lines.append(\"Command: \\(commandLine)\")\n    lines.append(\"Workdir: \\(workingDirectory.path)\")\n    lines.append(\"Exit code: \\(result.exitCode)\")\n    if !stderr.isEmpty {\n      lines.append(\"\")\n      lines.append(\"STDERR:\")\n      lines.append(stderr)\n    }\n    if !stdout.isEmpty {\n      lines.append(\"\")\n      lines.append(\"STDOUT:\")\n      lines.append(stdout)\n    }\n    if !fileOutput.isEmpty {\n      lines.append(\"\")\n      lines.append(\"OUTPUT FILE:\")\n      lines.append(fileOutput)\n    }\n    return lines.joined(separator: \"\\n\")\n  }\n\n  private func truncate(_ text: String, limit: Int) -> String {\n    guard text.count > limit else { return text }\n    let head = text.prefix(limit)\n    return \"\\(head)\\n…(truncated)\"\n  }\n\n  // MARK: - Process\n\n  private struct ProcessResult {\n    var outputText: String\n    var stderrText: String\n    var exitCode: Int32\n  }\n\n  private enum OutputStream {\n    case stdout\n    case stderr\n  }\n\n  private final class OutputCollector {\n    private let lock = NSLock()\n    private var stdoutText: String = \"\"\n    private var stderrText: String = \"\"\n    private var stdoutRemainder: String = \"\"\n    private var stderrRemainder: String = \"\"\n\n    func append(_ data: Data, stream: OutputStream) -> [String] {\n      let text = String(decoding: data, as: UTF8.self)\n      guard !text.isEmpty else { return [] }\n      lock.lock()\n      defer { lock.unlock() }\n      switch stream {\n      case .stdout:\n        stdoutText += text\n        return splitLines(text, remainder: &stdoutRemainder)\n      case .stderr:\n        stderrText += text\n        return splitLines(text, remainder: &stderrRemainder)\n      }\n    }\n\n    func flush(stream: OutputStream) -> String? {\n      lock.lock()\n      defer { lock.unlock() }\n      switch stream {\n      case .stdout:\n        guard !stdoutRemainder.isEmpty else { return nil }\n        let line = stdoutRemainder\n        stdoutRemainder = \"\"\n        return line\n      case .stderr:\n        guard !stderrRemainder.isEmpty else { return nil }\n        let line = stderrRemainder\n        stderrRemainder = \"\"\n        return line\n      }\n    }\n\n    func snapshot() -> (stdout: String, stderr: String) {\n      lock.lock()\n      defer { lock.unlock() }\n      return (stdoutText, stderrText)\n    }\n\n    private func splitLines(_ text: String, remainder: inout String) -> [String] {\n      let combined = remainder + text\n      let parts = combined.split(omittingEmptySubsequences: false, whereSeparator: \\.isNewline)\n      if combined.hasSuffix(\"\\n\") || combined.hasSuffix(\"\\r\") {\n        remainder = \"\"\n        return parts.map(String.init)\n      }\n      if let last = parts.last {\n        remainder = String(last)\n        return parts.dropLast().map(String.init)\n      }\n      remainder = combined\n      return []\n    }\n  }\n\n  private func runProcess(\n    executable: String,\n    args: [String],\n    input: Data?,\n    timeout: Double?,\n    workingDirectory: URL?,\n    progress: @escaping (WizardRunEvent) -> Void\n  ) async throws -> ProcessResult {\n    let process = Process()\n    var env = ProcessInfo.processInfo.environment\n    let defaultPath = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n    let existingPath = env[\"PATH\"]\n    env[\"PATH\"] = [defaultPath, existingPath]\n      .compactMap { $0?.isEmpty == false ? $0 : nil }\n      .joined(separator: \":\")\n    process.environment = env\n    process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n    process.arguments = [executable] + args\n    if let workingDirectory {\n      process.currentDirectoryURL = workingDirectory\n    }\n\n    let stdout = Pipe()\n    let stderr = Pipe()\n    process.standardOutput = stdout\n    process.standardError = stderr\n    let collector = OutputCollector()\n\n    let emit: (WizardRunEvent) -> Void = { event in\n      DispatchQueue.main.async {\n        progress(event)\n      }\n    }\n\n    let emitLines: @Sendable (_ lines: [String], _ kind: WizardRunEvent.Kind) -> Void = { lines, kind in\n      for line in lines {\n        if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }\n        emit(WizardRunEvent(message: line, kind: kind))\n      }\n    }\n\n    stdout.fileHandleForReading.readabilityHandler = { handle in\n      let data = handle.availableData\n      if data.isEmpty {\n        handle.readabilityHandler = nil\n        return\n      }\n      let lines = collector.append(data, stream: .stdout)\n      emitLines(lines, .stdout)\n    }\n\n    stderr.fileHandleForReading.readabilityHandler = { handle in\n      let data = handle.availableData\n      if data.isEmpty {\n        handle.readabilityHandler = nil\n        return\n      }\n      let lines = collector.append(data, stream: .stderr)\n      emitLines(lines, .stderr)\n    }\n\n    if input != nil {\n      let stdin = Pipe()\n      process.standardInput = stdin\n      stdin.fileHandleForWriting.writeabilityHandler = { handle in\n        handle.write(input!)\n        handle.closeFile()\n        stdin.fileHandleForWriting.writeabilityHandler = nil\n      }\n    }\n\n    try process.run()\n\n    if let timeout {\n      Task.detached {\n        try? await Task.sleep(nanoseconds: UInt64(timeout * 1_000_000_000))\n        if process.isRunning {\n          process.terminate()\n        }\n      }\n    }\n\n    process.waitUntilExit()\n\n    stdout.fileHandleForReading.readabilityHandler = nil\n    stderr.fileHandleForReading.readabilityHandler = nil\n\n    if let remainingOut = try? stdout.fileHandleForReading.readToEnd(), !remainingOut.isEmpty {\n      let lines = collector.append(remainingOut, stream: .stdout)\n      emitLines(lines, .stdout)\n    }\n    if let remainingErr = try? stderr.fileHandleForReading.readToEnd(), !remainingErr.isEmpty {\n      let lines = collector.append(remainingErr, stream: .stderr)\n      emitLines(lines, .stderr)\n    }\n    if let last = collector.flush(stream: .stdout) {\n      emitLines([last], .stdout)\n    }\n    if let last = collector.flush(stream: .stderr) {\n      emitLines([last], .stderr)\n    }\n\n    let snapshot = collector.snapshot()\n    return ProcessResult(outputText: snapshot.stdout, stderrText: snapshot.stderr, exitCode: process.terminationStatus)\n  }\n}\n"
  },
  {
    "path": "services/InternalSkillsRegistry.swift",
    "content": "import Foundation\n\nactor InternalSkillsRegistry {\n  private struct IndexedSkill: Hashable {\n    let definition: InternalSkillDefinition\n    let rootURL: URL\n  }\n\n  private let fileManager = FileManager.default\n  private var cached: [WizardFeature: [IndexedSkill]] = [:]\n\n  func skill(for feature: WizardFeature) -> InternalSkillAsset? {\n    let list = loadSkills(for: feature)\n    return list.first.map { materialize($0) }\n  }\n\n  func skills(for feature: WizardFeature) -> [InternalSkillAsset] {\n    loadSkills(for: feature).map { materialize($0) }\n  }\n\n  // MARK: - Load\n\n  private func loadSkills(for feature: WizardFeature) -> [IndexedSkill] {\n    if let cached = cached[feature] { return cached }\n    let bundled = loadIndex(from: bundledIndexURL(), baseURL: bundledRootURL())\n    let overrides = loadIndex(from: overrideIndexURL(), baseURL: overrideRootURL())\n\n    var map: [String: IndexedSkill] = [:]\n    for item in bundled { map[item.definition.id] = item }\n    for item in overrides { map[item.definition.id] = item }\n\n    let merged = map.values.filter { $0.definition.feature == feature }\n      .sorted { $0.definition.id < $1.definition.id }\n    cached[feature] = merged\n    return merged\n  }\n\n  private func loadIndex(from indexURL: URL?, baseURL: URL?) -> [IndexedSkill] {\n    guard let indexURL, let baseURL else { return [] }\n    guard let data = try? Data(contentsOf: indexURL) else { return [] }\n    let decoder = JSONDecoder()\n    let index = (try? decoder.decode(InternalSkillsIndex.self, from: data))?.skills ?? []\n    return index.map { def in\n      let root = baseURL.appendingPathComponent(def.id, isDirectory: true)\n      return IndexedSkill(definition: def, rootURL: root)\n    }\n  }\n\n  private func bundledIndexURL() -> URL? {\n    if let url = Bundle.main.url(forResource: \"index\", withExtension: \"json\", subdirectory: \"payload/internal-skills\") {\n      return url\n    }\n    return devPayloadRootURL()?\n      .appendingPathComponent(\"internal-skills\", isDirectory: true)\n      .appendingPathComponent(\"index.json\", isDirectory: false)\n  }\n\n  private func bundledRootURL() -> URL? {\n    if let url = Bundle.main.url(forResource: \"internal-skills\", withExtension: nil, subdirectory: \"payload\") {\n      return url\n    }\n    return devPayloadRootURL()?\n      .appendingPathComponent(\"internal-skills\", isDirectory: true)\n  }\n\n  private func devPayloadRootURL() -> URL? {\n    let cwd = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)\n    if let found = findPayloadRoot(startingAt: cwd) {\n      return found\n    }\n    if let execURL = Bundle.main.executableURL {\n      let execDir = execURL.deletingLastPathComponent()\n      if let found = findPayloadRoot(startingAt: execDir) {\n        return found\n      }\n    }\n    return nil\n  }\n\n  private func findPayloadRoot(startingAt start: URL) -> URL? {\n    var current = start\n    for _ in 0..<6 {\n      let candidate = current\n        .appendingPathComponent(\"payload\", isDirectory: true)\n        .appendingPathComponent(\"internal-skills\", isDirectory: true)\n        .appendingPathComponent(\"index.json\", isDirectory: false)\n      if fileManager.fileExists(atPath: candidate.path) {\n        return current.appendingPathComponent(\"payload\", isDirectory: true)\n      }\n      current = current.deletingLastPathComponent()\n    }\n    return nil\n  }\n\n  private func overrideRootURL() -> URL? {\n    let base = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first\n    return base?.appendingPathComponent(\"CodMate\", isDirectory: true)\n      .appendingPathComponent(\"internal-skills\", isDirectory: true)\n  }\n\n  private func overrideIndexURL() -> URL? {\n    overrideRootURL()?.appendingPathComponent(\"index.json\", isDirectory: false)\n  }\n\n  // MARK: - Materialize\n\n  private func materialize(_ skill: IndexedSkill) -> InternalSkillAsset {\n    let def = skill.definition\n    let assets = def.assets ?? InternalSkillAssetPaths()\n    let skillPath = assets.skill ?? \"SKILL.md\"\n    let promptPath = assets.prompt ?? \"prompt.md\"\n    let schemaPath = assets.schema ?? \"schema.json\"\n    let docsPath = assets.docs ?? \"docs.json\"\n\n    let skillMarkdown = readText(skill.rootURL.appendingPathComponent(skillPath))\n    let prompt = readText(skill.rootURL.appendingPathComponent(promptPath))\n    let schema = readText(skill.rootURL.appendingPathComponent(schemaPath))\n    let fileOverrides = loadDocsOverrides(skill.rootURL.appendingPathComponent(docsPath))\n    let docsOverrides = (def.docsSources ?? []) + fileOverrides\n\n    return InternalSkillAsset(\n      definition: def,\n      rootURL: skill.rootURL,\n      skillMarkdown: skillMarkdown,\n      prompt: prompt,\n      schema: schema,\n      docsOverrides: docsOverrides\n    )\n  }\n\n  private func readText(_ url: URL) -> String? {\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let text = String(data: data, encoding: .utf8) ?? \"\"\n    return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : text\n  }\n\n  private func loadDocsOverrides(_ url: URL) -> [WizardDocSource] {\n    guard let data = try? Data(contentsOf: url) else { return [] }\n    let decoder = JSONDecoder()\n    let list = (try? decoder.decode([WizardDocSource].self, from: data)) ?? []\n    return list\n  }\n}\n"
  },
  {
    "path": "services/LLMHTTPService.swift",
    "content": "import Foundation\n\n// MARK: - Minimal HTTP transport for Providers (OpenAI‑compatible / Anthropic)\n// Small baseline: text generation only, auto‑select provider from registry.\n\nactor LLMHTTPService {\n    enum PreferredEngine { case auto, codex, claudeCode }\n\n    struct Options: Sendable {\n        var preferred: PreferredEngine = .auto\n        var model: String? = nil\n        var timeout: TimeInterval = 25\n        var systemPrompt: String? = nil\n        var maxTokens: Int = 300\n        var temperature: Double = 0.2\n        // Optional hard selection of a registry provider id. If set, we will\n        // use that provider's connector (prefer codex, else claudeCode).\n        var providerId: String? = nil\n    }\n\n    struct Result: Sendable { let text: String; let providerId: String; let model: String?; let elapsedMs: Int; let statusCode: Int }\n\n    private let providers = ProvidersRegistryService()\n\n    func generateText(prompt: String, options: Options = Options()) async throws -> Result {\n        let start = Date()\n        let reg = await providers.mergedRegistry()\n\n        guard let sel = selectConnector(reg: reg, preferred: options.preferred, providerId: options.providerId) else {\n            throw HTTPError.noActiveProvider\n        }\n\n        // Determine target API family based on selected consumer first, then provider class fallback\n        let providerClass = (sel.provider.class ?? \"openai-compatible\").lowercased()\n        let isAnthropicFamily = (sel.consumerKey == ProvidersRegistryService.Consumer.claudeCode.rawValue) || providerClass == \"anthropic\"\n        let candidates = candidateModels(reg: reg, selection: sel, preferred: options.model)\n        var lastErr: Error? = nil\n        if isAnthropicFamily {\n            for m in candidates {\n                do {\n                    let (code, text) = try await callAnthropic(baseURL: sel.baseURL, headers: sel.headers, model: m, prompt: prompt, options: options)\n                    let ms = Int(Date().timeIntervalSince(start) * 1000)\n                    return Result(text: text, providerId: sel.provider.id, model: m, elapsedMs: ms, statusCode: code)\n                } catch let error as HTTPError {\n                    if case .http(let sc, _) = error, sc == 404 || sc == 403 || sc == 401 {\n                        lastErr = error; continue\n                    } else {\n                        lastErr = error; break\n                    }\n                } catch {\n                    lastErr = error; break\n                }\n            }\n        } else {\n            // Default to OpenAI‑compatible\n            let wire = (sel.connector.wireAPI ?? \"chat\").lowercased()\n            for m in candidates {\n                do {\n                    let (code, text): (Int, String)\n                    if wire == \"responses\" {\n                        (code, text) = try await callOpenAIResponses(baseURL: sel.baseURL, headers: sel.headers, model: m, prompt: prompt, options: options)\n                    } else {\n                        (code, text) = try await callOpenAIChat(baseURL: sel.baseURL, headers: sel.headers, model: m, system: options.systemPrompt, prompt: prompt, options: options)\n                    }\n                    let ms = Int(Date().timeIntervalSince(start) * 1000)\n                    return Result(text: text, providerId: sel.provider.id, model: m, elapsedMs: ms, statusCode: code)\n                } catch let error as HTTPError {\n                    if case .http(let sc, _) = error, sc == 404 || sc == 403 || sc == 401 {\n                        lastErr = error; continue\n                    } else {\n                        lastErr = error; break\n                    }\n                } catch {\n                    lastErr = error; break\n                }\n            }\n        }\n        throw lastErr ?? HTTPError.badResponse(\"model resolution failed\")\n    }\n\n    // Resolve model id from (in order): caller override → bindings.defaultModel → provider.recommended → connector.modelAliases[\"default\"] → first catalog model\n    private func resolveModel(\n        reg: ProvidersRegistryService.Registry,\n        selection sel: (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String),\n        preferred: String?\n    ) -> String? {\n        if let p = preferred, !p.isEmpty { return p }\n        if let bind = reg.bindings.defaultModel?[sel.consumerKey], !bind.isEmpty { return bind }\n        if let rec = sel.provider.recommended?.defaultModelFor?[sel.consumerKey], !rec.isEmpty { return rec }\n        if let def = sel.connector.modelAliases?[\"default\"], !def.isEmpty { return def }\n        if let first = sel.provider.catalog?.models?.first?.vendorModelId, !first.isEmpty { return first }\n        return nil\n    }\n\n    private func candidateModels(\n        reg: ProvidersRegistryService.Registry,\n        selection sel: (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String),\n        preferred: String?\n    ) -> [String?] {\n        var out: [String] = []\n        let push: (String?) -> Void = { v in if let v = v, !v.isEmpty, !out.contains(v) { out.append(v) } }\n        push(preferred)\n        push(reg.bindings.defaultModel?[sel.consumerKey])\n        push(sel.provider.recommended?.defaultModelFor?[sel.consumerKey])\n        push(sel.connector.modelAliases?[\"default\"])\n        if let first = sel.provider.catalog?.models?.first?.vendorModelId { push(first) }\n        // Provide a very last‑resort fallback per family (won't be hit if any above exist)\n        return out.isEmpty ? [nil] : out.map { Optional($0) }\n    }\n\n    // MARK: - Selection\n    private func selectConnector(\n        reg: ProvidersRegistryService.Registry,\n        preferred: PreferredEngine,\n        providerId: String?\n    ) -> (provider: ProvidersRegistryService.Provider, connector: ProvidersRegistryService.Connector, baseURL: String, headers: [String:String], consumerKey: String)? {\n        // All providers now use Auto-Proxy mode through CLIProxyAPI\n        // OAuth providers require separate authorization through CLIProxyAPI (isolated from main CLI auth)\n        // API key providers also route through CLIProxyAPI for unified model access\n\n        var parsedSelection = providerId.map { UnifiedProviderID.parse($0) }\n        if case .unknown(let raw) = parsedSelection,\n           let normalized = UnifiedProviderID.normalize(raw, registryProviders: reg.providers) {\n            parsedSelection = UnifiedProviderID.parse(normalized)\n        }\n\n        var effectiveProviderId: String?\n        var builtinProvider: LocalServerBuiltInProvider?\n        var legacyRerouteLabel: String?\n\n        switch parsedSelection {\n        case .oauth(let auth, _):\n            // OAuth providers always route through CLIProxyAPI (requires separate authorization)\n            builtinProvider = Self.builtinProvider(for: auth)\n        case .api(let id):\n            effectiveProviderId = id\n        case .legacyBuiltin(let builtin):\n            builtinProvider = builtin\n        case .legacyReroute(let label):\n            legacyRerouteLabel = label\n        case .autoProxy:\n            // Auto proxy mode: use CLI Proxy API for routing\n            break\n        case .unknown(let value):\n            effectiveProviderId = value\n        case .none:\n            break\n        }\n\n        // Handle legacy CLI Proxy reroute providers selection (local-reroute:*)\n        if let name = legacyRerouteLabel, let pid = providerId {\n            let port = Self.localServerPort()\n            let base = \"http://127.0.0.1:\\(port)/v1\"\n            let vConnector = ProvidersRegistryService.Connector(\n                baseURL: base,\n                wireAPI: \"chat\"\n            )\n            let vProvider = ProvidersRegistryService.Provider(\n                id: pid,\n                name: name,\n                class: \"openai-compatible\",\n                managedByCodMate: true,\n                connectors: [\"internal\": vConnector]\n            )\n            var headers: [String:String] = [:]\n            if let key = Self.loadLocalServerAPIKey(), !key.isEmpty {\n                headers[\"Authorization\"] = key.hasPrefix(\"Bearer \") ? key : \"Bearer \\(key)\"\n            }\n            return (vProvider, vConnector, base, headers, \"internal\")\n        }\n\n        // Handle OAuth providers selection (oauth:*) - always route through CLIProxyAPI\n        if let builtin = builtinProvider {\n            let port = Self.localServerPort()\n            let base = \"http://127.0.0.1:\\(port)/v1\"\n            let vConnector = ProvidersRegistryService.Connector(\n                baseURL: base,\n                wireAPI: \"chat\"\n            )\n            let id: String = {\n                // For OAuth accounts, preserve the full ID (including accountId if present)\n                if case .oauth = parsedSelection, let providerId = providerId {\n                  return providerId\n                }\n                if case .legacyBuiltin(let legacy) = parsedSelection { return legacy.id }\n                return builtin.id\n            }()\n            let vProvider = ProvidersRegistryService.Provider(\n                id: id,\n                name: builtin.displayName,\n                class: \"openai-compatible\",\n                managedByCodMate: true,\n                connectors: [\"internal\": vConnector]\n            )\n            var headers: [String:String] = [:]\n            if let key = Self.loadLocalServerAPIKey(), !key.isEmpty {\n                headers[\"Authorization\"] = key.hasPrefix(\"Bearer \") ? key : \"Bearer \\(key)\"\n            }\n            return (vProvider, vConnector, base, headers, \"internal\")\n        }\n\n        func resolve(_ consumer: ProvidersRegistryService.Consumer, scopedProvider: ProvidersRegistryService.Provider? = nil) -> (ProvidersRegistryService.Provider, ProvidersRegistryService.Connector, String, [String:String], String)? {\n            let key = consumer.rawValue\n            let p: ProvidersRegistryService.Provider?\n            if let scoped = scopedProvider { p = (scoped.connectors[key] != nil) ? scoped : nil }\n            else if let ap = reg.bindings.activeProvider?[key], let match = reg.providers.first(where: { $0.id == ap }) { p = match }\n            else { p = reg.providers.first(where: { $0.connectors[key] != nil }) }\n            guard let provider = p, let connector = provider.connectors[key] else { return nil }\n            guard let base = connector.baseURL, !base.isEmpty else { return nil }\n            var headers: [String:String] = [:]\n            // Start with explicit headers\n            if let h = connector.httpHeaders { for (k,v) in h { headers[k] = v } }\n            // Fill envHttpHeaders from env\n            if let eh = connector.envHttpHeaders { for (k, envKey) in eh { if let val = ProcessInfo.processInfo.environment[envKey], !val.isEmpty { headers[k] = val } } }\n            // If Authorization missing, use provider/envKey -> env var or direct token\n            if headers[\"Authorization\"] == nil {\n                if let name = provider.envKey ?? connector.envKey,\n                   let val = ProcessInfo.processInfo.environment[name], !val.isEmpty {\n                    headers[\"Authorization\"] = val.hasPrefix(\"Bearer \") ? val : \"Bearer \\(val)\"\n                } else if let k = provider.envKey ?? connector.envKey {\n                    let lower = k.lowercased()\n                    let looksLikeToken = lower.contains(\"sk-\") || k.hasPrefix(\"eyJ\") || k.contains(\".\")\n                    if looksLikeToken { headers[\"Authorization\"] = k.hasPrefix(\"Bearer \") ? k : \"Bearer \\(k)\" }\n                }\n            }\n            return (provider, connector, base, headers, key)\n        }\n\n        // Resolve the actual provider first\n        var result: (ProvidersRegistryService.Provider, ProvidersRegistryService.Connector, String, [String:String], String)?\n\n        // If providerId is specified, pick its connector (prefer codex, else claudeCode)\n        if let pid = effectiveProviderId, let p = reg.providers.first(where: { $0.id == pid }) {\n            result = resolve(.codex, scopedProvider: p) ?? resolve(.claudeCode, scopedProvider: p)\n        } else {\n            switch preferred {\n            case .codex:\n                result = resolve(.codex) ?? resolve(.claudeCode)\n            case .claudeCode:\n                result = resolve(.claudeCode) ?? resolve(.codex)\n            case .auto:\n                result = resolve(.codex) ?? resolve(.claudeCode)\n            }\n        }\n\n        guard let resolved = result else { return nil }\n\n        // All API key providers managed by CodMate route through CLIProxyAPI\n        // This provides unified model access and matches the Auto-Proxy model list\n        let (provider, _, _, _, _) = resolved\n\n        if provider.managedByCodMate == true {\n            // Route through CLIProxyAPI for unified access\n            let port = Self.localServerPort()\n            let base = \"http://127.0.0.1:\\(port)/v1\"\n\n            let vConnector = ProvidersRegistryService.Connector(\n                baseURL: base,\n                wireAPI: \"chat\"\n            )\n            // Keep original provider info for model selection\n            var reroutedProvider = provider\n            reroutedProvider.connectors = [\"internal\": vConnector]\n\n            var proxyHeaders: [String:String] = [:]\n            if let key = Self.loadLocalServerAPIKey(), !key.isEmpty {\n                proxyHeaders[\"Authorization\"] = key.hasPrefix(\"Bearer \") ? key : \"Bearer \\(key)\"\n            }\n\n            return (reroutedProvider, vConnector, base, proxyHeaders, \"internal\")\n        }\n\n        return resolved\n    }\n\n    /// Get the CLI Proxy API port from UserDefaults (single source of truth)\n    /// Note: This is a static method that can be called from any context,\n    /// so we read UserDefaults directly instead of using CLIProxyService.shared.port\n    /// which is @MainActor isolated.\n    private static func localServerPort() -> Int {\n        let p = UserDefaults.standard.integer(forKey: \"codmate.localserver.port\")\n        return p > 0 ? p : Int(CLIProxyService.defaultPort)\n    }\n\n    private static func builtinProvider(for auth: LocalAuthProvider) -> LocalServerBuiltInProvider? {\n        switch auth {\n        case .codex: return .openai\n        case .claude: return .anthropic\n        case .gemini: return .gemini\n        case .antigravity: return .antigravity\n        case .qwen: return .qwen\n        }\n    }\n\n    private static func localServerConfigPath() -> String {\n        let homeDir = FileManager.default.homeDirectoryForCurrentUser\n        let cliproxyapiDir = homeDir.appendingPathComponent(\".codmate/cliproxyapi\", isDirectory: true)\n        return cliproxyapiDir.appendingPathComponent(\"config.yaml\").path\n    }\n\n    private static func loadLocalServerAPIKey() -> String? {\n        guard let content = try? String(contentsOfFile: localServerConfigPath(), encoding: .utf8) else { return nil }\n        var inKeys = false\n        for line in content.components(separatedBy: .newlines) {\n            let trimmed = line.trimmingCharacters(in: .whitespaces)\n            if trimmed.hasPrefix(\"api-keys:\") {\n                inKeys = true\n                continue\n            }\n            if inKeys {\n                if trimmed.hasPrefix(\"-\") {\n                    var value = trimmed\n                    if let range = value.range(of: \"-\") {\n                        value = String(value[range.upperBound...]).trimmingCharacters(in: .whitespaces)\n                    }\n                    if value.hasPrefix(\"\\\"\") && value.hasSuffix(\"\\\"\") {\n                        value.removeFirst()\n                        value.removeLast()\n                    }\n                    return value.trimmingCharacters(in: .whitespaces)\n                }\n                if !trimmed.isEmpty {\n                    inKeys = false\n                }\n            }\n        }\n        return nil\n    }\n\n    // MARK: - OpenAI compatible\n    private func callOpenAIChat(baseURL: String, headers: [String:String], model: String?, system: String?, prompt: String, options: Options) async throws -> (Int, String) {\n        let url = openAIEndpoint(baseURL: baseURL, path: \"chat/completions\")\n        var msgs: [[String:Any]] = []\n        if let sys = system, !sys.isEmpty { msgs.append([\"role\":\"system\",\"content\": sys]) }\n        msgs.append([\"role\":\"user\",\"content\": prompt])\n        let body: [String: Any] = [\n            \"model\": model ?? \"gpt-4.1-mini\",\n            \"messages\": msgs,\n            \"temperature\": options.temperature,\n            \"max_tokens\": options.maxTokens\n        ]\n        let (code, json) = try await postJSON(url: url, headers: addJSONHeaders(headers), body: body, timeout: options.timeout)\n        if let choices = json[\"choices\"] as? [[String:Any]],\n           let first = choices.first,\n           let message = first[\"message\"] as? [String:Any],\n           let content = message[\"content\"] as? String {\n            return (code, content)\n        }\n        // Fallback for providers that return `choices[].text`\n        if let choices = json[\"choices\"] as? [[String:Any]], let first = choices.first, let text = first[\"text\"] as? String {\n            return (code, text)\n        }\n        throw HTTPError.badResponse(\"openai.chat: missing choices\")\n    }\n\n    private func callOpenAIResponses(baseURL: String, headers: [String:String], model: String?, prompt: String, options: Options) async throws -> (Int, String) {\n        let url = openAIEndpoint(baseURL: baseURL, path: \"responses\")\n        let body: [String: Any] = [\n            \"model\": model ?? \"gpt-4.1-mini\",\n            \"input\": [[\n                \"role\": \"user\",\n                \"content\": [[\"type\":\"text\",\"text\": prompt]]\n            ]],\n            \"temperature\": options.temperature,\n            \"max_output_tokens\": options.maxTokens\n        ]\n        let (code, json) = try await postJSON(url: url, headers: addJSONHeaders(headers), body: body, timeout: options.timeout)\n        if let s = json[\"output_text\"] as? String { return (code, s) }\n        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) }\n        if let content = json[\"content\"] as? [[String:Any]], let first = content.first, let text = first[\"text\"] as? String { return (code, text) }\n        throw HTTPError.badResponse(\"openai.responses: missing output_text/content\")\n    }\n\n    // MARK: - Anthropic\n    private func callAnthropic(baseURL: String, headers: [String:String], model: String?, prompt: String, options: Options) async throws -> (Int, String) {\n        let url = anthropicEndpoint(baseURL: baseURL, path: \"messages\")\n        var hdr = addJSONHeaders(headers)\n        if hdr[\"anthropic-version\"] == nil { hdr[\"anthropic-version\"] = \"2023-06-01\" }\n        let body: [String: Any] = [\n            \"model\": model ?? \"claude-3-5-sonnet-20241022\",\n            \"max_tokens\": options.maxTokens,\n            \"messages\": [[\"role\":\"user\",\"content\": [[\"type\":\"text\",\"text\": prompt]]]]\n        ]\n        let (code, json) = try await postJSON(url: url, headers: hdr, body: body, timeout: options.timeout)\n        if let content = json[\"content\"] as? [[String:Any]] {\n            for item in content {\n                if (item[\"type\"] as? String) == \"text\", let text = item[\"text\"] as? String { return (code, text) }\n            }\n        }\n        throw HTTPError.badResponse(\"anthropic.messages: missing content text\")\n    }\n\n    // MARK: - HTTP helpers\n    private func postJSON(url: URL, headers: [String:String], body: [String:Any], timeout: TimeInterval) async throws -> (Int, [String:Any]) {\n        var req = URLRequest(url: url); req.httpMethod = \"POST\"; req.timeoutInterval = timeout\n        for (k,v) in headers { req.setValue(v, forHTTPHeaderField: k) }\n        req.httpBody = try JSONSerialization.data(withJSONObject: body)\n        let (data, resp) = try await URLSession.shared.data(for: req)\n        guard let http = resp as? HTTPURLResponse else { throw HTTPError.badResponse(\"no http response\") }\n        let code = http.statusCode\n        if code / 100 != 2 { throw HTTPError.http(code, String(data: data, encoding: .utf8) ?? \"\") }\n        let json = (try? JSONSerialization.jsonObject(with: data)) as? [String:Any] ?? [:]\n        return (code, json)\n    }\n\n    // Build OpenAI-compatible endpoints robustly against base URLs that may already include a version (e.g., /v1 or /v4)\n    private func openAIEndpoint(baseURL: String, path: String) -> URL {\n        func hasNumericVersionSuffix(_ urlString: String) -> Bool {\n            guard let u = URL(string: urlString) else { return false }\n            let parts = u.path.split(separator: \"/\")\n            guard let last = parts.last else { return false }\n            let s = String(last).lowercased()\n            if s.hasPrefix(\"v\") {\n                let digits = s.dropFirst()\n                return !digits.isEmpty && digits.allSatisfy { $0.isNumber }\n            }\n            return false\n        }\n\n        var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        if base.hasSuffix(\"/\") { base.removeLast() }\n        let lower = base.lowercased()\n        // If base already ends with /v1 or /v{number}, don't append another /v1\n        if lower.hasSuffix(\"/v1\") || hasNumericVersionSuffix(base) {\n            return URL(string: base + \"/\" + path)!\n        } else {\n            return URL(string: base + \"/v1/\" + path)!\n        }\n    }\n\n    // Build Anthropic endpoints robustly against bases that may already include /v1\n    private func anthropicEndpoint(baseURL: String, path: String) -> URL {\n        var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        if base.hasSuffix(\"/\") { base.removeLast() }\n        if base.lowercased().hasSuffix(\"/v1\") {\n            return URL(string: base + \"/\" + path)!\n        } else {\n            return URL(string: base + \"/v1/\" + path)!\n        }\n    }\n\n    private func addJSONHeaders(_ h: [String:String]) -> [String:String] {\n        var out = h\n        if out[\"Content-Type\"] == nil { out[\"Content-Type\"] = \"application/json\" }\n        if out[\"Accept\"] == nil { out[\"Accept\"] = \"application/json\" }\n        return out\n    }\n\n    enum HTTPError: LocalizedError { case noActiveProvider; case http(Int, String); case badResponse(String)\n        var errorDescription: String? {\n            switch self {\n            case .noActiveProvider: return \"No active provider configured\"\n            case .http(let code, let body): return \"HTTP \\(code): \\(body.prefix(400))\"\n            case .badResponse(let s): return \"Bad response: \\(s)\"\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "services/LaunchAtLoginService.swift",
    "content": "import Foundation\nimport ServiceManagement\n\n@MainActor\nfinal class LaunchAtLoginService {\n  static let shared = LaunchAtLoginService()\n\n  private init() {}\n\n  /// Register or unregister the app to launch at login\n  func setLaunchAtLogin(enabled: Bool) {\n    if #available(macOS 13.0, *) {\n      do {\n        if enabled {\n          if SMAppService.mainApp.status == .enabled {\n            print(\"[LaunchAtLogin] Already enabled\")\n            return\n          }\n          try SMAppService.mainApp.register()\n          print(\"[LaunchAtLogin] Successfully registered for launch at login\")\n        } else {\n          if SMAppService.mainApp.status == .notRegistered {\n            print(\"[LaunchAtLogin] Already disabled\")\n            return\n          }\n          try SMAppService.mainApp.unregister()\n          print(\"[LaunchAtLogin] Successfully unregistered from launch at login\")\n        }\n      } catch {\n        print(\"[LaunchAtLogin] Failed to \\(enabled ? \"register\" : \"unregister\"): \\(error)\")\n      }\n    } else {\n      print(\"[LaunchAtLogin] Launch at login requires macOS 13.0 or later\")\n    }\n  }\n\n  /// Check if the app is currently set to launch at login\n  var isEnabled: Bool {\n    if #available(macOS 13.0, *) {\n      return SMAppService.mainApp.status == .enabled\n    }\n    return false\n  }\n\n  /// Synchronize the actual system state with preferences\n  func syncWithPreferences(_ preferences: SessionPreferencesStore) {\n    if #available(macOS 13.0, *) {\n      let actuallyEnabled = SMAppService.mainApp.status == .enabled\n      if preferences.launchAtLogin != actuallyEnabled {\n        print(\"[LaunchAtLogin] Syncing: preference=\\(preferences.launchAtLogin), actual=\\(actuallyEnabled)\")\n        setLaunchAtLogin(enabled: preferences.launchAtLogin)\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "services/LocalServerBuiltInProvider.swift",
    "content": "import Foundation\n\nenum LocalServerBuiltInProvider: String, CaseIterable, Identifiable {\n    case anthropic\n    case gemini\n    case openai\n    case antigravity\n    case qwen\n\n    var id: String { \"local-builtin-\\(rawValue)\" }\n\n    var displayName: String {\n        switch self {\n        case .anthropic: return \"Claude (OAuth)\"\n        case .gemini: return \"Gemini (OAuth)\"\n        case .openai: return \"Codex (OAuth)\"\n        case .antigravity: return \"Antigravity (OAuth)\"\n        case .qwen: return \"Qwen Code (OAuth)\"\n        }\n    }\n\n    var ownedByHints: [String] {\n        switch self {\n        case .anthropic: return [\"anthropic\", \"claude\"]\n        case .gemini: return [\"google\", \"gemini\"]\n        case .openai: return [\"openai\", \"codex\", \"gpt\"]\n        case .antigravity: return [\"antigravity\"]\n        case .qwen: return [\"qwen\"]\n        }\n    }\n\n    var modelIdHints: [String] {\n        switch self {\n        case .anthropic: return [\"claude-\"]\n        case .gemini: return [\"gemini-\"]\n        case .openai: return [\"gpt-\"]\n        case .antigravity: return [\"gemini-3\", \"gemini-3-\"]\n        case .qwen: return [\"qwen-\"]\n        }\n    }\n\n    func matchesOwnedBy(_ value: String?) -> Bool {\n        let lower = (value ?? \"\").lowercased()\n        return ownedByHints.contains { lower.contains($0) }\n    }\n\n    func matchesModelId(_ modelId: String) -> Bool {\n        let lower = modelId.lowercased()\n        return modelIdHints.contains { lower.hasPrefix($0) }\n    }\n\n    static func from(providerId: String?) -> LocalServerBuiltInProvider? {\n        guard let providerId else { return nil }\n        return LocalServerBuiltInProvider.allCases.first(where: { $0.id == providerId })\n    }\n}\n"
  },
  {
    "path": "services/MCPImportService.swift",
    "content": "import Foundation\n\nenum MCPImportService {\n  struct SourceDescriptor {\n    let label: String\n    let url: URL\n    let loader: () -> String?\n  }\n\n  private static let codmateBegin = \"# codmate-mcp begin\"\n  private static let codmateEnd = \"# codmate-mcp end\"\n\n  static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default)\n    -> [MCPImportCandidate]\n  {\n    let sources: [SourceDescriptor]\n    switch scope {\n    case .home:\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      sources = [\n        SourceDescriptor(\n          label: \"Codex\",\n          url: home.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"config.toml\", isDirectory: false),\n          loader: {\n            let url = home.appendingPathComponent(\".codex\", isDirectory: true)\n              .appendingPathComponent(\"config.toml\", isDirectory: false)\n            return readText(url: url, fileManager: fileManager).map(stripCodMateManagedBlock)\n          }),\n        SourceDescriptor(\n          label: \"Claude\",\n          url: home.appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"settings.json\", isDirectory: false),\n          loader: {\n            let url = home.appendingPathComponent(\".claude\", isDirectory: true)\n              .appendingPathComponent(\"settings.json\", isDirectory: false)\n            return readMCPServersJSON(url: url, fileManager: fileManager)\n          }),\n        SourceDescriptor(\n          label: \"Gemini\",\n          url: home.appendingPathComponent(\".gemini\", isDirectory: true)\n            .appendingPathComponent(\"settings.json\", isDirectory: false),\n          loader: {\n            let url = home.appendingPathComponent(\".gemini\", isDirectory: true)\n              .appendingPathComponent(\"settings.json\", isDirectory: false)\n            return readMCPServersJSON(url: url, fileManager: fileManager)\n          }),\n      ]\n    case .project(let directory):\n      sources = [\n        SourceDescriptor(\n          label: \"Codex\",\n          url: directory.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"config.toml\", isDirectory: false),\n          loader: {\n            let url = directory.appendingPathComponent(\".codex\", isDirectory: true)\n              .appendingPathComponent(\"config.toml\", isDirectory: false)\n            return readText(url: url, fileManager: fileManager).map(stripCodMateManagedBlock)\n          }),\n        // Claude Code official path: project_root/.mcp.json\n        SourceDescriptor(\n          label: \"Claude\",\n          url: directory.appendingPathComponent(\".mcp.json\", isDirectory: false),\n          loader: {\n            let url = directory.appendingPathComponent(\".mcp.json\", isDirectory: false)\n            return readMCPServersJSON(url: url, fileManager: fileManager)\n          }),\n        // CodMate legacy path: project_root/.claude/.mcp.json (for backward compatibility)\n        SourceDescriptor(\n          label: \"Claude\",\n          url: directory.appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\".mcp.json\", isDirectory: false),\n          loader: {\n            let url = directory.appendingPathComponent(\".claude\", isDirectory: true)\n              .appendingPathComponent(\".mcp.json\", isDirectory: false)\n            return readMCPServersJSON(url: url, fileManager: fileManager)\n          }),\n        SourceDescriptor(\n          label: \"Gemini\",\n          url: directory.appendingPathComponent(\".gemini\", isDirectory: true)\n            .appendingPathComponent(\"settings.json\", isDirectory: false),\n          loader: {\n            let url = directory.appendingPathComponent(\".gemini\", isDirectory: true)\n              .appendingPathComponent(\"settings.json\", isDirectory: false)\n            return readMCPServersJSON(url: url, fileManager: fileManager)\n          }),\n      ]\n    }\n    let filtered = sources.filter { source in\n      switch source.label {\n      case \"Codex\": return SessionPreferencesStore.isCLIEnabled(.codex)\n      case \"Claude\": return SessionPreferencesStore.isCLIEnabled(.claude)\n      case \"Gemini\": return SessionPreferencesStore.isCLIEnabled(.gemini)\n      default: return true\n      }\n    }\n    return scan(sources: filtered)\n  }\n\n  private static func scan(sources: [SourceDescriptor]) -> [MCPImportCandidate] {\n    var map: [String: MCPImportCandidate] = [:]\n    var byName: [String: [String]] = [:]\n\n    for source in sources {\n      guard let text = source.loader(), !text.isEmpty else { continue }\n      guard let drafts = try? UniImportMCPNormalizer.parseText(text) else { continue }\n\n      for draft in drafts {\n        let name = (draft.name ?? \"imported-server\").trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !name.isEmpty else { continue }\n\n        let signature = normalizeSignature(\n          name: name, kind: draft.kind, command: draft.command, url: draft.url, args: draft.args)\n        if var existing = map[signature] {\n          if !existing.sources.contains(source.label) {\n            existing.sources.append(source.label)\n          }\n          existing.sourcePaths[source.label] = source.url.path\n          map[signature] = existing\n        } else {\n          map[signature] = MCPImportCandidate(\n            id: UUID(),\n            name: name,\n            kind: draft.kind,\n            command: draft.command,\n            args: draft.args,\n            env: draft.env,\n            url: draft.url,\n            headers: draft.headers,\n            description: draft.meta?.description,\n            sources: [source.label],\n            sourcePaths: [source.label: source.url.path],\n            isSelected: true,\n            hasConflict: false,\n            hasNameCollision: false,\n            resolution: .overwrite,\n            renameName: name,\n            signature: signature\n          )\n        }\n      }\n    }\n\n    for candidate in map.values {\n      byName[candidate.name, default: []].append(candidate.signature)\n    }\n\n    var out = map.values.sorted {\n      $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n    }\n    for idx in out.indices {\n      if let signatures = byName[out[idx].name], signatures.count > 1 {\n        out[idx].hasNameCollision = true\n      }\n    }\n\n    return out\n  }\n\n  static func signature(for server: MCPServer) -> String {\n    normalizeSignature(\n      name: server.name,\n      kind: server.kind,\n      command: server.command,\n      url: server.url,\n      args: server.args\n    )\n  }\n\n  static func filterManagedCandidates(\n    _ candidates: [MCPImportCandidate],\n    managedSignatures: Set<String>\n  ) -> [MCPImportCandidate] {\n    candidates.filter { !managedSignatures.contains($0.signature) }\n  }\n\n  private static func normalizeSignature(\n    name: String,\n    kind: MCPServerKind,\n    command: String?,\n    url: String?,\n    args: [String]?\n  ) -> String {\n    let normName = name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    let normCommand = (command ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    let normURL = (url ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    let normArgs = (args ?? []).map {\n      $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    }.sorted().joined(separator: \"|\")\n    return \"\\(normName)|\\(kind.rawValue)|\\(normCommand)|\\(normURL)|\\(normArgs)\"\n  }\n\n  private static func readText(url: URL, fileManager: FileManager) -> String? {\n    guard fileManager.fileExists(atPath: url.path) else { return nil }\n    return try? String(contentsOf: url, encoding: .utf8)\n  }\n\n  private static func readMCPServersJSON(url: URL, fileManager: FileManager) -> String? {\n    guard fileManager.fileExists(atPath: url.path) else { return nil }\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {\n      return nil\n    }\n    guard let mcpServers = json[\"mcpServers\"] as? [String: Any] else { return nil }\n    let payload: [String: Any] = [\"mcpServers\": mcpServers]\n    guard\n      let out = try? JSONSerialization.data(\n        withJSONObject: payload, options: [.prettyPrinted, .withoutEscapingSlashes])\n    else { return nil }\n    return String(data: out, encoding: .utf8)\n  }\n\n  private static func stripCodMateManagedBlock(_ text: String) -> String {\n    guard let begin = text.range(of: codmateBegin), let end = text.range(of: codmateEnd) else {\n      return text\n    }\n    var updated = text\n    updated.removeSubrange(begin.lowerBound..<end.upperBound)\n    return updated\n  }\n}\n"
  },
  {
    "path": "services/MCPQuickTestService.swift",
    "content": "import Foundation\n#if canImport(MCP)\nimport MCP\n#endif\n#if canImport(System)\nimport System\n#endif\n\nenum MCPQuickTestError: Error, LocalizedError {\n    case invalidConfiguration(String)\n    case unreachable(String)\n    case timeout\n    case unknown\n\n    var errorDescription: String? {\n        switch self {\n        case .invalidConfiguration(let m): return m\n        case .unreachable(let m): return m\n        case .timeout: return \"Timeout\"\n        case .unknown: return \"Unknown error\"\n        }\n    }\n}\n\nstruct MCPQuickTestResult: Sendable {\n    let connected: Bool\n    let serverName: String?\n    let tools: Int\n    let prompts: Int\n    let resources: Int\n    let models: Int\n    let hasTools: Bool\n    let hasPrompts: Bool\n    let hasResources: Bool\n}\n\n/// Lightweight connectivity test for MCP servers.\n/// NOTE: This does not perform full MCP handshake; it only verifies reachability quickly.\nactor MCPQuickTestService {\n    private var cancelRequested: Bool = false\n    private var currentProcess: Process? = nil\n\n    func cancelActive() async {\n        cancelRequested = true\n        if let p = currentProcess, p.isRunning {\n            p.terminate()\n            Task.detached {\n                try? await Task.sleep(nanoseconds: 800_000_000)\n                if p.isRunning { p.terminate() }\n            }\n        }\n    }\n\n    func test(server: MCPServer, timeoutSeconds: TimeInterval = 5.0) async -> Result<MCPQuickTestResult, MCPQuickTestError> {\n        cancelRequested = false\n        currentProcess = nil\n        switch server.kind {\n        case .stdio:\n            return await testStdio(server: server, timeoutSeconds: timeoutSeconds)\n        case .sse, .streamable_http:\n            return await testHTTP(server: server, timeoutSeconds: timeoutSeconds)\n        }\n    }\n\n    private func testHTTP(server: MCPServer, timeoutSeconds: TimeInterval) async -> Result<MCPQuickTestResult, MCPQuickTestError> {\n        guard let urlString = server.url, let url = URL(string: urlString) else {\n            return .failure(.invalidConfiguration(\"Missing or invalid URL\"))\n        }\n        #if canImport(MCP)\n        // Prefer real MCP handshake via HTTPClientTransport when SDK is available\n        do {\n            let cfg = URLSessionConfiguration.ephemeral\n            cfg.timeoutIntervalForRequest = timeoutSeconds\n            cfg.timeoutIntervalForResource = timeoutSeconds\n            var headers: [String: String] = [:]\n            if let h = server.headers { headers = h }\n            let transport = HTTPClientTransport(\n                endpoint: url,\n                configuration: cfg,\n                streaming: true,\n                sseInitializationTimeout: 3,\n                requestModifier: { req in\n                    var r = req\n                    for (k,v) in headers { r.setValue(v, forHTTPHeaderField: k) }\n                    return r\n                },\n                logger: nil\n            )\n            let client = Client(name: \"CodMate\", version: \"1.0.0\")\n            let initResult = try await client.connect(transport: transport)\n            // Console diagnostics for investigation\n            print(\"[MCPTest] HTTP connect ok → protocol=\\(initResult.protocolVersion) server=\\(initResult.serverInfo.name) \\(initResult.serverInfo.version)\")\n            let caps = initResult.capabilities\n            let hasTools = (caps.tools != nil)\n            let hasPrompts = (caps.prompts != nil)\n            let hasResources = (caps.resources != nil)\n            print(\"[MCPTest] caps: tools=\\(hasTools) prompts=\\(hasPrompts) resources=\\(hasResources) logging=\\(caps.logging != nil) sampling=\\(caps.sampling != nil)\")\n            // Try to list counts only for declared capabilities\n            var toolsCount = 0, promptsCount = 0, resourcesCount = 0, modelsCount = 0\n            if hasTools {\n                do { let res = try await client.listTools(); toolsCount = res.tools.count; print(\"[MCPTest] listTools=\\(toolsCount)\") } catch { print(\"[MCPTest] listTools error: \\(error)\") }\n            }\n            if hasPrompts {\n                do { let res = try await client.listPrompts(); promptsCount = res.prompts.count; print(\"[MCPTest] listPrompts=\\(promptsCount)\") } catch { print(\"[MCPTest] listPrompts error: \\(error)\") }\n            }\n            if hasResources {\n                do { let res = try await client.listResources(); resourcesCount = res.resources.count; print(\"[MCPTest] listResources=\\(resourcesCount)\") } catch { print(\"[MCPTest] listResources error: \\(error)\") }\n            }\n            // Some servers expose models via prompts/resources; if the SDK exposes listModels in future, plug here.\n            return .success(.init(connected: true, serverName: initResult.serverInfo.name, tools: toolsCount, prompts: promptsCount, resources: resourcesCount, models: modelsCount, hasTools: hasTools, hasPrompts: hasPrompts, hasResources: hasResources))\n        } catch {\n            print(\"[MCPTest] HTTP SDK connect/list failed: \\(error)\")\n            // Fallback to HTTP reachability probe\n            return await httpProbe(url: url, headers: server.headers, timeoutSeconds: timeoutSeconds)\n        }\n        #else\n        return await httpProbe(url: url, headers: server.headers, timeoutSeconds: timeoutSeconds)\n        #endif\n    }\n\n    private func httpProbe(url: URL, headers: [String: String]?, timeoutSeconds: TimeInterval) async -> Result<MCPQuickTestResult, MCPQuickTestError> {\n        let config = URLSessionConfiguration.ephemeral\n        config.timeoutIntervalForRequest = timeoutSeconds\n        config.timeoutIntervalForResource = timeoutSeconds\n        let session = URLSession(configuration: config)\n        var request = URLRequest(url: url)\n        request.httpMethod = \"GET\"\n        if let headers { for (k,v) in headers { request.setValue(v, forHTTPHeaderField: k) } }\n        do {\n            let (_, resp) = try await session.data(for: request)\n            let code = (resp as? HTTPURLResponse)?.statusCode ?? 0\n            let ok = (200...299).contains(code) || code == 401 || code == 403 || code == 405\n            guard ok else { return .failure(.unreachable(\"HTTP \\(code)\")) }\n            return .success(.init(connected: true, serverName: nil, tools: 0, prompts: 0, resources: 0, models: 0, hasTools: false, hasPrompts: false, hasResources: false))\n        } catch {\n            if (error as? URLError)?.code == .timedOut { return .failure(.timeout) }\n            return .failure(.unreachable(error.localizedDescription))\n        }\n    }\n\n    private func testStdio(server: MCPServer, timeoutSeconds: TimeInterval) async -> Result<MCPQuickTestResult, MCPQuickTestError> {\n        guard let cmdRaw = server.command?.trimmingCharacters(in: .whitespacesAndNewlines), !cmdRaw.isEmpty else {\n            return .failure(.invalidConfiguration(\"Missing command\"))\n        }\n        // Resolve executable: absolute path → as-is; otherwise search PATH; fallback to /usr/bin/env cmd\n        let fm = FileManager.default\n        var env = ProcessInfo.processInfo.environment\n        if let custom = server.env { for (k,v) in custom { env[k] = v } }\n        // Ensure PATH contains common Homebrew locations\n        let defaultPATH = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n        let mergedPATH: String = {\n            if let p = env[\"PATH\"], !p.isEmpty { return p + \":\" + defaultPATH }\n            return defaultPATH\n        }()\n        env[\"PATH\"] = mergedPATH\n\n        let cmd = cmdRaw\n        let isAbsolute = cmd.hasPrefix(\"/\")\n        let execURL: URL?\n        if isAbsolute {\n            execURL = URL(fileURLWithPath: cmd)\n        } else {\n            // Search PATH\n            var found: URL? = nil\n            for dir in mergedPATH.split(separator: \":\") {\n                let path = String(dir) + \"/\" + cmd\n                if fm.isExecutableFile(atPath: path) { found = URL(fileURLWithPath: path); break }\n            }\n            execURL = found\n        }\n\n        let proc = Process()\n        let args = server.args ?? []\n        if let url = execURL {\n            proc.executableURL = url\n            proc.arguments = args\n        } else {\n            // Fallback: /usr/bin/env cmd args… to honor PATH resolution on macOS\n            proc.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n            proc.arguments = [cmd] + args\n        }\n        // Diagnostics\n        print(\"[MCPTest] stdio PATH=\\(mergedPATH)\")\n        print(\"[MCPTest] stdio exec=\\(execURL?.path ?? \"/usr/bin/env \\(cmd)\") args=\\(args)\")\n        proc.environment = env\n\n        #if canImport(MCP)\n        // Wire child process stdio to SDK stdio transport\n        let childStdout = Pipe()\n        let childStdin = Pipe()\n        let childStderr = Pipe()\n        proc.standardOutput = childStdout\n        proc.standardInput = childStdin\n        proc.standardError = childStderr\n        do {\n            try proc.run()\n        } catch {\n            let errMsg = (error as NSError).localizedDescription\n            if (error as NSError).domain == NSPOSIXErrorDomain && (error as NSError).code == ENOENT {\n                return .failure(.unreachable(\"Command not found in PATH\"))\n            }\n            return .failure(.unreachable(errMsg))\n        }\n\n        // Build transport using the child's pipes\n        // Note: input for transport is what we read FROM (child stdout), output is what we write TO (child stdin)\n        #if canImport(System)\n        let inFD = FileDescriptor(rawValue: CInt(childStdout.fileHandleForReading.fileDescriptor))\n        let outFD = FileDescriptor(rawValue: CInt(childStdin.fileHandleForWriting.fileDescriptor))\n        #else\n        let inFD = CInt(childStdout.fileHandleForReading.fileDescriptor)\n        let outFD = CInt(childStdin.fileHandleForWriting.fileDescriptor)\n        #endif\n        let transport = StdioTransport(input: inFD, output: outFD, logger: nil)\n        let client = Client(name: \"CodMate\", version: \"1.0.0\")\n        do {\n            let initResult = try await client.connect(transport: transport)\n            print(\"[MCPTest] stdio connect ok → protocol=\\(initResult.protocolVersion) server=\\(initResult.serverInfo.name) \\(initResult.serverInfo.version)\")\n            let caps = initResult.capabilities\n            let hasTools = (caps.tools != nil)\n            let hasPrompts = (caps.prompts != nil)\n            let hasResources = (caps.resources != nil)\n            print(\"[MCPTest] caps: tools=\\(hasTools) prompts=\\(hasPrompts) resources=\\(hasResources)\")\n            var toolsCount = 0, promptsCount = 0, resourcesCount = 0\n            if hasTools {\n                do { let res = try await client.listTools(); toolsCount = res.tools.count; print(\"[MCPTest] listTools=\\(toolsCount)\") } catch { print(\"[MCPTest] listTools error: \\(error)\") }\n            }\n            if hasPrompts {\n                do { let res = try await client.listPrompts(); promptsCount = res.prompts.count; print(\"[MCPTest] listPrompts=\\(promptsCount)\") } catch { print(\"[MCPTest] listPrompts error: \\(error)\") }\n            }\n            if hasResources {\n                do { let res = try await client.listResources(); resourcesCount = res.resources.count; print(\"[MCPTest] listResources=\\(resourcesCount)\") } catch { print(\"[MCPTest] listResources error: \\(error)\") }\n            }\n            // Cleanup\n            await transport.disconnect()\n            if proc.isRunning { proc.terminate() }\n            currentProcess = nil\n            return .success(.init(connected: true, serverName: initResult.serverInfo.name, tools: toolsCount, prompts: promptsCount, resources: resourcesCount, models: 0, hasTools: hasTools, hasPrompts: hasPrompts, hasResources: hasResources))\n        } catch {\n            print(\"[MCPTest] stdio SDK connect/list failed: \\(error)\")\n            if proc.isRunning { proc.terminate() }\n            return .failure(.unreachable(error.localizedDescription))\n        }\n        #else\n        // Without SDK, do a minimal reachability ping\n        let pipe = Pipe()\n        proc.standardOutput = pipe\n        proc.standardError = pipe\n        do { try proc.run() } catch {\n            let errMsg = (error as NSError).localizedDescription\n            if (error as NSError).domain == NSPOSIXErrorDomain && (error as NSError).code == ENOENT {\n                return .failure(.unreachable(\"Command not found in PATH\"))\n            }\n            return .failure(.unreachable(errMsg))\n        }\n        let deadline = UInt64((min(timeoutSeconds, 1.5)) * 1_000_000_000)\n        try? await Task.sleep(nanoseconds: deadline)\n        if proc.isRunning { proc.terminate() }\n        currentProcess = nil\n        return .success(.init(connected: true, serverName: nil, tools: 0, prompts: 0, resources: 0, models: 0, hasTools: false, hasPrompts: false, hasResources: false))\n        #endif\n    }\n}\n"
  },
  {
    "path": "services/MCPServersStore.swift",
    "content": "import Foundation\n\n// MARK: - Persistent MCP Servers Store\n\nactor MCPServersStore {\n    struct Paths { let home: URL; let fileURL: URL }\n\n    static func defaultPaths(fileManager: FileManager = .default) -> Paths {\n        // Persist MCP servers under the real user home (~/.codmate), not sandbox container\n        let home = SessionPreferencesStore.getRealUserHomeURL()\n            .appendingPathComponent(\".codmate\", isDirectory: true)\n        return Paths(home: home, fileURL: home.appendingPathComponent(\"mcp-servers.json\"))\n    }\n\n    private let fm: FileManager\n    private let paths: Paths\n    private var cache: [MCPServer]? = nil\n\n    init(paths: Paths = MCPServersStore.defaultPaths(), fileManager: FileManager = .default) {\n        self.paths = paths\n        self.fm = fileManager\n    }\n\n    // MARK: Load/Save\n    func load() -> [MCPServer] {\n        if let cache { return cache }\n        let url = paths.fileURL\n        guard let data = try? Data(contentsOf: url) else { cache = []; return [] }\n        if let list = try? JSONDecoder().decode([MCPServer].self, from: data) {\n            cache = list\n            return list\n        }\n        cache = []\n        return []\n    }\n\n    private func save(_ list: [MCPServer]) throws {\n        try fm.createDirectory(at: paths.home, withIntermediateDirectories: true)\n        let tmp = paths.fileURL.appendingPathExtension(\"tmp\")\n        let enc = JSONEncoder()\n        enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n        let data = try enc.encode(list)\n        try data.write(to: tmp, options: .atomic)\n        if fm.fileExists(atPath: paths.fileURL.path) { try fm.removeItem(at: paths.fileURL) }\n        try fm.moveItem(at: tmp, to: paths.fileURL)\n        cache = list\n    }\n\n    // MARK: Public API\n    func list() -> [MCPServer] { load() }\n\n    func upsert(_ server: MCPServer) throws {\n        var list = load()\n        if let idx = list.firstIndex(where: { $0.name == server.name }) {\n            list[idx] = server\n        } else {\n            list.append(server)\n        }\n        try save(list)\n    }\n\n    func upsertMany(_ servers: [MCPServer]) throws {\n        var map: [String: MCPServer] = [:]\n        for s in load() { map[s.name] = s }\n        for s in servers { map[s.name] = s }\n        let sorted = map.values.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }\n        try save(sorted)\n    }\n\n    // Export enabled servers to Claude Code user settings (~/.claude/settings.json)\n    // Per official docs, settings.json is the canonical configuration entry point.\n    //\n    // Safety strategy:\n    // - Only modifies the \"mcpServers\" field\n    // - Preserves all other existing configuration\n    // - Creates backup before writing\n    // - Uses atomic write to prevent partial corruption\n    func exportEnabledForClaudeConfig(servers: [MCPServer]? = nil) throws {\n        if !SessionPreferencesStore.isCLIEnabled(.claude) { return }\n        let list: [MCPServer]\n        if let servers {\n            list = servers.enabledServers(for: .claude)\n        } else {\n            list = load().enabledServers(for: .claude)\n        }\n        let realHome = SessionPreferencesStore.getRealUserHomeURL()\n        // User settings file under ~/.claude/settings.json (preferred)\n        let claudeDir = realHome.appendingPathComponent(\".claude\", isDirectory: true)\n        let claudeSettingsPath = claudeDir.appendingPathComponent(\"settings.json\")\n        let codmateDir = realHome.appendingPathComponent(\".codmate\", isDirectory: true)\n        let helperPath = codmateDir.appendingPathComponent(\"mcp-enabled-claude.json\")\n\n        // Step 1: Load existing settings or create empty object\n        var existingConfig: [String: Any] = [:]\n        var existingData: Data? = nil\n        if fm.fileExists(atPath: claudeSettingsPath.path) {\n            existingData = try? Data(contentsOf: claudeSettingsPath)\n            if let data = existingData,\n               let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {\n                existingConfig = json\n            }\n        }\n\n        // Step 2: Build mcpServers object\n        var serversObj: [String: Any] = [:]\n        for s in list {\n            var entry: [String: Any] = [:]\n            if let url = s.url { entry[\"url\"] = url }\n            if let cmd = s.command { entry[\"command\"] = cmd }\n            if let args = s.args { entry[\"args\"] = args }\n            if let env = s.env { entry[\"env\"] = env }\n            if let headers = s.headers { entry[\"headers\"] = headers }\n            serversObj[s.name] = entry\n        }\n\n        // Step 3: Update or remove mcpServers key\n        if serversObj.isEmpty {\n            existingConfig.removeValue(forKey: \"mcpServers\")\n        } else {\n            existingConfig[\"mcpServers\"] = serversObj\n        }\n\n        // Step 4: Write atomically to ~/.claude/settings.json (with backup)\n        try fm.createDirectory(at: claudeDir, withIntermediateDirectories: true)\n        if let backupData = existingData {\n            let backupPath = claudeSettingsPath.appendingPathExtension(\"backup\")\n            try? backupData.write(to: backupPath, options: .atomic)\n        }\n        let settingsData = try JSONSerialization.data(withJSONObject: existingConfig, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes])\n        try settingsData.write(to: claudeSettingsPath, options: .atomic)\n\n        // Legacy helper file (no longer used). Remove if present.\n        if fm.fileExists(atPath: helperPath.path) {\n            try? fm.removeItem(at: helperPath)\n        }\n    }\n\n    func delete(name: String) throws {\n        var list = load()\n        list.removeAll { $0.name == name }\n        try save(list)\n    }\n\n    func setEnabled(name: String, enabled: Bool) throws {\n        var list = load()\n        guard let idx = list.firstIndex(where: { $0.name == name }) else { return }\n        list[idx].enabled = enabled\n        try save(list)\n    }\n\n    func setCapabilityEnabled(name: String, capability: String, enabled: Bool) throws {\n        var list = load()\n        guard let idx = list.firstIndex(where: { $0.name == name }) else { return }\n        var caps = list[idx].capabilities\n        if let cidx = caps.firstIndex(where: { $0.name == capability }) {\n            caps[cidx].enabled = enabled\n        } else {\n            caps.append(MCPCapability(name: capability, enabled: enabled))\n        }\n        list[idx].capabilities = caps\n        try save(list)\n    }\n}\n"
  },
  {
    "path": "services/MainWindowCoordinator.swift",
    "content": "import AppKit\n\nfinal class MainWindowCoordinator: NSObject, NSWindowDelegate {\n  static let shared = MainWindowCoordinator()\n  private weak var window: NSWindow?\n  private var visibility: SystemMenuVisibility = .visible\n  private var didAutoHideOnAttach = false\n  private var lastAppliedVisibility: SystemMenuVisibility?\n\n  var hasAttachedWindow: Bool { window != nil }\n\n  func attach(_ window: NSWindow) {\n    if self.window === window { return }\n    self.window = window\n    window.delegate = self\n    applyVisibilityOnAttachIfNeeded()\n  }\n\n  func windowShouldClose(_ sender: NSWindow) -> Bool {\n    sender.orderOut(nil)\n\n    // Check if settings window is still visible\n    let settingsWindowId = NSUserInterfaceItemIdentifier(\"CodMateSettingsWindow\")\n    let settingsWindowVisible = NSApplication.shared.windows.contains { window in\n      window.identifier == settingsWindowId && window.isVisible\n    }\n\n    // Only hide Dock icon if:\n    // 1. No other app windows are visible, AND\n    // 2. User preference is \"Menu Bar Only\" mode\n    if !settingsWindowVisible && visibility == .menuOnly {\n      NSApplication.shared.setActivationPolicy(.accessory)\n    }\n\n    return false\n  }\n\n  func applyMenuVisibility(_ visibility: SystemMenuVisibility) {\n    self.visibility = visibility\n    let previous = lastAppliedVisibility\n    lastAppliedVisibility = visibility\n    if visibility == .menuOnly, previous != .menuOnly {\n      hideMainWindow()\n    }\n  }\n\n  private func applyVisibilityOnAttachIfNeeded() {\n    guard visibility == .menuOnly, didAutoHideOnAttach == false else { return }\n    hideMainWindow()\n    didAutoHideOnAttach = true\n  }\n\n  private func hideMainWindow() {\n    window?.orderOut(nil)\n  }\n}\n"
  },
  {
    "path": "services/MenuBarController.swift",
    "content": "import AppKit\nimport Combine\nimport Foundation\nimport SwiftUI\n\n@MainActor\nfinal class MenuBarController: NSObject, NSMenuDelegate {\n  static let shared = MenuBarController()\n\n  private let statusMenu = NSMenu()\n  private var statusItem: NSStatusItem?\n  private weak var viewModel: SessionListViewModel?\n  private weak var preferences: SessionPreferencesStore?\n\n  private let providersRegistry = ProvidersRegistryService()\n  private let mcpStore = MCPServersStore()\n  private let skillsStore = SkillsStore()\n  private let skillsSyncer = SkillsSyncService()\n  private let commandsStore = CommandsStore()\n  private let commandsSyncer = CommandsSyncService()\n\n  private var cachedBindings = ProvidersRegistryService.Bindings(\n    activeProvider: nil, defaultModel: nil)\n  private var cachedProviders: [ProvidersRegistryService.Provider] = []\n  private var cachedMCPServers: [MCPServer] = []\n  private var cachedSkills: [SkillRecord] = []\n  private var cachedCommands: [CommandRecord] = []\n  private var refreshTask: Task<Void, Never>?\n  private var actionHandlers: [() -> Void] = []\n  private var preferencesCancellable: AnyCancellable?\n  private var usageCancellable: AnyCancellable?\n  private var isShowingDynamicIcon = false\n  private var isMenuOpen = false\n  private let updateViewModel = UpdateViewModel()\n\n  private let relativeFormatter: RelativeDateTimeFormatter = {\n    let formatter = RelativeDateTimeFormatter()\n    formatter.unitsStyle = .short\n    return formatter\n  }()\n\n  private let usageCountdownFormatter: DateComponentsFormatter = {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = [.day, .hour, .minute]\n    formatter.unitsStyle = .abbreviated\n    formatter.maximumUnitCount = 2\n    formatter.includesTimeRemainingPhrase = false\n    return formatter\n  }()\n\n  private let usageResetFormatter: DateFormatter = {\n    let formatter = DateFormatter()\n    formatter.setLocalizedDateFormatFromTemplate(\"MMM d HH:mm\")\n    return formatter\n  }()\n\n  func configure(viewModel: SessionListViewModel, preferences: SessionPreferencesStore) {\n    self.viewModel = viewModel\n    self.preferences = preferences\n    statusMenu.delegate = self\n    preferencesCancellable?.cancel()\n    preferencesCancellable = preferences.$systemMenuVisibility.sink { [weak self] visibility in\n      self?.applySystemMenuVisibility(visibility)\n    }\n\n    usageCancellable?.cancel()\n    usageCancellable = viewModel.$usageSnapshots\n      .receive(on: RunLoop.main)\n      .sink { [weak self] snapshots in\n        guard let self else { return }\n        self.updateStatusItemIcon(with: snapshots)\n        // Rebuild menu if it's currently open to show updated usage data\n        if self.isMenuOpen {\n          self.rebuildMenu()\n        }\n      }\n\n    applySystemMenuVisibility(preferences.systemMenuVisibility)\n    refreshMenuData()\n  }\n\n  func menuWillOpen(_ menu: NSMenu) {\n    isMenuOpen = true\n    ensureMenuDataLoaded()\n    rebuildMenu()\n    refreshMenuData()\n  }\n\n  func menuDidClose(_ menu: NSMenu) {\n    isMenuOpen = false\n  }\n\n  func reapplyVisibilityFromPreferences() {\n    guard let preferences else { return }\n    applySystemMenuVisibility(preferences.systemMenuVisibility)\n  }\n\n  private func ensureStatusItem() {\n    guard statusItem == nil else { return }\n    let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)\n    item.button?.imagePosition = .imageOnly\n    item.menu = statusMenu\n    statusItem = item\n\n    // Always set a placeholder icon first to avoid blank display\n    applyStaticIcon(to: item.button)\n\n    // Then update with dynamic icon if snapshots are available\n    if let snapshots = viewModel?.usageSnapshots {\n        updateStatusItemIcon(with: snapshots)\n    }\n  }\n\n  private func applyStaticIcon(to button: NSStatusBarButton?) {\n    guard let button else { return }\n    if let image = NSImage(\n      systemSymbolName: \"fossil.shell.fill\", accessibilityDescription: \"CodMate\")\n    {\n      image.isTemplate = true\n      // Apply rotation and flip to make the shell spiral look correct\n      let transformed = horizontallyFlippedImage(image)\n      button.image = transformed ?? image\n    } else {\n      // Fallback: create a placeholder icon if system symbol fails\n      button.image = createPlaceholderIcon()\n    }\n  }\n\n  private func createPlaceholderIcon() -> NSImage {\n    // Create a simple placeholder icon with the same size as menu bar icons\n    // Use a simple circle icon as fallback\n    let size = NSSize(width: 18, height: 18)\n\n    // Try to use a system symbol as fallback\n    if let systemImage = NSImage(systemSymbolName: \"circle.fill\", accessibilityDescription: \"CodMate\") {\n      systemImage.isTemplate = true\n      systemImage.size = size\n      return systemImage\n    }\n\n    // Last resort: create a simple geometric shape\n    let image = NSImage(size: size)\n    image.lockFocus()\n\n    // Draw a simple filled circle\n    let rect = NSRect(origin: .zero, size: size)\n    let path = NSBezierPath(ovalIn: rect.insetBy(dx: 4, dy: 4))\n    NSColor.black.setFill()\n    path.fill()\n\n    image.unlockFocus()\n    image.isTemplate = true\n    return image\n  }\n\n  private func updateStatusItemIcon(with snapshots: [UsageProviderKind: UsageProviderSnapshot]) {\n    guard let button = statusItem?.button else { return }\n    let enabledProviders = orderedEnabledProviders()\n\n    // Check if we have any valid usage data to show\n    let hasData = enabledProviders.contains { provider in\n      guard let snapshot = snapshots[provider] else { return false }\n      return snapshot.availability == .ready || snapshot.origin == .thirdParty\n    }\n\n    guard hasData else {\n        // If no data, keep or revert to static icon\n        if isShowingDynamicIcon || button.image == nil {\n            applyStaticIcon(to: button)\n            isShowingDynamicIcon = false\n        }\n        return\n    }\n\n    let referenceDate = Date()\n    // Use fixed black color for template image generation\n    // System automatically handles coloring (white/black) based on menu bar context\n    let menuBarColor = Color.black\n    let ringStates = enabledProviders.map {\n      ringState(for: $0, relativeTo: referenceDate, snapshots: snapshots, colorOverride: menuBarColor)\n    }\n\n    let view = TripleUsageDonutView(\n      states: ringStates,\n      trackColor: menuBarColor\n    )\n    .scaleEffect(0.7)\n\n    let renderer = ImageRenderer(content: view)\n    // Use higher scale for anti-aliased rendering on Retina displays\n    let backingScale = NSScreen.main?.backingScaleFactor ?? 2.0\n    renderer.scale = backingScale * 2.0  // 4x for 2x display, 6x for 3x display\n\n    if let nsImage = renderer.nsImage {\n        nsImage.isTemplate = true // Use system template mode (ignore colors, use alpha)\n        button.image = nsImage\n        isShowingDynamicIcon = true\n    } else {\n        // Fallback to static icon if dynamic icon rendering fails\n        applyStaticIcon(to: button)\n        isShowingDynamicIcon = false\n    }\n  }\n\n  private func ringState(\n    for provider: UsageProviderKind,\n    relativeTo date: Date,\n    snapshots: [UsageProviderKind: UsageProviderSnapshot],\n    colorOverride: Color? = nil\n  ) -> UsageRingState {\n    let color = colorOverride ?? providerColor(provider)\n    guard let snapshot = snapshots[provider] else {\n      return UsageRingState(progress: nil, baseColor: color, disabled: false)\n    }\n    if snapshot.origin == .thirdParty {\n      return UsageRingState(progress: nil, baseColor: color, disabled: true)\n    }\n    guard snapshot.availability == .ready else {\n      return UsageRingState(progress: nil, baseColor: color, disabled: false)\n    }\n    let urgentMetric = snapshot.urgentMetric(relativeTo: date)\n    return UsageRingState(\n      progress: urgentMetric?.progress,\n      baseColor: color,\n      healthState: urgentMetric?.healthState(relativeTo: date),\n      disabled: false\n    )\n  }\n\n  private func providerColor(_ provider: UsageProviderKind) -> Color {\n    switch provider {\n    case .codex:\n      return Color.accentColor\n    case .claude:\n      return Color(nsColor: .systemPurple)\n    case .gemini:\n      return Color(nsColor: .systemTeal)\n    }\n  }\n\n  private func horizontallyFlippedImage(_ image: NSImage) -> NSImage? {\n    // Create new image with swapped dimensions for 90° rotation\n    let rotatedSize = NSSize(width: image.size.height, height: image.size.width)\n    let transformed = NSImage(size: rotatedSize)\n    transformed.lockFocus()\n\n    let transform = NSAffineTransform()\n    // Move to center of rotated canvas\n    transform.translateX(by: rotatedSize.width / 2, yBy: rotatedSize.height / 2)\n    // Scale to 95% of original size\n    transform.scaleX(by: 0.95, yBy: 0.95)\n    // Rotate 90° clockwise (negative angle for clockwise)\n    transform.rotate(byDegrees: -90)\n    // Flip horizontally (to make shell spiral clockwise)\n    transform.scaleX(by: -1.0, yBy: 1.0)\n    // Move back to draw from center\n    transform.translateX(by: -image.size.width / 2, yBy: -image.size.height / 2)\n    transform.concat()\n\n    image.draw(\n      at: .zero,\n      from: NSRect(origin: .zero, size: image.size),\n      operation: .copy,\n      fraction: 1.0\n    )\n\n    transformed.unlockFocus()\n    transformed.isTemplate = true\n    return transformed\n  }\n\n  private func applySystemMenuVisibility(_ visibility: SystemMenuVisibility) {\n    updateActivationPolicy(for: visibility)\n    switch visibility {\n    case .hidden:\n      if let item = statusItem {\n        NSStatusBar.system.removeStatusItem(item)\n        statusItem = nil\n      }\n    case .visible, .menuOnly:\n      ensureStatusItem()\n      rebuildMenu()\n      refreshMenuData()\n    }\n    MainWindowCoordinator.shared.applyMenuVisibility(visibility)\n  }\n\n  private func updateActivationPolicy(for visibility: SystemMenuVisibility) {\n    #if os(macOS)\n      let app = NSApplication.shared\n      switch visibility {\n      case .hidden:\n        // When menu bar is hidden, show Dock icon so user can still access the app\n        app.setActivationPolicy(.regular)\n      case .visible:\n        // When both are visible, show Dock icon\n        app.setActivationPolicy(.regular)\n      case .menuOnly:\n        // Menu bar only mode - hide Dock icon\n        app.setActivationPolicy(.accessory)\n      }\n    #endif\n  }\n\n  // MARK: - Menu Builders\n\n  private func rebuildMenu() {\n    statusMenu.removeAllItems()\n    actionHandlers.removeAll(keepingCapacity: true)\n    guard viewModel != nil else {\n      statusMenu.addItem(disabledItem(title: \"CodMate starting...\"))\n      return\n    }\n\n    // 0) Show main window\n    let showMainItem = actionItem(\n      title: \"Show CodMate Window\", action: #selector(handleOpenCodMate))\n    applySystemImage(showMainItem, name: \"rectangle.stack\")\n    statusMenu.addItem(showMainItem)\n\n    statusMenu.addItem(.separator())\n\n    // 1) Usage\n    for provider in usageOrder() {\n      let item = makeUsageMenuItem(for: provider)\n      statusMenu.addItem(item)\n    }\n\n    statusMenu.addItem(.separator())\n\n    // 2) Recent Projects\n    let recentProjects = recentProjectEntries(limit: 10)\n    if recentProjects.isEmpty {\n      let item = disabledItem(title: \"No recent projects\")\n      applySystemImage(item, name: \"square.grid.2x2\")\n      statusMenu.addItem(item)\n    } else {\n      for entry in recentProjects {\n        let item = makeProjectMenuItem(entry)\n        statusMenu.addItem(item)\n      }\n    }\n\n    statusMenu.addItem(.separator())\n\n    // 3) Providers\n    for provider in orderedEnabledProviders() {\n      statusMenu.addItem(providerMenuItem(for: provider))\n    }\n\n    statusMenu.addItem(.separator())\n\n    // 4) Extensions\n    let commandsItem = NSMenuItem(title: \"Commands\", action: nil, keyEquivalent: \"\")\n    applySystemImage(commandsItem, name: \"command\")\n    commandsItem.submenu = buildCommandsMenu()\n    statusMenu.addItem(commandsItem)\n\n    let mcpItem = NSMenuItem(title: \"MCP Servers\", action: nil, keyEquivalent: \"\")\n    applySystemImage(mcpItem, name: \"puzzlepiece.extension\")\n    mcpItem.submenu = buildMCPServersMenu()\n    statusMenu.addItem(mcpItem)\n\n    let skillsItem = NSMenuItem(title: \"Skills\", action: nil, keyEquivalent: \"\")\n    applySystemImage(skillsItem, name: \"sparkles\")\n    skillsItem.submenu = buildSkillsMenu()\n    statusMenu.addItem(skillsItem)\n\n    let extensionsItem = actionItem(\n      title: \"Extensions...\", action: #selector(handleOpenExtensionsSettings))\n    applySystemImage(extensionsItem, name: \"puzzlepiece.extension\")\n    statusMenu.addItem(extensionsItem)\n\n    statusMenu.addItem(.separator())\n\n    // 5) Global actions\n    let globalSearchItem = actionItem(\n      title: \"Global Search...\", action: #selector(handleSearchSessions))\n    applySystemImage(globalSearchItem, name: \"magnifyingglass\")\n    statusMenu.addItem(globalSearchItem)\n    let settingsItem = actionItem(title: \"Settings...\", action: #selector(handleOpenSettings))\n    applySystemImage(settingsItem, name: \"gear\")\n    statusMenu.addItem(settingsItem)\n\n    statusMenu.addItem(.separator())\n\n    // 6) About / Updates / Quit\n    let aboutItem = actionItem(title: \"About CodMate\", action: #selector(handleOpenAbout))\n    applySystemImage(aboutItem, name: \"info.circle\")\n    statusMenu.addItem(aboutItem)\n    let updates = actionItem(title: \"Check for Updates...\", action: #selector(handleCheckForUpdates))\n    applySystemImage(updates, name: \"arrow.triangle.2.circlepath\")\n    statusMenu.addItem(updates)\n\n    let quitItem = actionItem(title: \"Quit\", action: #selector(handleQuit))\n    applySystemImage(quitItem, name: \"power\")\n    statusMenu.addItem(quitItem)\n  }\n\n  // MARK: - Menu Item Styling Helpers\n\n  private func makeAlignedMenuTitle(left: String, right: String) -> NSAttributedString {\n    // CRITICAL for macOS 15 modern UI:\n    // - Do NOT use custom tabStops\n    // - Do NOT use custom font sizes\n    // - Use ONLY system default menuFont(ofSize: 0) and color changes\n    // Any deviation triggers legacy menu rendering mode\n\n    let fullString = \"\\(left)  \\(right)\"  // Use spaces instead of tab for simpler layout\n    let attr = NSMutableAttributedString(string: fullString)\n    let fullRange = NSRange(location: 0, length: attr.length)\n\n    // Use system default menu font - the ONLY font we should use for menu items\n    let defaultMenuFont = NSFont.menuFont(ofSize: 0)\n    attr.addAttribute(.font, value: defaultMenuFont, range: fullRange)\n    attr.addAttribute(.foregroundColor, value: NSColor.labelColor, range: fullRange)\n\n    // Style right-side text (secondary color only - no font changes, no paragraph styles)\n    let rightLoc = (left as NSString).length + 2  // +2 for the two spaces\n    if rightLoc < attr.length {\n      let rightRange = NSRange(location: rightLoc, length: (right as NSString).length)\n      if NSMaxRange(rightRange) <= attr.length {\n        attr.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: rightRange)\n      }\n    }\n\n    return attr\n  }\n\n  private func makeProjectMenuItem(_ entry: RecentProjectEntry) -> NSMenuItem {\n    let name = entry.project.name\n    let time = relativeDateString(entry.lastActive)\n\n    let item = NSMenuItem(title: \"\\(name)  \\(time)\", action: nil, keyEquivalent: \"\")\n    item.attributedTitle = makeAlignedMenuTitle(left: name, right: time)\n\n    applySystemImage(item, name: \"square.grid.2x2\")\n    item.submenu = buildProjectMenu(entry)\n\n    return item\n  }\n\n  private func makeSessionMenuItem(_ session: SessionSummary) -> NSMenuItem {\n    let name = session.effectiveTitle\n    let time = relativeDateString(anchorDate(for: session))\n\n    let item = actionItem(title: \"\\(name)  \\(time)\", action: #selector(handleResumeSession(_:)))\n    item.representedObject = session.id\n    item.image = providerImage(for: providerKind(for: session))\n    item.attributedTitle = makeAlignedMenuTitle(left: name, right: time)\n\n    return item\n  }\n\n  private func providerMenuItem(for provider: UsageProviderKind) -> NSMenuItem {\n    let baseTitle = \"\\(provider.displayName) Provider\"\n    let item = NSMenuItem(title: baseTitle, action: nil, keyEquivalent: \"\")\n    item.image = providerImage(for: provider)\n\n    if let rightLabel = activeProviderLabel(for: provider) {\n      item.attributedTitle = makeAlignedMenuTitle(left: baseTitle, right: rightLabel)\n    }\n\n    item.submenu = buildProviderMenu(for: provider)\n    return item\n  }\n\n  // MARK: - Usage Helpers\n\n  private func usageOrder() -> [UsageProviderKind] {\n    [.codex, .claude, .gemini].filter { isCLIEnabled($0) }\n  }\n\n  private func orderedEnabledProviders() -> [UsageProviderKind] {\n    let ordered: [UsageProviderKind] = [.gemini, .claude, .codex]\n    return ordered.filter { isCLIEnabled($0) }\n  }\n\n  private func isCLIEnabled(_ provider: UsageProviderKind) -> Bool {\n    guard let preferences else { return true }\n    return preferences.isCLIEnabled(provider.baseKind)\n  }\n\n  private func makeUsageMenuItem(for provider: UsageProviderKind) -> NSMenuItem {\n    guard let viewModel, let snapshot = viewModel.usageSnapshots[provider] else {\n      let item = NSMenuItem(title: provider.displayName, action: nil, keyEquivalent: \"\")\n      item.image = providerImage(for: provider)\n      item.submenu = buildUsageProviderMenu(provider)\n      return item\n    }\n\n    if snapshot.origin == .thirdParty {\n      let item = NSMenuItem(title: \"\\(provider.displayName) Custom provider\", action: nil, keyEquivalent: \"\")\n      item.image = providerImage(for: provider)\n      item.submenu = buildUsageProviderMenu(provider)\n      return item\n    }\n\n    let item = NSMenuItem(title: \"\", action: nil, keyEquivalent: \"\")\n    item.image = providerImage(for: provider)\n    item.submenu = buildUsageProviderMenu(provider)\n\n    switch snapshot.availability {\n    case .ready:\n      let urgent = snapshot.urgentMetric()\n      let percent = urgent?.percentText ?? \"-\"\n      // Include badge in provider name if available (e.g., \"Codex Free\" or \"Codex Plus\")\n      var providerName = snapshot.title\n      if let badge = snapshot.titleBadge, !badge.isEmpty {\n        providerName = \"\\(providerName) \\(badge)\"\n      }\n      let name = \"\\(providerName) (\\(percent))\"\n      var reset = resetSummaryText(for: urgent)\n      // Capitalize first letter for better presentation\n      if !reset.isEmpty, reset.first?.isLowercase == true {\n        reset = reset.prefix(1).uppercased() + reset.dropFirst()\n      }\n\n      // Always use aligned title to keep the provider name in the same vertical column.\n      // Use a space if reset is empty to ensure the tab stop is applied.\n      item.attributedTitle = makeAlignedMenuTitle(left: name, right: reset.isEmpty ? \" \" : reset)\n\n    case .empty:\n      // Include badge in provider name if available\n      var providerName = snapshot.title\n      if let badge = snapshot.titleBadge, !badge.isEmpty {\n        providerName = \"\\(providerName) \\(badge)\"\n      }\n      item.title = \"\\(providerName) Not available\"\n    case .comingSoon:\n      // Include badge in provider name if available\n      var providerName = snapshot.title\n      if let badge = snapshot.titleBadge, !badge.isEmpty {\n        providerName = \"\\(providerName) \\(badge)\"\n      }\n      item.title = providerName\n    }\n\n    return item\n  }\n\n  private func makeUsageMetricMenuItem(_ metric: UsageMetricSnapshot, referenceDate: Date, provider: UsageProviderKind) -> NSMenuItem {\n    let state = MetricDisplayState(\n      metric: metric, referenceDate: referenceDate, resetFormatter: usageResetFormatter)\n\n    var name = metric.label\n    if provider == .gemini && name.lowercased().hasPrefix(\"gemini-\") {\n      name = String(name.dropFirst(\"gemini-\".count))\n    }\n    if let percent = state.percentText, !percent.isEmpty {\n      name += \" (\\(percent))\"\n    }\n\n    var time = state.resetText\n    if time.hasPrefix(\"Expires at \") {\n      time = String(time.dropFirst(\"Expires at \".count))\n    }\n\n    let item = disabledItem(title: \"\\(name)  \\(time)\")\n    if !time.isEmpty {\n      item.attributedTitle = makeAlignedMenuTitle(left: name, right: time)\n    }\n    return item\n  }\n\n  // MARK: - Submenu Builders\n\n  private func buildUsageProviderMenu(_ provider: UsageProviderKind) -> NSMenu {\n    let menu = NSMenu()\n    guard let viewModel, let snapshot = viewModel.usageSnapshots[provider] else {\n      menu.addItem(disabledItem(title: \"No usage data available\"))\n      return menu\n    }\n\n    if snapshot.origin == .thirdParty {\n      menu.addItem(disabledItem(title: \"Custom provider (usage unavailable)\"))\n      return menu\n    }\n\n    switch snapshot.availability {\n    case .ready:\n      let referenceDate = Date()\n      let metrics = snapshot.metrics.filter { $0.kind != .snapshot && $0.kind != .context }\n      if metrics.isEmpty {\n        menu.addItem(disabledItem(title: \"No usage metrics\"))\n      } else {\n        for metric in metrics {\n          let item = makeUsageMetricMenuItem(metric, referenceDate: referenceDate, provider: provider)\n          menu.addItem(item)\n        }\n      }\n      menu.addItem(.separator())\n      let refreshItem = actionItem(title: updatedLabel(snapshot, referenceDate: referenceDate), action: #selector(handleUsageAction(_:)))\n      refreshItem.representedObject = provider.rawValue\n      applySystemImage(refreshItem, name: \"arrow.clockwise\", fallback: \"arrow.triangle.2.circlepath\")\n      menu.addItem(refreshItem)\n    case .empty:\n      menu.addItem(disabledItem(title: snapshot.statusMessage ?? \"Usage not available\"))\n      if let action = snapshot.action {\n        menu.addItem(.separator())\n        menu.addItem(actionMenuItem(for: action, provider: provider))\n      }\n    case .comingSoon:\n      menu.addItem(disabledItem(title: snapshot.statusMessage ?? \"Usage coming soon\"))\n    }\n\n    return menu\n  }\n\n  private func buildProjectMenu(_ entry: RecentProjectEntry) -> NSMenu {\n    let menu = NSMenu()\n    guard let anchor = projectAnchor(for: entry.project) else {\n      menu.addItem(disabledItem(title: \"No sessions found\"))\n      return menu\n    }\n\n    let newItems = buildNewSessionMenuItems(anchor: anchor)\n    if newItems.isEmpty {\n      menu.addItem(disabledItem(title: \"New Session\"))\n    } else {\n      appendSplitMenuItems(newItems, to: menu)\n    }\n\n    menu.addItem(.separator())\n\n    let sessions = recentSessions(for: entry.project.id)\n    let history = Array(sessions.prefix(10))\n    if history.isEmpty {\n      menu.addItem(disabledItem(title: \"No recent sessions\"))\n      return menu\n    }\n\n    for session in history {\n      let item = makeSessionMenuItem(session)\n      menu.addItem(item)\n    }\n\n    if sessions.count > history.count {\n      let moreItem = actionItem(title: \"More...\", action: #selector(handleShowProjectTasks(_:)))\n      moreItem.representedObject = entry.project.id\n      applySystemImage(moreItem, name: \"list.bullet.rectangle\")\n      menu.addItem(moreItem)\n    }\n\n    return menu\n  }\n\n  private func buildProviderMenu(for provider: UsageProviderKind) -> NSMenu {\n    let menu = NSMenu()\n\n    let consumer: ProvidersRegistryService.Consumer? = {\n      switch provider {\n      case .codex: return .codex\n      case .claude: return .claudeCode\n      case .gemini: return nil\n      }\n    }()\n\n    // For Gemini, use preferences.geminiProxyProviderId instead of Consumer\n    if provider == .gemini {\n      guard let preferences else {\n        menu.addItem(disabledItem(title: \"Preferences not available\"))\n        return menu\n      }\n      let activeId = preferences.geminiProxyProviderId\n\n      // Default (Built-in) option\n      let builtIn = actionItem(title: \"Default (Built-in)\", action: #selector(handleSelectProvider(_:)))\n      builtIn.representedObject = ProviderSelection(consumer: nil, providerId: nil, isGemini: true)\n      builtIn.state = (activeId != UnifiedProviderID.autoProxyId) ? .on : .off\n      menu.addItem(builtIn)\n\n      // Auto-Proxy (CliProxyAPI) option\n      let autoProxy = actionItem(title: \"Auto-Proxy (CliProxyAPI)\", action: #selector(handleSelectProvider(_:)))\n      autoProxy.representedObject = ProviderSelection(consumer: nil, providerId: UnifiedProviderID.autoProxyId, isGemini: true)\n      autoProxy.state = (activeId == UnifiedProviderID.autoProxyId) ? .on : .off\n      menu.addItem(autoProxy)\n\n      return menu\n    }\n\n    guard let consumer else {\n      menu.addItem(disabledItem(title: \"Providers not available\"))\n      return menu\n    }\n\n    let activeId = cachedBindings.activeProvider?[consumer.rawValue]\n\n    // Default (Built-in) option\n    let builtIn = actionItem(title: \"Default (Built-in)\", action: #selector(handleSelectProvider(_:)))\n    builtIn.representedObject = ProviderSelection(consumer: consumer, providerId: nil)\n    builtIn.state = (activeId != UnifiedProviderID.autoProxyId) ? .on : .off\n    menu.addItem(builtIn)\n\n    // Auto-Proxy (CliProxyAPI) option\n    let autoProxy = actionItem(title: \"Auto-Proxy (CliProxyAPI)\", action: #selector(handleSelectProvider(_:)))\n    autoProxy.representedObject = ProviderSelection(consumer: consumer, providerId: UnifiedProviderID.autoProxyId)\n    autoProxy.state = (activeId == UnifiedProviderID.autoProxyId) ? .on : .off\n    menu.addItem(autoProxy)\n\n    return menu\n  }\n\n  private func providerImage(for authProvider: LocalAuthProvider) -> NSImage? {\n    let name: String\n    switch authProvider {\n    case .codex: name = \"ChatGPTIcon\"\n    case .claude: name = \"ClaudeIcon\"\n    case .gemini: name = \"GeminiIcon\"\n    case .antigravity: name = \"AntigravityIcon\"\n    case .qwen: name = \"QwenIcon\"\n    }\n    return ProviderIconThemeHelper.menuImage(named: name)\n  }\n\n  private func apiKeyProviderImage(for provider: ProvidersRegistryService.Provider) -> NSImage? {\n    guard let iconName = iconNameForAPIProvider(provider) else { return nil }\n    return ProviderIconThemeHelper.menuImage(named: iconName)\n  }\n\n  private func iconNameForAPIProvider(_ provider: ProvidersRegistryService.Provider) -> String? {\n    // Use unified icon resource library helper\n    return ProviderIconResource.iconName(for: provider)\n  }\n\n  private func buildMCPServersMenu() -> NSMenu {\n    let menu = NSMenu()\n    let servers = cachedMCPServers.sorted {\n      $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n    }\n    if servers.isEmpty {\n      menu.addItem(disabledItem(title: \"No MCP servers\"))\n      return menu\n    }\n\n    for server in servers.prefix(10) {\n      let item = actionItem(title: server.name, action: #selector(handleToggleMCPServer(_:)))\n      item.representedObject = server.name\n      item.state = server.enabled ? .on : .off\n      menu.addItem(item)\n    }\n\n    return menu\n  }\n\n  private func buildSkillsMenu() -> NSMenu {\n    let menu = NSMenu()\n    let skills = cachedSkills.sorted {\n      $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n    }\n    if skills.isEmpty {\n      menu.addItem(disabledItem(title: \"No skills installed\"))\n      return menu\n    }\n\n    for skill in skills.prefix(10) {\n      let item = actionItem(title: skill.name, action: #selector(handleToggleSkill(_:)))\n      item.representedObject = skill.id\n      item.state = skill.isEnabled ? .on : .off\n      menu.addItem(item)\n    }\n\n    return menu\n  }\n\n  private func buildCommandsMenu() -> NSMenu {\n    let menu = NSMenu()\n    let commands = cachedCommands.sorted {\n      $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending\n    }\n    if commands.isEmpty {\n      menu.addItem(disabledItem(title: \"No commands\"))\n      return menu\n    }\n\n    for command in commands.prefix(10) {\n      let item = actionItem(title: command.name, action: #selector(handleToggleCommand(_:)))\n      item.representedObject = command.id\n      item.state = command.isEnabled ? .on : .off\n      menu.addItem(item)\n    }\n\n    return menu\n  }\n\n  // MARK: - Helpers\n\n  private func activeProviderLabel(for provider: UsageProviderKind) -> String? {\n    // For Gemini, use preferences.geminiProxyProviderId\n    if provider == .gemini {\n      guard let preferences else { return nil }\n      let activeId = preferences.geminiProxyProviderId\n      if let activeId, !activeId.isEmpty, activeId == UnifiedProviderID.autoProxyId {\n        return \"Auto-Proxy\"\n      }\n      return \"Built-in\"\n    }\n\n    let consumer: ProvidersRegistryService.Consumer? = {\n      switch provider {\n      case .codex: return .codex\n      case .claude: return .claudeCode\n      case .gemini: return nil\n      }\n    }()\n    guard let consumer else { return nil }\n\n    let activeId = cachedBindings.activeProvider?[consumer.rawValue]\n    if let activeId, !activeId.isEmpty, activeId == UnifiedProviderID.autoProxyId {\n      return \"Auto-Proxy\"\n    }\n    return \"Built-in\"\n  }\n\n  private func updatedLabel(_ snapshot: UsageProviderSnapshot, referenceDate: Date) -> String {\n    if let updated = snapshot.updatedAt {\n      let relative = relativeFormatter.localizedString(for: updated, relativeTo: referenceDate)\n      return \"Updated \\(relative)\"\n    } else {\n      return \"Waiting for usage data\"\n    }\n  }\n\n  private func resetSummaryText(for metric: UsageMetricSnapshot?) -> String {\n    guard let metric else { return \"\" }\n    if let reset = metric.resetDate {\n      if let countdown = resetCountdown(from: reset, kind: metric.kind) {\n        return countdown\n      }\n      return usageResetFormatter.string(from: reset)\n    }\n    if let minutes = metric.fallbackWindowMinutes {\n      if minutes >= 60 {\n        return String(format: \"%.1fh window\", Double(minutes) / 60.0)\n      }\n      return \"\\(minutes)m window\"\n    }\n    return \"\"\n  }\n\n  private func resetCountdown(from date: Date, kind: UsageMetricSnapshot.Kind) -> String? {\n    let interval = date.timeIntervalSinceNow\n    guard interval > 0 else {\n      return kind == .sessionExpiry ? \"expired\" : \"reset\"\n    }\n    if let formatted = usageCountdownFormatter.string(from: interval) {\n      let verb = kind == .sessionExpiry ? \"expires in\" : \"resets in\"\n      return \"\\(verb) \\(formatted)\"\n    }\n    return nil\n  }\n\n  private func actionMenuItem(\n    for action: UsageProviderSnapshot.Action,\n    provider: UsageProviderKind\n  ) -> NSMenuItem {\n    let label: String\n    switch action {\n    case .refresh:\n      label = \"Load usage\"\n    case .authorizeKeychain:\n      label = \"Grant access\"\n    }\n    let item = actionItem(title: label, action: #selector(handleUsageAction(_:)))\n    item.representedObject = provider.rawValue\n    return item\n  }\n\n  // MARK: - Projects / Sessions Helpers\n\n  private func anchorDate(for session: SessionSummary) -> Date {\n    session.lastUpdatedAt ?? session.startedAt\n  }\n\n  private struct RecentProjectEntry {\n    let project: Project\n    let lastActive: Date\n    let lastSession: SessionSummary?\n  }\n\n  private func recentProjectEntries(limit: Int) -> [RecentProjectEntry] {\n    guard let viewModel else { return [] }\n    let sessions = allSessionSnapshot()\n      .sorted { anchorDate(for: $0) > anchorDate(for: $1) }\n    var seen: Set<String> = []\n    var recent: [RecentProjectEntry] = []\n\n    for session in sessions {\n      guard let pid = viewModel.projectIdForSession(session.id) else { continue }\n      if pid == SessionListViewModel.otherProjectId { continue }\n      guard !seen.contains(pid) else { continue }\n      guard let project = viewModel.projects.first(where: { $0.id == pid }) else { continue }\n      seen.insert(pid)\n      recent.append(\n        RecentProjectEntry(\n          project: project, lastActive: anchorDate(for: session), lastSession: session))\n      if recent.count >= limit { break }\n    }\n\n    // Keep time-based descending order (most recently active projects first)\n    return recent\n  }\n\n  private func recentSessions(for projectId: String) -> [SessionSummary] {\n    guard let viewModel else { return [] }\n    return allSessionSnapshot()\n      .filter { viewModel.projectIdForSession($0.id) == projectId }\n      .sorted { anchorDate(for: $0) > anchorDate(for: $1) }\n  }\n\n  private func projectAnchor(for project: Project) -> SessionSummary? {\n    guard let viewModel else { return nil }\n    if let visible = viewModel.sections.flatMap({ $0.sessions }).first(where: {\n      viewModel.projectIdForSession($0.id) == project.id\n    }) {\n      return visible\n    }\n    return allSessionSnapshot().first(where: { viewModel.projectIdForSession($0.id) == project.id })\n  }\n\n  private func relativeDateString(_ date: Date) -> String {\n    relativeFormatter.localizedString(for: date, relativeTo: Date())\n  }\n\n  // MARK: - New Session Menu (Project)\n\n  private func buildNewSessionMenuItems(anchor: SessionSummary) -> [MenuNode] {\n    guard let viewModel else { return [] }\n    let allowed = Set(viewModel.allowedSources(for: anchor))\n    let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini]\n    let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted()\n\n    func sourceKey(_ source: SessionSource) -> String {\n      switch source {\n      case .codexLocal: return \"codex-local\"\n      case .codexRemote(let host): return \"codex-\\(host)\"\n      case .claudeLocal: return \"claude-local\"\n      case .claudeRemote(let host): return \"claude-\\(host)\"\n      case .geminiLocal: return \"gemini-local\"\n      case .geminiRemote(let host): return \"gemini-\\(host)\"\n      }\n    }\n\n    func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource {\n      switch base {\n      case .codex: return .codexRemote(host: host)\n      case .claude: return .claudeRemote(host: host)\n      case .gemini: return .geminiRemote(host: host)\n      }\n    }\n\n    // Build \"New with\" menu items - directly launch with default terminal\n    var menuItems: [MenuNode] = []\n    for base in requestedOrder where allowed.contains(base) {\n      let providerKind = providerKindForBase(base)\n      let icon = providerImage(for: providerKind)\n      let menuTitle = \"New with \\(base.displayName)\"\n\n      // If no remote hosts, create a simple action item\n      if enabledRemoteHosts.isEmpty {\n        menuItems.append(\n          .action(\n            id: \"new-\\(base.rawValue)\",\n            title: menuTitle,\n            icon: icon,\n            run: { [weak self] in\n              self?.launchNewSessionWithDefaultTerminal(for: anchor, using: base.sessionSource)\n            }\n          )\n        )\n      } else {\n        // If remote hosts exist, create a submenu\n        var providerItems: [MenuNode] = [\n          .action(\n            id: \"new-\\(base.rawValue)-local\",\n            title: \"Local\",\n            run: { [weak self] in\n              self?.launchNewSessionWithDefaultTerminal(for: anchor, using: base.sessionSource)\n            }\n          )\n        ]\n\n        providerItems.append(.separator)\n        for host in enabledRemoteHosts {\n          let remote = remoteSource(for: base, host: host)\n          providerItems.append(\n            .action(\n              id: \"new-\\(base.rawValue)-\\(host)\",\n              title: host,\n              run: { [weak self] in\n                self?.launchNewSessionWithDefaultTerminal(for: anchor, using: remote)\n              }\n            )\n          )\n        }\n\n        menuItems.append(\n          .submenu(\n            id: \"newwith-\\(base.rawValue)\", title: menuTitle, icon: icon, children: providerItems)\n        )\n      }\n    }\n\n    if menuItems.isEmpty {\n      let fallbackSource = anchor.source\n      let fallbackKind = providerKind(for: anchor)\n      let fallbackIcon = providerImage(for: fallbackKind)\n      menuItems.append(\n        .action(\n          id: \"newwith-fallback\",\n          title: \"New with \\(fallbackSource.branding.displayName)\",\n          icon: fallbackIcon,\n          run: { [weak self] in\n            self?.launchNewSessionWithDefaultTerminal(for: anchor, using: fallbackSource)\n          }\n        )\n      )\n    }\n\n    return menuItems\n  }\n\n  private func launchNewSession(\n    for session: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    guard let viewModel else { return }\n    viewModel.launchNewSessionWithProfile(\n      session: session,\n      using: source,\n      profile: profile,\n      workingDirectory: session.cwd\n    )\n  }\n\n  private func launchNewSessionWithDefaultTerminal(\n    for session: SessionSummary,\n    using source: SessionSource\n  ) {\n    guard let preferences else { return }\n    let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n      id: preferences.defaultResumeExternalAppId\n    )\n    guard let profile else { return }\n    launchNewSession(for: session, using: source, profile: profile)\n  }\n\n  // MARK: - Menu Node Builder\n\n  private enum MenuNode {\n    case action(id: String, title: String, icon: NSImage? = nil, run: () -> Void)\n    case separator\n    case submenu(id: String, title: String, icon: NSImage? = nil, children: [MenuNode])\n  }\n\n  private func appendSplitMenuItems(_ items: [MenuNode], to menu: NSMenu) {\n    for item in items {\n      switch item {\n      case .separator:\n        menu.addItem(.separator())\n      case .action(let id, let title, let icon, let run):\n        let mi = actionItem(title: title, action: #selector(handleDynamicAction(_:)))\n        mi.tag = registerAction(run)\n        mi.identifier = NSUserInterfaceItemIdentifier(id)\n        if let icon = icon {\n          mi.image = icon\n        }\n        menu.addItem(mi)\n      case .submenu(_, let title, let icon, let children):\n        let mi = NSMenuItem(title: title, action: nil, keyEquivalent: \"\")\n        if let icon = icon {\n          mi.image = icon\n        }\n        let sub = NSMenu(title: title)\n        appendSplitMenuItems(children, to: sub)\n        mi.submenu = sub\n        menu.addItem(mi)\n      }\n    }\n  }\n\n  private func registerAction(_ action: @escaping () -> Void) -> Int {\n    actionHandlers.append(action)\n    return actionHandlers.count - 1\n  }\n\n  private func actionItem(title: String, action: Selector) -> NSMenuItem {\n    let item = NSMenuItem(title: title, action: action, keyEquivalent: \"\")\n    item.target = self\n    return item\n  }\n\n  // MARK: - Provider / Extension Data\n\n  private func refreshMenuData() {\n    refreshTask?.cancel()\n    refreshTask = Task { [weak self] in\n      guard let self else { return }\n      if SecurityScopedBookmarks.shared.isSandboxed {\n        let home = SessionPreferencesStore.getRealUserHomeURL()\n        let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n          directory: codmate, purpose: .generalAccess)\n      }\n\n      async let bindings = providersRegistry.getBindings()\n      async let providers = providersRegistry.listProviders()\n      async let mcpServers = mcpStore.list()\n      async let skills = skillsStore.list()\n      async let commands = commandsStore.listWithBuiltIns()\n\n      let (bindingsResult, providersResult, mcpResult, skillsResult, commandsResult) = await (\n        bindings, providers, mcpServers, skills, commands\n      )\n\n      await MainActor.run {\n        self.cachedBindings = bindingsResult\n        self.cachedProviders = providersResult\n        self.cachedMCPServers = mcpResult\n        self.cachedSkills = skillsResult\n        self.cachedCommands = commandsResult\n        self.rebuildMenu()\n      }\n    }\n  }\n\n  private func ensureMenuDataLoaded() {\n    guard let viewModel else { return }\n    if viewModel.allSessions.isEmpty && !viewModel.isLoading {\n      Task { [weak self] in\n        await viewModel.refreshSessions(force: true)\n        await MainActor.run { self?.rebuildMenu() }\n      }\n    }\n    if viewModel.usageSnapshots.isEmpty {\n      viewModel.requestUsageStatusRefresh(for: .codex)\n      viewModel.requestUsageStatusRefresh(for: .claude)\n      viewModel.requestUsageStatusRefresh(for: .gemini)\n      Task { [weak self] in\n        try? await Task.sleep(nanoseconds: 300_000_000)\n        await MainActor.run { self?.rebuildMenu() }\n      }\n    }\n  }\n\n  private func providerDisplayName(_ provider: ProvidersRegistryService.Provider) -> String {\n    provider.name?.isEmpty == false ? provider.name! : provider.id\n  }\n\n  private func systemMenuImage(_ name: String, fallback: String? = nil) -> NSImage? {\n    let image =\n      NSImage(systemSymbolName: name, accessibilityDescription: nil)\n      ?? (fallback.flatMap { NSImage(systemSymbolName: $0, accessibilityDescription: nil) })\n    guard let image else { return nil }\n    image.isTemplate = true\n    image.size = NSSize(width: 14, height: 14)\n    return image\n  }\n\n  private func applySystemImage(_ item: NSMenuItem, name: String, fallback: String? = nil) {\n    if let image = systemMenuImage(name, fallback: fallback) {\n      item.image = image\n    }\n  }\n\n  private func providerImage(for provider: UsageProviderKind) -> NSImage? {\n    let name: String\n    switch provider {\n    case .codex: name = \"ChatGPTIcon\"\n    case .claude: name = \"ClaudeIcon\"\n    case .gemini: name = \"GeminiIcon\"\n    }\n    return ProviderIconThemeHelper.menuImage(named: name)\n  }\n\n\n  private func providerKind(for session: SessionSummary) -> UsageProviderKind {\n    switch session.source.baseKind {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n\n  private func providerKindForBase(_ base: ProjectSessionSource) -> UsageProviderKind {\n    switch base {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n\n  private func disabledItem(title: String) -> NSMenuItem {\n    let item = NSMenuItem(title: title, action: nil, keyEquivalent: \"\")\n    item.isEnabled = false\n    return item\n  }\n\n  private func allSessionSnapshot() -> [SessionSummary] {\n    guard let viewModel else { return [] }\n    if !viewModel.allSessions.isEmpty { return viewModel.allSessions }\n    return viewModel.sections.flatMap(\\.sessions)\n  }\n\n  // MARK: - Actions\n\n  @objc private func handleDynamicAction(_ sender: NSMenuItem) {\n    let idx = sender.tag\n    guard idx >= 0 && idx < actionHandlers.count else { return }\n    actionHandlers[idx]()\n  }\n\n  @objc private func handleUsageAction(_ sender: NSMenuItem) {\n    guard let raw = sender.representedObject as? String,\n      let provider = UsageProviderKind(rawValue: raw)\n    else { return }\n    viewModel?.requestUsageStatusRefresh(for: provider)\n  }\n\n  @objc private func handleSearchSessions() {\n    activateApp(raiseWindows: true)\n    NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil)\n  }\n\n  @objc private func handleOpenCodMate() {\n    activateApp(raiseWindows: true)\n  }\n\n  /// Public method to handle app activation from Dock icon clicks or other external triggers\n  func handleDockIconClick() {\n    activateApp(raiseWindows: true)\n  }\n\n  @objc private func handleOpenSettings() {\n    activateApp(raiseWindows: false)\n    NotificationCenter.default.post(name: .codMateOpenSettings, object: nil)\n  }\n\n  @objc private func handleOpenAbout() {\n    activateApp(raiseWindows: false)\n    NotificationCenter.default.post(\n      name: .codMateOpenSettings,\n      object: nil,\n      userInfo: [\"category\": SettingCategory.about.rawValue]\n    )\n  }\n\n  @objc private func handleCheckForUpdates() {\n    updateViewModel.checkNow()\n    activateApp(raiseWindows: false)\n    NotificationCenter.default.post(\n      name: .codMateOpenSettings,\n      object: nil,\n      userInfo: [\"category\": SettingCategory.about.rawValue]\n    )\n  }\n\n  @objc private func handleOpenExtensionsSettings() {\n    activateApp(raiseWindows: false)\n    NotificationCenter.default.post(\n      name: .codMateOpenSettings,\n      object: nil,\n      userInfo: [\n        \"category\": SettingCategory.mcpServer.rawValue,\n        \"extensionsTab\": ExtensionsSettingsTab.mcp.rawValue,\n      ]\n    )\n  }\n\n  @objc func handleQuit() {\n    guard let preferences else {\n      NSApp.terminate(nil)\n      return\n    }\n\n    // Check if any app windows are visible (main or settings)\n    let visibleWindows = NSApp.windows.filter { window in\n      window.isVisible &&\n      (window.identifier == NSUserInterfaceItemIdentifier(\"CodMateMainWindow\") ||\n       window.identifier == NSUserInterfaceItemIdentifier(\"CodMateSettingsWindow\"))\n    }\n\n    // If windows are open, just close them instead of quitting\n    if !visibleWindows.isEmpty {\n      for window in visibleWindows {\n        window.close()\n      }\n      // Only hide Dock icon if user preference is \"Menu Bar Only\" mode\n      if preferences.systemMenuVisibility == .menuOnly {\n        NSApp.setActivationPolicy(.accessory)\n      }\n      return\n    }\n\n    // No windows open - proceed with quit confirmation\n    if preferences.confirmBeforeQuit {\n      let alert = NSAlert()\n      alert.messageText = \"Are you sure you want to quit CodMate?\"\n      alert.informativeText = \"CodMate will hide in the menu bar. You can access it anytime.\"\n      alert.alertStyle = .warning\n      alert.addButton(withTitle: \"Quit\")\n      alert.addButton(withTitle: \"Cancel\")\n\n      let response = alert.runModal()\n      if response == .alertSecondButtonReturn {\n        // User clicked Cancel\n        return\n      }\n    }\n\n    NSApp.terminate(nil)\n  }\n\n  @objc private func handleResumeSession(_ sender: NSMenuItem) {\n    guard let id = sender.representedObject as? String else { return }\n    guard let session = viewModel?.sessionSummary(for: id) else { return }\n    resumeSession(session)\n  }\n\n  @objc private func handleShowProjectTasks(_ sender: NSMenuItem) {\n    guard let projectId = sender.representedObject as? String else { return }\n    guard let viewModel else { return }\n    activateApp(raiseWindows: true)\n    viewModel.setSelectedProject(projectId)\n    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak viewModel] in\n      viewModel?.projectWorkspaceMode = .tasks\n    }\n  }\n\n  private final class ProviderSelection: NSObject {\n    let consumer: ProvidersRegistryService.Consumer?\n    let providerId: String?\n    let isGemini: Bool\n    init(consumer: ProvidersRegistryService.Consumer?, providerId: String?, isGemini: Bool = false) {\n      self.consumer = consumer\n      self.providerId = providerId\n      self.isGemini = isGemini\n    }\n  }\n\n  @objc private func handleSelectProvider(_ sender: NSMenuItem) {\n    guard let selection = sender.representedObject as? ProviderSelection else { return }\n    Task { [weak self] in\n      guard let self else { return }\n      if selection.isGemini {\n        await applyGeminiProviderSelection(providerId: selection.providerId)\n      } else if let consumer = selection.consumer {\n        switch consumer {\n        case .codex:\n          await applyCodexProviderSelection(providerId: selection.providerId)\n        case .claudeCode:\n          await applyClaudeProviderSelection(providerId: selection.providerId)\n        }\n      }\n      await MainActor.run { self.refreshMenuData() }\n    }\n  }\n\n  @objc private func handleToggleMCPServer(_ sender: NSMenuItem) {\n    guard let name = sender.representedObject as? String else { return }\n    Task { [weak self] in\n      await self?.toggleMCPServer(named: name)\n    }\n  }\n\n  @objc private func handleToggleSkill(_ sender: NSMenuItem) {\n    guard let id = sender.representedObject as? String else { return }\n    Task { [weak self] in\n      await self?.toggleSkill(id: id)\n    }\n  }\n\n  @objc private func handleToggleCommand(_ sender: NSMenuItem) {\n    guard let id = sender.representedObject as? String else { return }\n    Task { [weak self] in\n      await self?.toggleCommand(id: id)\n    }\n  }\n\n  private func resumeSession(_ session: SessionSummary) {\n    guard let preferences else { return }\n    activateApp(raiseWindows: true)\n    if preferences.defaultResumeUseEmbeddedTerminal {\n      NotificationCenter.default.post(\n        name: .codMateResumeSession,\n        object: nil,\n        userInfo: [\"sessionId\": session.id]\n      )\n    } else {\n      openPreferredExternal(session)\n    }\n  }\n\n  private func openPreferredExternal(_ session: SessionSummary) {\n    guard let viewModel, let preferences else { return }\n    guard\n      let profile = ExternalTerminalProfileStore.shared\n        .resolvePreferredProfile(id: preferences.defaultResumeExternalAppId)\n    else { return }\n\n    guard viewModel.copyResumeCommandsIfEnabled(session: session, destinationApp: profile) else {\n      return\n    }\n\n    let dir = viewModel.resolvedWorkingDirectory(for: session)\n    var didNotify = false\n    if profile.isNone {\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n\n    if profile.usesWarpCommands {\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n    } else if profile.isTerminal {\n      if !viewModel.openInTerminal(session: session) {\n        _ = viewModel.copyResumeCommandsIfEnabled(session: session, destinationApp: profile)\n        _ = viewModel.openAppleTerminal(at: dir)\n        if viewModel.shouldCopyCommandsToClipboard {\n          if viewModel.preferences.commandCopyNotificationsEnabled {\n            Task {\n              await SystemNotifier.shared.notify(\n                title: \"CodMate\",\n                body: \"Command copied. Paste it in the opened terminal.\"\n              )\n            }\n            didNotify = true\n          }\n        }\n      }\n    } else {\n      let cmd =\n        profile.supportsCommandResolved\n        ? viewModel.buildResumeCLIInvocationRespectingProject(session: session)\n        : nil\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n    }\n\n    if viewModel.shouldCopyCommandsToClipboard,\n      didNotify == false,\n      viewModel.preferences.commandCopyNotificationsEnabled\n    {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n  }\n\n  private func applyCodexProviderSelection(providerId: String?) async {\n    do {\n      try await providersRegistry.setActiveProvider(.codex, providerId: providerId)\n      // For OAuth accounts, providerId is in format \"oauth:provider:accountId\"\n      // For API key providers, providerId is in format \"api:providerId\"\n      // For Auto-Proxy, providerId is UnifiedProviderID.autoProxyId\n      let parsed = UnifiedProviderID.parse(providerId ?? \"\")\n      if case .api(let apiId) = parsed {\n        // Only apply for API key providers\n        let all = await providersRegistry.listAllProviders()\n        let provider = all.first(where: { $0.id == apiId })\n        try await CodexConfigService().applyProviderFromRegistry(provider)\n      } else {\n        // For OAuth accounts and Auto-Proxy, no need to apply (handled by CLI Proxy API)\n        try await CodexConfigService().applyProviderFromRegistry(nil)\n      }\n    } catch {\n      await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Failed to switch provider.\")\n    }\n  }\n\n  private func applyClaudeProviderSelection(providerId: String?) async {\n    do {\n      try await providersRegistry.setActiveProvider(.claudeCode, providerId: providerId)\n    } catch {\n      await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Failed to switch provider.\")\n      return\n    }\n\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n        directory: home, purpose: .generalAccess)\n    }\n\n    let settings = ClaudeSettingsService()\n    let isBuiltin = (providerId == nil)\n\n    // Check if this is an OAuth account (format: \"oauth:provider:accountId\") or Auto-Proxy\n    let parsed = UnifiedProviderID.parse(providerId ?? \"\")\n    let isOAuth: Bool\n    if case .oauth = parsed {\n      isOAuth = true\n    } else {\n      isOAuth = false\n    }\n    let isAutoProxy: Bool\n    if case .autoProxy = parsed {\n      isAutoProxy = true\n    } else {\n      isAutoProxy = false\n    }\n\n    if isBuiltin {\n      try? await settings.setModel(nil)\n      try? await settings.setEnvBaseURL(nil)\n      try? await settings.setForceLoginMethod(nil)\n      try? await settings.setEnvToken(nil)\n      return\n    }\n\n    // For OAuth accounts and Auto-Proxy, no need to configure settings (handled by CLI Proxy API)\n    if isOAuth || isAutoProxy {\n      return\n    }\n\n    let providers = await providersRegistry.listAllProviders()\n    // For API key providers, extract the actual provider ID\n    let actualProviderId: String?\n    if case .api(let apiId) = parsed {\n      actualProviderId = apiId\n    } else {\n      actualProviderId = providerId\n    }\n    guard let provider = providers.first(where: { $0.id == actualProviderId }) else { return }\n    let connector = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n    let loginMethod =\n      connector?.loginMethod?.lowercased() == \"subscription\" ? \"subscription\" : \"api\"\n\n    if let base = connector?.baseURL?.trimmingCharacters(in: .whitespacesAndNewlines), !base.isEmpty\n    {\n      try? await settings.setEnvBaseURL(base)\n    } else {\n      try? await settings.setEnvBaseURL(nil)\n    }\n\n    if loginMethod == \"api\" {\n      try? await settings.setForceLoginMethod(\"console\")\n    } else {\n      try? await settings.setForceLoginMethod(nil)\n    }\n\n    if loginMethod == \"api\" {\n      var token: String? = nil\n      let keyName = provider.envKey ?? connector?.envKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n      let env = ProcessInfo.processInfo.environment\n      if let val = env[keyName], !val.isEmpty {\n        token = val\n      } else {\n        let looksLikeToken =\n          keyName.lowercased().contains(\"sk-\") || keyName.hasPrefix(\"eyJ\") || keyName.contains(\".\")\n        if looksLikeToken { token = keyName }\n      }\n      try? await settings.setEnvToken(token)\n    } else {\n      try? await settings.setEnvToken(nil)\n    }\n  }\n\n  private func applyGeminiProviderSelection(providerId: String?) async {\n    guard let preferences else { return }\n    // For Gemini, just update preferences.geminiProxyProviderId\n    // No need to apply provider configuration (handled by CLI Proxy API or built-in)\n    preferences.geminiProxyProviderId = providerId\n  }\n\n  private func toggleMCPServer(named name: String) async {\n    do {\n      if SecurityScopedBookmarks.shared.isSandboxed {\n        let home = SessionPreferencesStore.getRealUserHomeURL()\n        let codmate = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        let codex = home.appendingPathComponent(\".codex\", isDirectory: true)\n        _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n          directory: codmate, purpose: .generalAccess)\n        _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n          directory: codex, purpose: .generalAccess)\n        _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n          directory: home, purpose: .generalAccess)\n      }\n\n      let list = await mcpStore.list()\n      guard let server = list.first(where: { $0.name == name }) else { return }\n      try await mcpStore.setEnabled(name: name, enabled: !server.enabled)\n      let updated = await mcpStore.list()\n      let codex = CodexConfigService()\n      try? await codex.applyMCPServers(updated)\n      try? await mcpStore.exportEnabledForClaudeConfig(servers: updated)\n      let gemini = GeminiSettingsService()\n      try? await gemini.applyMCPServers(updated)\n      await MainActor.run {\n        cachedMCPServers = updated\n        rebuildMenu()\n      }\n    } catch {\n      await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Failed to update MCP server.\")\n    }\n  }\n\n  private func toggleSkill(id: String) async {\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n        directory: home, purpose: .generalAccess)\n    }\n\n    var records = await skillsStore.list()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return }\n    records[idx].isEnabled.toggle()\n    await skillsStore.saveAll(records)\n\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n      directory: home.appendingPathComponent(\".codex\", isDirectory: true),\n      purpose: .generalAccess,\n      message: \"Authorize ~/.codex to sync Codex skills\"\n    )\n    AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n      directory: home.appendingPathComponent(\".claude\", isDirectory: true),\n      purpose: .generalAccess,\n      message: \"Authorize ~/.claude to sync Claude skills\"\n    )\n    let warnings = await skillsSyncer.syncGlobal(skills: records)\n    if warnings.first != nil {\n      await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Failed to sync skills.\")\n    }\n\n    await MainActor.run {\n      cachedSkills = records\n      rebuildMenu()\n    }\n  }\n\n  private func toggleCommand(id: String) async {\n    if SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      _ = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n        directory: home, purpose: .generalAccess)\n    }\n\n    await commandsStore.update(id: id) { record in\n      record.isEnabled.toggle()\n    }\n\n    let records = await commandsStore.listWithBuiltIns()\n\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n      directory: home.appendingPathComponent(\".codex\", isDirectory: true),\n      purpose: .generalAccess,\n      message: \"Authorize ~/.codex to sync Codex commands\"\n    )\n    AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n      directory: home.appendingPathComponent(\".claude\", isDirectory: true),\n      purpose: .generalAccess,\n      message: \"Authorize ~/.claude to sync Claude commands\"\n    )\n    let warnings = await commandsSyncer.syncGlobal(commands: records)\n    if warnings.first != nil {\n      await SystemNotifier.shared.notify(title: \"CodMate\", body: \"Failed to sync commands.\")\n    }\n\n    await MainActor.run {\n      cachedCommands = records\n      rebuildMenu()\n    }\n  }\n\n  private func activateApp(raiseWindows: Bool) {\n    // If in .accessory mode, temporarily switch to .regular to allow window activation\n    let needsRegularMode = NSApp.activationPolicy() == .accessory\n    if needsRegularMode {\n      NSApp.setActivationPolicy(.regular)\n    }\n\n    NSApp.activate(ignoringOtherApps: true)\n    guard raiseWindows else { return }\n\n    // Prioritize main window to ensure Dock clicks and menu actions show the main window\n    let mainWindowId = NSUserInterfaceItemIdentifier(\"CodMateMainWindow\")\n    if let mainWindow = NSApp.windows.first(where: { $0.identifier == mainWindowId }) {\n      mainWindow.makeKeyAndOrderFront(nil)\n      return\n    }\n\n    // Fallback: try to find and activate any other visible window\n    let keyable = NSApp.windows.filter { $0.canBecomeKey }\n    if let front = keyable.first(where: { $0.isVisible }) {\n      front.makeKeyAndOrderFront(nil)\n      return\n    }\n\n    // Last resort: post notification to create/show main window (e.g., first launch)\n    NotificationCenter.default.post(name: .codMateOpenMainWindow, object: nil)\n  }\n}\n\nprivate struct MetricDisplayState {\n  var progress: Double?\n  var usageText: String?\n  var percentText: String?\n  var resetText: String\n\n  init(metric: UsageMetricSnapshot, referenceDate: Date, resetFormatter: DateFormatter) {\n    let expired = metric.resetDate.map { $0 <= referenceDate } ?? false\n    if expired {\n      progress = metric.progress != nil ? 0 : nil\n      percentText = metric.percentText != nil ? \"0%\" : nil\n      if metric.kind == .fiveHour {\n        usageText = \"No usage since reset\"\n      } else {\n        usageText = metric.usageText\n      }\n      if metric.kind == .fiveHour {\n        resetText = \"Reset\"\n      } else {\n        resetText = \"\"\n      }\n    } else {\n      progress = metric.progress\n      percentText = metric.percentText\n      usageText = Self.remainingText(for: metric, referenceDate: referenceDate)\n      resetText = Self.resetDescription(for: metric, resetFormatter: resetFormatter)\n    }\n  }\n\n  private static func remainingText(for metric: UsageMetricSnapshot, referenceDate: Date) -> String?\n  {\n    guard let resetDate = metric.resetDate else {\n      return metric.usageText\n    }\n\n    let remaining = resetDate.timeIntervalSince(referenceDate)\n    if remaining <= 0 {\n      return metric.kind == .sessionExpiry ? \"Expired\" : \"Reset\"\n    }\n\n    let minutes = Int(remaining / 60)\n    let hours = minutes / 60\n    let days = hours / 24\n\n    switch metric.kind {\n    case .fiveHour:\n      let mins = minutes % 60\n      if hours > 0 { return \"\\(hours)h \\(mins)m remaining\" }\n      return \"\\(mins)m remaining\"\n    case .weekly:\n      let remainingHours = hours % 24\n      if days > 0 {\n        if remainingHours > 0 { return \"\\(days)d \\(remainingHours)h remaining\" }\n        return \"\\(days)d remaining\"\n      } else if hours > 0 {\n        let mins = minutes % 60\n        return \"\\(hours)h \\(mins)m remaining\"\n      }\n      return \"\\(minutes)m remaining\"\n    case .sessionExpiry, .quota:\n      let mins = minutes % 60\n      if hours > 0 { return \"\\(hours)h \\(mins)m remaining\" }\n      return \"\\(mins)m remaining\"\n    case .context, .snapshot:\n      return metric.usageText\n    }\n  }\n\n  private static func resetDescription(\n    for metric: UsageMetricSnapshot, resetFormatter: DateFormatter\n  ) -> String {\n    if let date = metric.resetDate {\n      let prefix = metric.kind == .sessionExpiry ? \"Expires at \" : \"\"\n      return prefix + resetFormatter.string(from: date)\n    }\n    if let minutes = metric.fallbackWindowMinutes {\n      if minutes >= 60 {\n        return String(format: \"%.1fh window\", Double(minutes) / 60.0)\n      }\n      return \"\\(minutes) min window\"\n    }\n    return \"\"\n  }\n}\n"
  },
  {
    "path": "services/PathTreeStore.swift",
    "content": "import Foundation\n\n// Actor to maintain an incrementally updatable directory tree built from cwd counts.\nactor PathTreeStore {\n  private var root: PathTreeNode? = nil\n  private var rootPrefix: [String] = []\n\n  func currentRoot() -> PathTreeNode? { root }\n\n  func applySnapshot(counts: [String: Int]) -> PathTreeNode? {\n    guard !counts.isEmpty else {\n      root = nil\n      rootPrefix = []\n      return nil\n    }\n    let newRoot = counts.buildPathTreeFromCounts()\n    root = newRoot\n    if let id = newRoot?.id {\n      rootPrefix = URL(fileURLWithPath: id, isDirectory: true).pathComponents\n    } else {\n      rootPrefix = []\n    }\n    return root\n  }\n\n  // Apply a delta map: path -> +/- count. Returns nil if a rebuild is required.\n  func applyDelta(_ delta: [String: Int]) -> PathTreeNode? {\n    guard !delta.isEmpty else { return root }\n    guard var current = root else {\n      // Nothing to update incrementally; signal rebuild\n      return nil\n    }\n\n    func isPrefix(_ prefix: [String], of array: [String]) -> Bool {\n      guard prefix.count <= array.count else { return false }\n      if prefix.isEmpty { return true }\n      let slice = Array(array.prefix(prefix.count))\n      return slice.elementsEqual(prefix)\n    }\n\n    // Verify all paths stay within the same root prefix; otherwise request rebuild\n    for (path, _) in delta {\n      let comps = URL(fileURLWithPath: path, isDirectory: true).pathComponents\n      if !isPrefix(rootPrefix, of: comps) {\n        return nil\n      }\n    }\n\n    // Mutating helpers\n    func ensureChildren(_ node: inout PathTreeNode) {\n      if node.children == nil { node.children = [] }\n    }\n\n    func buildChain(from current: PathTreeNode, components: [String], startIndex: Int, delta: Int) -> PathTreeNode {\n      var node = current\n      var pathSoFar = URL(fileURLWithPath: node.id, isDirectory: true).pathComponents\n      for i in startIndex..<components.count {\n        pathSoFar.append(components[i])\n        let id = NSString.path(withComponents: pathSoFar)\n        ensureChildren(&node)\n        var child = PathTreeNode(id: id, name: components[i], count: 0, children: [])\n        child.count += delta\n        node.children?.append(child)\n        node.count += delta\n        node = child\n      }\n      return current\n    }\n\n    func updatedNode(_ node: PathTreeNode, components: [String], index: Int, delta: Int) -> PathTreeNode? {\n      var n = node\n      n.count += delta\n      if index >= components.count { return n }\n\n      let nextName = components[index]\n      let targetId = NSString.path(withComponents: Array(URL(fileURLWithPath: n.id, isDirectory: true).pathComponents + [nextName]))\n      ensureChildren(&n)\n      if let idx = n.children?.firstIndex(where: { $0.id == targetId }) {\n        if let childUpdated = updatedNode(n.children![idx], components: components, index: index + 1, delta: delta) {\n          n.children![idx] = childUpdated\n        } else {\n          return nil\n        }\n      } else {\n        // Missing intermediate node: request a rebuild instead of creating deep chains here\n        return nil\n      }\n\n      // Prune zero-count children without descendants\n      if var kids = n.children {\n        kids.removeAll { $0.count <= 0 && ($0.children == nil || $0.children!.isEmpty) }\n        n.children = kids.isEmpty ? nil : kids\n      }\n      return n\n    }\n\n    // Apply each delta, bailing out if any update fails\n    for (path, d) in delta {\n      if d == 0 { continue }\n      let comps = URL(fileURLWithPath: path, isDirectory: true).pathComponents\n      guard let updated = updatedNode(current, components: comps, index: rootPrefix.count, delta: d) else { return nil }\n      current = updated\n    }\n    // All deltas applied successfully\n    root = current\n    return root\n  }\n}\n"
  },
  {
    "path": "services/PresetPromptsStore.swift",
    "content": "import AppKit\nimport Foundation\n\n// Simple, opt-in preset prompts loader.\n// Looks for per-project overrides first, then user-level config, then falls back to built-ins provided by caller.\n// Thread-safe via actor; caches small reads by path+mtime.\nactor PresetPromptsStore {\n    struct Prompt: Hashable, Codable {\n        var label: String\n        var command: String\n    }\n    enum PromptLocation { case project, user, builtin }\n\n    static let shared = PresetPromptsStore()\n\n    private var cache: [String: (mtime: Date, items: [Prompt])] = [:]\n    private var hiddenCache: [String: (mtime: Date, items: Set<String>)] = [:]\n\n    func load(for workingDirectory: String?) -> [Prompt] {\n        let projectURL: URL? = workingDirectory.map {\n            URL(fileURLWithPath: $0)\n                .appendingPathComponent(\".codmate\", isDirectory: true)\n                .appendingPathComponent(\"prompts.json\", isDirectory: false)\n        }\n        let userURL = userFileURL()\n        let projectItems = projectURL.flatMap { read(url: $0) } ?? []\n        let userItems = read(url: userURL) ?? []\n        // Merge: project-level first, then user-level excluding duplicate commands\n        var seen = Set<String>()\n        var merged: [Prompt] = []\n        for p in projectItems { if seen.insert(p.command).inserted { merged.append(p) } }\n        for p in userItems { if seen.insert(p.command).inserted { merged.append(p) } }\n        return merged\n    }\n\n    // MARK: - Focused loaders\n    func loadProjectOnly(for workingDirectory: String?) -> [Prompt] {\n        guard let workingDirectory else { return [] }\n        let url = URL(fileURLWithPath: workingDirectory)\n            .appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"prompts.json\", isDirectory: false)\n        return read(url: url) ?? []\n    }\n\n    func loadUserOnly() -> [Prompt] {\n        let url = userFileURL()\n        return read(url: url) ?? []\n    }\n\n    func projectFileExists(for workingDirectory: String?) -> Bool {\n        guard let workingDirectory else { return false }\n        let fm = FileManager.default\n        let url = URL(fileURLWithPath: workingDirectory)\n            .appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"prompts.json\", isDirectory: false)\n        return fm.fileExists(atPath: url.path)\n    }\n\n    func openOrCreateUserFile(withTemplate template: [Prompt]) {\n        let url = userFileURL()\n        ensureParentDir(url)\n        let fm = FileManager.default\n        if !fm.fileExists(atPath: url.path) {\n            // Write simple template\n            let arr: [[String: String]] = template.map { [\"label\": $0.label, \"command\": $0.command] }\n            if let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) {\n                try? data.write(to: url)\n            }\n        }\n        NSWorkspace.shared.open(url)\n    }\n\n    func openOrCreatePreferredFile(for workingDirectory: String?, withTemplate template: [Prompt]) {\n        let fm = FileManager.default\n        let projectURL = workingDirectory.map {\n            URL(fileURLWithPath: $0)\n                .appendingPathComponent(\".codmate\", isDirectory: true)\n                .appendingPathComponent(\"prompts.json\", isDirectory: false)\n        }\n        let userURL = userFileURL()\n        let preferredURL: URL\n        if let p = projectURL, fm.fileExists(atPath: p.path) { preferredURL = p } else { preferredURL = userURL }\n        ensureParentDir(preferredURL)\n        if !fm.fileExists(atPath: preferredURL.path) {\n            let arr: [[String: String]] = template.map { [\"label\": $0.label, \"command\": $0.command] }\n            if let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) {\n                try? data.write(to: preferredURL)\n            }\n        }\n        NSWorkspace.shared.open(preferredURL)\n    }\n\n    /// Adds a prompt record to the most appropriate file (project-level preferred, else user-level).\n    /// Returns the URL written on success.\n    @discardableResult\n    func add(prompt: Prompt, for workingDirectory: String?) -> URL? {\n        // Prefer project-level file if we have a working directory\n        let fm = FileManager.default\n        let projectURL: URL? = workingDirectory.map {\n            URL(fileURLWithPath: $0)\n                .appendingPathComponent(\".codmate\", isDirectory: true)\n                .appendingPathComponent(\"prompts.json\", isDirectory: false)\n        }\n        let userURL = userFileURL()\n        let targetURL: URL = {\n            if let p = projectURL, fm.fileExists(atPath: p.path) { return p }\n            return userURL\n        }()\n        ensureParentDir(targetURL)\n\n        // Load existing array or start new\n        var items = (read(url: targetURL) ?? [])\n        // De-duplicate by command exact match (case-sensitive) or same label+command\n        if items.contains(where: { $0.command == prompt.command }) == false {\n            items.append(prompt)\n        }\n        // Write back\n        let arr: [[String: String]] = items.map { [\"label\": $0.label, \"command\": $0.command] }\n        guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) else {\n            return nil\n        }\n        do {\n            try data.write(to: targetURL)\n            // Invalidate cache\n            cache.removeValue(forKey: targetURL.path)\n            return targetURL\n        } catch {\n            return nil\n        }\n    }\n\n    @discardableResult\n    func delete(prompt: Prompt, location: PromptLocation, workingDirectory: String?) -> Bool {\n        if location == .builtin {\n            return addHidden(command: prompt.command, for: workingDirectory) != nil\n        }\n        let fm = FileManager.default\n        let targetURL: URL = {\n            switch location {\n            case .project:\n                guard let cwd = workingDirectory else { return userFileURL() }\n                return URL(fileURLWithPath: cwd)\n                    .appendingPathComponent(\".codmate\", isDirectory: true)\n                    .appendingPathComponent(\"prompts.json\", isDirectory: false)\n            case .user:\n                return userFileURL()\n            case .builtin:\n                return userFileURL() // unreachable due to early return\n            }\n        }()\n        guard fm.fileExists(atPath: targetURL.path) else { return false }\n        guard var items = read(url: targetURL) else { return false }\n        let before = items.count\n        items.removeAll { $0.command == prompt.command }\n        guard items.count != before else { return false }\n        let arr: [[String: String]] = items.map { [\"label\": $0.label, \"command\": $0.command] }\n        guard let data = try? JSONSerialization.data(withJSONObject: arr, options: [.prettyPrinted]) else {\n            return false\n        }\n        do {\n            try data.write(to: targetURL)\n            cache.removeValue(forKey: targetURL.path)\n            return true\n        } catch {\n            return false\n        }\n    }\n\n    // MARK: - Hidden built-ins\n    func loadHidden(for workingDirectory: String?) -> Set<String> {\n        let fm = FileManager.default\n        var urls: [URL] = []\n        if let cwd = workingDirectory {\n            urls.append(projectHiddenURL(for: cwd))\n        }\n        urls.append(userHiddenURL())\n        var hidden = Set<String>()\n        for url in urls {\n            guard fm.fileExists(atPath: url.path) else { continue }\n            let mtime = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date.distantPast\n            if let cached = hiddenCache[url.path], cached.mtime == mtime {\n                hidden.formUnion(cached.items)\n                continue\n            }\n            if let data = try? Data(contentsOf: url, options: [.mappedIfSafe]),\n               let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {\n                let set = Set(arr)\n                hiddenCache[url.path] = (mtime, set)\n                hidden.formUnion(set)\n            }\n        }\n        return hidden\n    }\n\n    @discardableResult\n    func addHidden(command: String, for workingDirectory: String?) -> URL? {\n        let _ = FileManager.default\n        // Prefer project-level hidden when project file exists; else user-level\n        let preferredProject = projectFileExists(for: workingDirectory)\n        let url = preferredProject ? projectHiddenURL(for: workingDirectory!) : userHiddenURL()\n        ensureParentDir(url)\n        var list: [String] = []\n        if let data = try? Data(contentsOf: url), let arr = try? JSONSerialization.jsonObject(with: data) as? [String] {\n            list = arr\n        }\n        if !list.contains(command) { list.append(command) }\n        guard let data = try? JSONSerialization.data(withJSONObject: list, options: [.prettyPrinted]) else { return nil }\n        do {\n            try data.write(to: url)\n            hiddenCache.removeValue(forKey: url.path)\n            return url\n        } catch {\n            return nil\n        }\n    }\n\n    private func userHiddenURL() -> URL {\n        userFileURL().deletingLastPathComponent().appendingPathComponent(\"prompts-hidden.json\")\n    }\n    private func projectHiddenURL(for workingDirectory: String) -> URL {\n        URL(fileURLWithPath: workingDirectory)\n            .appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"prompts-hidden.json\", isDirectory: false)\n    }\n\n    // MARK: - Private\n    private func read(url: URL) -> [Prompt]? {\n        let fm = FileManager.default\n        guard fm.fileExists(atPath: url.path) else { return nil }\n        let mtime = (try? fm.attributesOfItem(atPath: url.path)[.modificationDate] as? Date) ?? Date.distantPast\n        if let cached = cache[url.path], cached.mtime == mtime { return cached.items }\n\n        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return nil }\n        var parsed: [Prompt] = []\n\n        // Accept either [String] or [{label, command}] or [{title, text}]\n        if let arr = try? JSONSerialization.jsonObject(with: data) as? [Any] {\n            for item in arr {\n                if let s = item as? String {\n                    parsed.append(Prompt(label: s, command: s))\n                } else if let dict = item as? [String: Any] {\n                    let label = (dict[\"label\"] as? String)\n                        ?? (dict[\"title\"] as? String)\n                        ?? (dict[\"name\"] as? String)\n                        ?? (dict[\"command\"] as? String)\n                        ?? (dict[\"text\"] as? String)\n                        ?? \"\"\n                    let command = (dict[\"command\"] as? String)\n                        ?? (dict[\"text\"] as? String)\n                        ?? label\n                    if !label.isEmpty {\n                        parsed.append(Prompt(label: label, command: command))\n                    }\n                }\n            }\n        }\n\n        cache[url.path] = (mtime, parsed)\n        return parsed\n    }\n\n    private func userFileURL() -> URL {\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        return home\n            .appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"prompts.json\", isDirectory: false)\n    }\n\n    private func ensureParentDir(_ url: URL) {\n        let fm = FileManager.default\n        let dir = url.deletingLastPathComponent()\n        if !fm.fileExists(atPath: dir.path) {\n            try? fm.createDirectory(at: dir, withIntermediateDirectories: true)\n        }\n    }\n}\n"
  },
  {
    "path": "services/ProjectExtensionsApplier.swift",
    "content": "import Foundation\n\nactor ProjectExtensionsApplier {\n  private let fm: FileManager\n  private let skillsSyncer = SkillsSyncService()\n\n  init(fileManager: FileManager = .default) {\n    self.fm = fileManager\n  }\n\n  func apply(\n    projectDirectory: URL,\n    mcpSelections: [ProjectMCPSelection],\n    skillRecords: [SkillRecord],\n    skillSelections: [SkillsSyncService.SkillSelection],\n    trustLevel: String?\n  ) async {\n    await ensureCodexTrustedIfNeeded(\n      projectDirectory: projectDirectory,\n      mcpSelections: mcpSelections,\n      skillSelections: skillSelections,\n      trustLevel: trustLevel\n    )\n    await applyMCP(projectDirectory: projectDirectory, selections: mcpSelections)\n    _ = await skillsSyncer.syncProject(\n      skills: skillRecords,\n      selections: skillSelections,\n      projectDirectory: projectDirectory\n    )\n  }\n\n  private func ensureCodexTrustedIfNeeded(\n    projectDirectory: URL,\n    mcpSelections: [ProjectMCPSelection],\n    skillSelections: [SkillsSyncService.SkillSelection],\n    trustLevel: String?\n  ) async {\n    guard SessionPreferencesStore.isCLIEnabled(.codex) else { return }\n    let needsCodexMCP = mcpSelections.contains { $0.isSelected && $0.targets.codex }\n    let needsCodexSkills = skillSelections.contains { $0.isSelected && $0.targets.codex }\n    guard needsCodexMCP || needsCodexSkills else { return }\n\n    if await SecurityScopedBookmarks.shared.isSandboxed {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      let codexDir = home.appendingPathComponent(\".codex\", isDirectory: true)\n      await MainActor.run {\n        AuthorizationHub.shared.ensureDirectoryAccessOrPrompt(\n          directory: codexDir,\n          purpose: .generalAccess,\n          message: \"Authorize ~/.codex to update trusted projects\"\n        )\n      }\n    }\n\n    let level = trustLevel?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let resolvedLevel = (level?.isEmpty == false ? level : nil) ?? \"trusted\"\n    let service = CodexConfigService()\n    try? await service.ensureProjectTrusted(directory: projectDirectory, trustLevel: resolvedLevel)\n  }\n\n  private func applyMCP(projectDirectory: URL, selections: [ProjectMCPSelection]) async {\n    let selected = selections.filter { $0.isSelected }\n    let codexServers = selected.filter { $0.targets.codex }.map { $0.server }\n    let claudeServers = selected.filter { $0.targets.claude }.map { $0.server }\n    let geminiServers = selected.filter { $0.targets.gemini }.map { $0.server }\n\n    if SessionPreferencesStore.isCLIEnabled(.codex) {\n      let codexDir = projectDirectory.appendingPathComponent(\".codex\", isDirectory: true)\n      let configURL = codexDir.appendingPathComponent(\"config.toml\", isDirectory: false)\n      if !codexServers.isEmpty || fm.fileExists(atPath: configURL.path) {\n        let ensured = ensureCodexConfig(projectDirectory: projectDirectory)\n        ensureCodexAuthSymlink(projectDirectory: ensured.deletingLastPathComponent())\n        let service = CodexConfigService(paths: .init(home: codexDir, configURL: ensured))\n        try? await service.applyMCPServers(codexServers)\n      }\n    }\n\n    // Claude Code official path: project_root/.mcp.json\n    let claudeRootFile = projectDirectory.appendingPathComponent(\".mcp.json\", isDirectory: false)\n    // CodMate legacy path: project_root/.claude/.mcp.json (for backward compatibility)\n    let claudeLegacyDir = projectDirectory.appendingPathComponent(\".claude\", isDirectory: true)\n    let claudeLegacyFile = claudeLegacyDir.appendingPathComponent(\".mcp.json\", isDirectory: false)\n\n    if SessionPreferencesStore.isCLIEnabled(.claude) {\n      if !claudeServers.isEmpty {\n        // Write to Claude Code official path (project root)\n        writeClaudeMCPFile(servers: claudeServers, file: claudeRootFile)\n        // Remove legacy file if it exists to avoid conflicts\n        if fm.fileExists(atPath: claudeLegacyFile.path) {\n          try? fm.removeItem(at: claudeLegacyFile)\n        }\n      } else {\n        // Remove both files when clearing\n        if fm.fileExists(atPath: claudeRootFile.path) {\n          try? fm.removeItem(at: claudeRootFile)\n        }\n        if fm.fileExists(atPath: claudeLegacyFile.path) {\n          try? fm.removeItem(at: claudeLegacyFile)\n        }\n      }\n    }\n\n    if SessionPreferencesStore.isCLIEnabled(.gemini) {\n      let geminiDir = projectDirectory.appendingPathComponent(\".gemini\", isDirectory: true)\n      let geminiSettings = geminiDir.appendingPathComponent(\"settings.json\", isDirectory: false)\n      if !geminiServers.isEmpty || fm.fileExists(atPath: geminiSettings.path) {\n        let service = GeminiSettingsService(paths: .init(directory: geminiDir, file: geminiSettings))\n        try? await service.applyMCPServers(geminiServers)\n      }\n    }\n  }\n\n  private func ensureCodexConfig(projectDirectory: URL) -> URL {\n    let codexDir = projectDirectory.appendingPathComponent(\".codex\", isDirectory: true)\n    let configURL = codexDir.appendingPathComponent(\"config.toml\", isDirectory: false)\n    if !fm.fileExists(atPath: configURL.path) {\n      try? fm.createDirectory(at: codexDir, withIntermediateDirectories: true)\n      let global = SessionPreferencesStore.getRealUserHomeURL()\n        .appendingPathComponent(\".codex\", isDirectory: true)\n        .appendingPathComponent(\"config.toml\", isDirectory: false)\n      if fm.fileExists(atPath: global.path) {\n        try? fm.copyItem(at: global, to: configURL)\n      } else {\n        try? \"\".write(to: configURL, atomically: true, encoding: .utf8)\n      }\n    }\n    return configURL\n  }\n\n  private func ensureCodexAuthSymlink(projectDirectory: URL) {\n    let auth = projectDirectory.appendingPathComponent(\"auth.json\", isDirectory: false)\n    guard !fm.fileExists(atPath: auth.path) else { return }\n    let global = SessionPreferencesStore.getRealUserHomeURL()\n      .appendingPathComponent(\".codex\", isDirectory: true)\n      .appendingPathComponent(\"auth.json\", isDirectory: false)\n    guard fm.fileExists(atPath: global.path) else { return }\n    try? fm.createSymbolicLink(at: auth, withDestinationURL: global)\n  }\n\n  private func writeClaudeMCPFile(servers: [MCPServer], file: URL) {\n    var obj: [String: Any] = [:]\n    var mcpServers: [String: Any] = [:]\n    for server in servers {\n      var config: [String: Any] = [:]\n      if let command = server.command { config[\"command\"] = command }\n      if let args = server.args, !args.isEmpty { config[\"args\"] = args }\n      if let env = server.env, !env.isEmpty { config[\"env\"] = env }\n      if let url = server.url { config[\"url\"] = url }\n      if let headers = server.headers, !headers.isEmpty { config[\"headers\"] = headers }\n      mcpServers[server.name] = config\n    }\n    obj[\"mcpServers\"] = mcpServers\n    if let data = try? JSONSerialization.data(\n      withJSONObject: obj, options: [.prettyPrinted, .withoutEscapingSlashes])\n    {\n      try? data.write(to: file, options: .atomic)\n    }\n  }\n}\n"
  },
  {
    "path": "services/ProjectExtensionsStore.swift",
    "content": "import Foundation\n\nactor ProjectExtensionsStore {\n  struct Paths {\n    let root: URL\n    let extensionsDir: URL\n\n    static func `default`(fileManager: FileManager = .default) -> Paths {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      let root = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        .appendingPathComponent(\"projects\", isDirectory: true)\n      let extensionsDir = root.appendingPathComponent(\"extensions\", isDirectory: true)\n      return Paths(root: root, extensionsDir: extensionsDir)\n    }\n  }\n\n  private let paths: Paths\n  private let fm: FileManager\n\n  init(paths: Paths = .default(), fileManager: FileManager = .default) {\n    self.paths = paths\n    self.fm = fileManager\n  }\n\n  func load(projectId: String) -> ProjectExtensionsConfig? {\n    let url = configURL(for: projectId)\n    guard fm.fileExists(atPath: url.path) else { return nil }\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    return try? decoder.decode(ProjectExtensionsConfig.self, from: data)\n  }\n\n  func save(_ config: ProjectExtensionsConfig) {\n    let url = configURL(for: config.projectId)\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n    encoder.dateEncodingStrategy = .iso8601\n    guard let data = try? encoder.encode(config) else { return }\n    try? fm.createDirectory(at: paths.extensionsDir, withIntermediateDirectories: true)\n    try? data.write(to: url, options: .atomic)\n  }\n\n  func delete(projectId: String) {\n    let url = configURL(for: projectId)\n    if fm.fileExists(atPath: url.path) {\n      try? fm.removeItem(at: url)\n    }\n  }\n\n  private func configURL(for projectId: String) -> URL {\n    paths.extensionsDir.appendingPathComponent(projectId + \".json\", isDirectory: false)\n  }\n\n  static func loadSync(projectId: String) -> ProjectExtensionsConfig? {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let url = home.appendingPathComponent(\".codmate\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n      .appendingPathComponent(\"extensions\", isDirectory: true)\n      .appendingPathComponent(projectId + \".json\", isDirectory: false)\n    guard FileManager.default.fileExists(atPath: url.path) else { return nil }\n    guard let data = try? Data(contentsOf: url) else { return nil }\n    let decoder = JSONDecoder(); decoder.dateDecodingStrategy = .iso8601\n    return try? decoder.decode(ProjectExtensionsConfig.self, from: data)\n  }\n\n  static func requiresCodexHome(projectId: String) -> Bool {\n    guard let config = loadSync(projectId: projectId) else { return false }\n    return config.mcpServers.contains { $0.isSelected && $0.targets.codex }\n  }\n}\n"
  },
  {
    "path": "services/ProjectsStore.swift",
    "content": "import Foundation\n\n// ProjectsStore: manages project metadata and session memberships\n// Layout (under ~/.codmate/projects):\n//  - metadata/<projectId>.json  (one file per project)\n//  - memberships.json           (central mapping: { version, sessionToProject })\n\nstruct ProjectMeta: Codable, Hashable, Sendable {\n    var id: String\n    var name: String\n    var directory: String?\n    var trustLevel: String?\n    var overview: String?\n    var instructions: String?\n    var profileId: String?\n    var profile: ProjectProfile?\n    var parentId: String?\n    var sources: [ProjectSessionSource]?\n    var createdAt: Date\n    var updatedAt: Date\n\n    init(from project: Project) {\n        self.id = project.id\n        self.name = project.name\n        self.directory = project.directory\n        self.trustLevel = project.trustLevel\n        self.overview = project.overview\n        self.instructions = project.instructions\n        self.profileId = project.profileId\n        self.profile = project.profile\n        self.parentId = project.parentId\n        self.sources = Array(project.sources).sorted { $0.rawValue < $1.rawValue }\n        self.createdAt = Date()\n        self.updatedAt = Date()\n    }\n\n    func asProject() -> Project {\n        var sourceSet = Set(sources ?? ProjectSessionSource.allCases)\n        if sourceSet.isEmpty {\n            sourceSet = ProjectSessionSource.allSet\n        }\n        if !sourceSet.contains(.gemini) {\n            sourceSet.insert(.gemini)\n        }\n        return Project(\n            id: id,\n            name: name,\n            directory: directory,\n            trustLevel: trustLevel,\n            overview: overview,\n            instructions: instructions,\n            profileId: profileId,\n            profile: profile,\n            parentId: parentId,\n            sources: sourceSet\n        )\n    }\n}\n\nstruct SessionAssignment: Codable, Hashable, Sendable {\n    let id: String\n    let source: ProjectSessionSource\n}\n\nactor ProjectsStore {\n    struct Paths {\n        let root: URL\n        let metadataDir: URL\n        let membershipsURL: URL\n\n        static func `default`(fileManager: FileManager = .default) -> Paths {\n            let home = fileManager.homeDirectoryForCurrentUser\n            // New centralized CodMate data root\n            let root = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                .appendingPathComponent(\"projects\", isDirectory: true)\n            return Paths(\n                root: root,\n                metadataDir: root.appendingPathComponent(\"metadata\", isDirectory: true),\n                membershipsURL: root.appendingPathComponent(\"memberships.json\", isDirectory: false)\n            )\n        }\n    }\n\n    private let fm: FileManager\n    private let paths: Paths\n\n    // runtime caches\n    private var projects: [String: ProjectMeta] = [:] // id -> meta\n    private var sessionToProject: [String: String] = [:] // membershipKey -> projectId\n    private let membershipVersion = 2\n\n    init(paths: Paths = .default(), fileManager: FileManager = .default) {\n        self.fm = fileManager\n        self.paths = paths\n        \n        // Before creating new directories, attempt legacy migration from ~/.codex/projects → ~/.codmate/projects\n        Self.migrateLegacyIfNeeded(to: paths, fm: fileManager)\n        try? fileManager.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true)\n        \n        // Load memberships - use local variables to avoid actor isolation issues\n        var loadedSessionToProject: [String: String] = [:]\n        if let data = try? Data(contentsOf: paths.membershipsURL),\n           let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n        {\n            let map = obj[\"sessionToProject\"] as? [String: String] ?? [:]\n            let version = obj[\"version\"] as? Int ?? 1\n            if version >= 2 {\n                loadedSessionToProject = map\n            } else {\n                // Legacy keys did not encode the session source; assume Codex\n                loadedSessionToProject = map.reduce(into: [:]) { result, entry in\n                    let legacyKey = Self.makeMembershipKey(for: entry.key, source: .codex)\n                    result[legacyKey] = entry.value\n                }\n            }\n        }\n        self.sessionToProject = loadedSessionToProject\n        \n        // Load metadata - use local variable to avoid actor isolation issues\n        var loadedProjects: [String: ProjectMeta] = [:]\n        if let en = fileManager.enumerator(at: paths.metadataDir, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) {\n            let dec = JSONDecoder(); dec.dateDecodingStrategy = .iso8601\n            for case let url as URL in en {\n                if url.pathExtension.lowercased() != \"json\" { continue }\n                if let data = try? Data(contentsOf: url),\n                   let meta = try? dec.decode(ProjectMeta.self, from: data)\n                {\n                    loadedProjects[meta.id] = meta\n                }\n            }\n        }\n        self.projects = loadedProjects\n    }\n\n    // MARK: - Public API\n    func listProjects() -> [Project] { projects.values.map { $0.asProject() }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } }\n    func getProject(id: String) -> Project? { projects[id]?.asProject() }\n\n    func upsertProject(_ p: Project) {\n        var meta = projects[p.id] ?? ProjectMeta(from: p)\n        meta.name = p.name\n        meta.directory = p.directory\n        meta.trustLevel = p.trustLevel\n        meta.overview = p.overview\n        meta.instructions = p.instructions\n        meta.profileId = p.profileId\n        meta.profile = p.profile\n        meta.parentId = p.parentId\n        meta.sources = Array(p.sources).sorted { $0.rawValue < $1.rawValue }\n        meta.updatedAt = Date()\n        projects[p.id] = meta\n        saveProjectMeta(meta)\n    }\n\n    func deleteProject(id: String) {\n        // Remove meta\n        projects.removeValue(forKey: id)\n        let metaURL = paths.metadataDir.appendingPathComponent(id + \".json\")\n        // Move to Trash instead of permanent deletion for safety\n        var resulting: NSURL?\n        if fm.fileExists(atPath: metaURL.path) {\n            do { try fm.trashItem(at: metaURL, resultingItemURL: &resulting) } catch { /* best-effort */ }\n        }\n        // Unassign all sessions under this project\n        var changed = false\n        for (sid, pid) in sessionToProject where pid == id {\n            sessionToProject.removeValue(forKey: sid)\n            changed = true\n        }\n        if changed { saveMemberships() }\n    }\n\n    private func membershipKey(for id: String, source: ProjectSessionSource) -> String {\n        Self.makeMembershipKey(for: id, source: source)\n    }\n    \n    private static func makeMembershipKey(for id: String, source: ProjectSessionSource) -> String {\n        return \"\\(source.rawValue)|\\(id)\"\n    }\n\n    func assign(sessions: [SessionAssignment], to projectId: String?) {\n        var changed = false\n        for entry in sessions {\n            let trimmed = entry.id.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed.isEmpty { continue }\n            let key = membershipKey(for: trimmed, source: entry.source)\n            if let pid = projectId {\n                if sessionToProject[key] != pid {\n                    sessionToProject[key] = pid\n                    changed = true\n                }\n            } else {\n                if sessionToProject.removeValue(forKey: key) != nil {\n                    changed = true\n                }\n            }\n        }\n        if changed { saveMemberships() }\n    }\n\n    func projectId(for sessionId: String, source: ProjectSessionSource) -> String? {\n        sessionToProject[membershipKey(for: sessionId, source: source)]\n    }\n    func membershipsSnapshot() -> [String: String] { sessionToProject }\n    func counts() -> [String: Int] {\n        sessionToProject.values.reduce(into: [:]) { $0[$1, default: 0] += 1 }\n    }\n\n    // MARK: - Load/Save\n    private func loadAll() { /* unused post-init; kept for future reload hooks */ }\n\n    private func saveProjectMeta(_ meta: ProjectMeta) {\n        try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true)\n        let url = paths.metadataDir.appendingPathComponent(meta.id + \".json\")\n        let enc = JSONEncoder(); enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]; enc.dateEncodingStrategy = .iso8601\n        if let data = try? enc.encode(meta) {\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    private func saveMemberships() {\n        let obj: [String: Any] = [\n            \"version\": membershipVersion,\n            \"sessionToProject\": sessionToProject\n        ]\n        if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) {\n            try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true)\n            try? data.write(to: paths.membershipsURL, options: .atomic)\n        }\n    }\n\n    // MARK: - Legacy migration\n    /// Move or copy legacy data from `~/.codex/projects` into the new `~/.codmate/projects` location.\n    /// - Behavior:\n    ///   - If legacy root exists and new root is missing or empty, attempt a directory move.\n    ///   - If new root exists with content, copy over missing files (non-destructive) and keep legacy as-is.\n    private static func migrateLegacyIfNeeded(to paths: Paths, fm: FileManager) {\n        let home = fm.homeDirectoryForCurrentUser\n        let legacyRoot = home.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"projects\", isDirectory: true)\n        let newRoot = paths.root\n\n        // Quick existence check\n        var isDir: ObjCBool = false\n        let legacyExists = fm.fileExists(atPath: legacyRoot.path, isDirectory: &isDir) && isDir.boolValue\n        guard legacyExists else { return }\n\n        // Ensure parent of new root exists\n        let newParent = newRoot.deletingLastPathComponent()\n        try? fm.createDirectory(at: newParent, withIntermediateDirectories: true)\n\n        // Determine if new root exists and is empty\n        var newIsDir: ObjCBool = false\n        let newExists = fm.fileExists(atPath: newRoot.path, isDirectory: &newIsDir) && newIsDir.boolValue\n        let newIsEmpty: Bool = {\n            guard newExists else { return true }\n            do {\n                let items = try fm.contentsOfDirectory(atPath: newRoot.path)\n                return items.isEmpty\n            } catch { return true }\n        }()\n\n        // Prefer moving the whole directory if safe\n        if !newExists || newIsEmpty {\n            do {\n                if newExists && newIsEmpty {\n                    // Remove empty shell so move succeeds\n                    try? fm.removeItem(at: newRoot)\n                }\n                try fm.moveItem(at: legacyRoot, to: newRoot)\n                return\n            } catch {\n                // Fall back to per-file copy if move fails (e.g., cross-device)\n            }\n        }\n\n        // Non-destructive copy of missing files\n        do {\n            try fm.createDirectory(at: newRoot, withIntermediateDirectories: true)\n\n            // Copy memberships.json if missing\n            let legacyMemberships = legacyRoot.appendingPathComponent(\"memberships.json\")\n            let newMemberships = newRoot.appendingPathComponent(\"memberships.json\")\n            if fm.fileExists(atPath: legacyMemberships.path) && !fm.fileExists(atPath: newMemberships.path) {\n                try? fm.copyItem(at: legacyMemberships, to: newMemberships)\n            }\n\n            // Copy metadata directory contents if missing\n            let legacyMetadata = legacyRoot.appendingPathComponent(\"metadata\", isDirectory: true)\n            let newMetadata = newRoot.appendingPathComponent(\"metadata\", isDirectory: true)\n            var isLegacyMetaDir: ObjCBool = false\n            if fm.fileExists(atPath: legacyMetadata.path, isDirectory: &isLegacyMetaDir), isLegacyMetaDir.boolValue {\n                try? fm.createDirectory(at: newMetadata, withIntermediateDirectories: true)\n                if let en = fm.enumerator(at: legacyMetadata, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) {\n                    for case let url as URL in en {\n                        if url.pathExtension.lowercased() != \"json\" { continue }\n                        let dest = newMetadata.appendingPathComponent(url.lastPathComponent)\n                        if !fm.fileExists(atPath: dest.path) {\n                            try? fm.copyItem(at: url, to: dest)\n                        }\n                    }\n                }\n            }\n        } catch {\n            // Best effort; do not block app startup on migration failures\n        }\n    }\n}\n"
  },
  {
    "path": "services/ProvidersRegistryService.swift",
    "content": "import Foundation\n\n// MARK: - Providers Registry (Codex-first, Claude Code placeholder)\n\nactor ProvidersRegistryService {\n    // Consumers we support in registry (keys for connectors/bindings)\n    enum Consumer: String, Codable, CaseIterable { case codex, claudeCode }\n\n    struct Connector: Codable, Equatable {\n        var baseURL: String?\n        var wireAPI: String? // responses | chat\n        var envKey: String?\n        // Login method for this consumer's connector: \"api\" (default) or \"subscription\" (Claude login)\n        var loginMethod: String?\n        var queryParams: [String: String]?\n        var httpHeaders: [String: String]?\n        var envHttpHeaders: [String: String]?\n        var requestMaxRetries: Int?\n        var streamMaxRetries: Int?\n        var streamIdleTimeoutMs: Int?\n        // Optional per-consumer model aliases (used by Claude Code):\n        // keys: \"default\", \"haiku\", \"sonnet\", \"opus\"\n        var modelAliases: [String: String]?\n    }\n\n    struct ModelCaps: Codable, Equatable {\n        var reasoning: Bool?; var tool_use: Bool?; var vision: Bool?; var long_context: Bool?\n        var code_tuned: Bool?; var tps_hint: String?; var max_output_tokens: Int?\n    }\n\n    struct ModelEntry: Codable, Equatable {\n        var vendorModelId: String\n        var caps: ModelCaps?\n        var aliases: [String]?\n    }\n\n    struct Catalog: Codable, Equatable {\n        var models: [ModelEntry]?\n    }\n\n    struct Recommended: Codable, Equatable {\n        var defaultModelFor: [String: String]? // consumer -> vendorModelId\n    }\n\n    struct Provider: Codable, Identifiable, Equatable {\n        var id: String\n        var name: String?\n        var `class`: String? // openai-compatible | anthropic | other\n        var managedByCodMate: Bool\n        // Shared API key environment variable (preferred). Connector-level envKey is deprecated.\n        var envKey: String?\n        // Optional references for user guidance (Get Key / Docs)\n        var keyURL: String?\n        var docsURL: String?\n        var connectors: [String: Connector] // consumer -> connector\n        var catalog: Catalog?\n        var recommended: Recommended?\n        // Custom SF Symbol icon name (e.g., \"a.circle.fill\") - only for user-created providers\n        var customIcon: String?\n    }\n\n    struct Bindings: Codable, Equatable {\n        var activeProvider: [String: String]? // consumer -> providerId\n        var defaultModel: [String: String]?   // consumer -> vendorModelId\n    }\n\n    struct Migration: Codable, Equatable { var importedFromCodexConfigAt: Date? }\n\n    struct Registry: Codable, Equatable {\n        var version: Int\n        var providers: [Provider]\n        var bindings: Bindings\n        var migration: Migration?\n    }\n\n    // MARK: - Paths\n    struct Paths { let home: URL; let fileURL: URL }\n    static func defaultPaths(fileManager: FileManager = .default) -> Paths {\n        let home = fileManager.homeDirectoryForCurrentUser\n        let dir = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        return Paths(home: dir, fileURL: dir.appendingPathComponent(\"providers.json\"))\n    }\n\n    private let fm: FileManager\n    private let paths: Paths\n\n    init(paths: Paths = ProvidersRegistryService.defaultPaths(), fileManager: FileManager = .default) {\n        self.paths = paths\n        self.fm = fileManager\n    }\n\n    // MARK: - Public API\n    nonisolated func load() -> Registry {\n        let url = paths.fileURL\n        if let data = try? Data(contentsOf: url),\n           let reg = try? JSONDecoder().decode(Registry.self, from: data) {\n            return reg\n        }\n        return Registry(\n            version: 1,\n            providers: [],\n            bindings: .init(activeProvider: nil, defaultModel: nil),\n            migration: nil\n        )\n    }\n\n    func save(_ reg: Registry) throws {\n        try fm.createDirectory(at: paths.home, withIntermediateDirectories: true)\n        let tmp = paths.fileURL.appendingPathExtension(\"tmp\")\n        let enc = JSONEncoder()\n        enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n        enc.dateEncodingStrategy = .iso8601\n        let data = try enc.encode(reg)\n        try data.write(to: tmp, options: .atomic)\n        // Replace atomically\n        if fm.fileExists(atPath: paths.fileURL.path) {\n            try fm.removeItem(at: paths.fileURL)\n        }\n        try fm.moveItem(at: tmp, to: paths.fileURL)\n    }\n\n    func listProviders() -> [Provider] { load().providers }\n\n    // MARK: - Bundled registry (read-only, loaded from app bundle)\n    private struct BundledProvidersFile: Codable { let providers: [Provider] }\n\n    private func loadBundledRegistry() -> Registry? {\n        // Try reading providers.json from the application bundle.\n        // Support payload/providers.json as well as top-level providers.json.\n        let bundle = Bundle.main\n        var urls: [URL] = []\n        if let u = bundle.url(forResource: \"providers\", withExtension: \"json\") { urls.append(u) }\n        if let u = bundle.url(forResource: \"providers\", withExtension: \"json\", subdirectory: \"payload\") { urls.append(u) }\n        for url in urls {\n            guard let data = try? Data(contentsOf: url) else { continue }\n            let dec = JSONDecoder()\n            // Full registry\n            if let reg = try? dec.decode(Registry.self, from: data) { return reg }\n            // { providers: [...] }\n            if let file = try? dec.decode(BundledProvidersFile.self, from: data) {\n                return Registry(version: 1, providers: file.providers, bindings: .init(activeProvider: nil, defaultModel: nil), migration: nil)\n            }\n            // [Provider]\n            if let arr = try? dec.decode([Provider].self, from: data) {\n                return Registry(version: 1, providers: arr, bindings: .init(activeProvider: nil, defaultModel: nil), migration: nil)\n            }\n        }\n        return nil\n    }\n\n    // Public reader that merges user-defined providers with bundled ones (dedup by id, preferring user)\n    func listAllProviders() -> [Provider] {\n        let user = load().providers\n        let builtins = loadBundledRegistry()?.providers ?? []\n        let userIds = Set(user.map { $0.id })\n        let extra = builtins.filter { !userIds.contains($0.id) }\n        return user + extra\n    }\n\n    // Helper for services that need a full registry view including bundled providers\n    func mergedRegistry() -> Registry {\n        let base = load()\n        let mergedProviders = listAllProviders()\n        // Merge bindings: user > bundled defaults\n        let bundled = loadBundledRegistry()\n        var mergedBindings = base.bindings\n        if let b = bundled?.bindings {\n            // activeProvider\n            var ap = mergedBindings.activeProvider ?? [:]\n            for k in (b.activeProvider ?? [:]).keys {\n                if ap[k] == nil { ap[k] = b.activeProvider?[k] }\n            }\n            mergedBindings.activeProvider = ap.isEmpty ? nil : ap\n            // defaultModel\n            var dm = mergedBindings.defaultModel ?? [:]\n            for k in (b.defaultModel ?? [:]).keys {\n                if dm[k] == nil { dm[k] = b.defaultModel?[k] }\n            }\n            mergedBindings.defaultModel = dm.isEmpty ? nil : dm\n        }\n        return Registry(version: base.version, providers: mergedProviders, bindings: mergedBindings, migration: base.migration)\n    }\n\n    // MARK: - Public: list bundled providers (templates only, no merge)\n    func listBundledProviders() -> [Provider] {\n        return loadBundledRegistry()?.providers ?? []\n    }\n\n    func upsertProvider(_ provider: Provider) throws {\n        var reg = load()\n        if let idx = reg.providers.firstIndex(where: { $0.id == provider.id }) {\n            reg.providers[idx] = provider\n        } else {\n            reg.providers.append(provider)\n        }\n        try save(reg)\n    }\n\n    func deleteProvider(id: String) throws {\n        var reg = load()\n        reg.providers.removeAll { $0.id == id }\n        try save(reg)\n    }\n\n    func getBindings() -> Bindings { load().bindings }\n\n    func setActiveProvider(_ consumer: Consumer, providerId: String?) throws {\n        var reg = load()\n        var ap = reg.bindings.activeProvider ?? [:]\n        ap[consumer.rawValue] = providerId\n        reg.bindings.activeProvider = ap\n        try save(reg)\n        // Notify listeners (e.g., SessionListViewModel) so usage capsules update immediately\n        NotificationCenter.default.post(\n            name: .codMateActiveProviderChanged,\n            object: nil,\n            userInfo: [\n                \"consumer\": consumer.rawValue,\n                \"providerId\": providerId as Any\n            ]\n        )\n    }\n\n    func setDefaultModel(_ consumer: Consumer, modelId: String?) throws {\n        var reg = load()\n        var dm = reg.bindings.defaultModel ?? [:]\n        dm[consumer.rawValue] = modelId\n        reg.bindings.defaultModel = dm\n        try save(reg)\n    }\n\n    // MARK: - Migration from Codex config (providers + active/model)\n    func migrateFromCodexIfNeeded(codex: CodexConfigService = CodexConfigService()) async {\n        var reg = load()\n        if reg.migration?.importedFromCodexConfigAt != nil { return }\n        // If registry already has providers, skip migration\n        if !reg.providers.isEmpty { return }\n\n        // Pull from Codex config.toml\n        let list = await codex.listProviders()\n        let active = await codex.activeProvider()\n        let model = await codex.getTopLevelString(\"model\")\n        var providers: [Provider] = []\n        for p in list {\n            var connectors: [String: Connector] = [:]\n            let c = Connector(\n                baseURL: p.baseURL,\n                wireAPI: p.wireAPI,\n                envKey: p.envKey,\n                queryParams: nil,\n                httpHeaders: nil,\n                envHttpHeaders: nil,\n                requestMaxRetries: p.requestMaxRetries,\n                streamMaxRetries: p.streamMaxRetries,\n                streamIdleTimeoutMs: p.streamIdleTimeoutMs,\n                modelAliases: nil\n            )\n            connectors[Consumer.codex.rawValue] = c\n            // leave claudeCode empty placeholder\n            let np = Provider(\n                id: p.id,\n                name: p.name,\n                class: \"openai-compatible\",\n                managedByCodMate: p.managedByCodMate,\n                envKey: p.envKey,\n                connectors: connectors,\n                catalog: nil,\n                recommended: nil\n            )\n            providers.append(np)\n        }\n        reg.providers = providers\n        var ap = reg.bindings.activeProvider ?? [:]\n        ap[Consumer.codex.rawValue] = active\n        reg.bindings.activeProvider = ap\n        var dm = reg.bindings.defaultModel ?? [:]\n        if let model { dm[Consumer.codex.rawValue] = model }\n        reg.bindings.defaultModel = dm\n        reg.migration = .init(importedFromCodexConfigAt: Date())\n        try? save(reg)\n    }\n}\n"
  },
  {
    "path": "services/RemoteSessionMirror.swift",
    "content": "import Foundation\nimport OSLog\n\nenum RemoteSessionKind: String, Sendable {\n    case codex\n    case claude\n\n    var remoteBasePath: String {\n        switch self {\n        case .codex: return \"$HOME/.codex/sessions\"\n        case .claude: return \"$HOME/.claude/projects\"\n        }\n    }\n\n    var cacheComponent: String {\n        switch self {\n        case .codex: return \"codex\"\n        case .claude: return \"claude\"\n        }\n    }\n}\n\nstruct RemoteMirrorOutcome {\n    let localRoot: URL\n    let fileMap: [URL: MirroredFile]\n\n    struct MirroredFile {\n        let remotePath: String\n        let remoteTimestamp: TimeInterval\n    }\n}\n\nactor RemoteSessionMirror {\n    private let fileManager: FileManager\n    private let cacheRoot: URL\n    private let logger = Logger(subsystem: \"io.umate.codemate\", category: \"RemoteSessionMirror\")\n    private static let sshExecutable = \"/usr/bin/ssh\"\n    private static let scpExecutable = \"/usr/bin/scp\"\n    private static let rsyncExecutable = \"/usr/bin/rsync\"\n    private static let sshDefaultOptions: [String] = [\n        \"-o\", \"ControlMaster=no\",\n        \"-o\", \"ControlPersist=no\",\n        \"-o\", \"ControlPath=none\",\n        \"-o\", \"ServerAliveInterval=60\",\n        \"-o\", \"ServerAliveCountMax=3\",\n        \"-o\", \"StrictHostKeyChecking=accept-new\",\n        \"-o\", \"HashKnownHosts=yes\"\n    ]\n\n    init(fileManager: FileManager = .default) {\n        self.fileManager = fileManager\n        let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!\n            .appendingPathComponent(\"CodMate\", isDirectory: true)\n            .appendingPathComponent(\"remote\", isDirectory: true)\n        self.cacheRoot = base\n        try? fileManager.createDirectory(at: base, withIntermediateDirectories: true)\n    }\n\n    func ensureMirror(\n        host: SSHHost,\n        kind: RemoteSessionKind,\n        scope: SessionLoadScope\n    ) async throws -> RemoteMirrorOutcome {\n        let localHostRoot = cacheRoot.appendingPathComponent(host.alias, isDirectory: true)\n            .appendingPathComponent(kind.cacheComponent, isDirectory: true)\n        try? fileManager.createDirectory(at: localHostRoot, withIntermediateDirectories: true)\n\n        let remoteListing = try fetchRemoteListing(host: host, kind: kind, scope: scope)\n        var pendingDownloads: [PendingDownload] = []\n        var fileMap: [URL: RemoteMirrorOutcome.MirroredFile] = [:]\n\n        for entry in remoteListing {\n            let localURL = localHostRoot.appendingPathComponent(entry.relativePath, isDirectory: false)\n            try? fileManager.createDirectory(\n                at: localURL.deletingLastPathComponent(), withIntermediateDirectories: true)\n            if needsDownload(localURL: localURL, remoteSize: entry.size, remoteTimestamp: entry.timestamp) {\n                pendingDownloads.append(.init(entry: entry, localURL: localURL))\n            }\n            fileMap[localURL] = .init(\n                remotePath: entry.absolutePath,\n                remoteTimestamp: entry.timestamp\n            )\n        }\n\n        if !pendingDownloads.isEmpty {\n            do {\n                try downloadBatch(\n                    host: host,\n                    kind: kind,\n                    localRoot: localHostRoot,\n                    downloads: pendingDownloads\n                )\n            } catch {\n                logger.warning(\n                    \"rsync fetch failed for host=\\(host.alias, privacy: .public) count=\\(pendingDownloads.count) error=\\(String(describing: error), privacy: .public); falling back to scp\"\n                )\n                for pending in pendingDownloads {\n                    try download(\n                        host: host,\n                        remoteAbsolutePath: pending.entry.absolutePath,\n                        to: pending.localURL\n                    )\n                    let attributes: [FileAttributeKey: Any] = [\n                        .modificationDate: Date(timeIntervalSince1970: pending.entry.timestamp)\n                    ]\n                    try? fileManager.setAttributes(attributes, ofItemAtPath: pending.localURL.path)\n                }\n            }\n        }\n\n        return RemoteMirrorOutcome(localRoot: localHostRoot, fileMap: fileMap)\n    }\n\n    private struct RemoteEntry {\n        let relativePath: String\n        let absolutePath: String\n        let size: UInt64\n        let timestamp: TimeInterval\n    }\n\n    private struct PendingDownload {\n        let entry: RemoteEntry\n        let localURL: URL\n    }\n\n    private func fetchRemoteListing(\n        host: SSHHost,\n        kind: RemoteSessionKind,\n        scope: SessionLoadScope\n    ) throws -> [RemoteEntry] {\n        let base = kind.remoteBasePath\n        let directories = relativeDirectories(for: scope)\n        let searchPaths = directories.isEmpty ? [\".\"]\n            : directories.map { $0.hasPrefix(\"./\") ? $0 : \"./\\($0)\" }\n\n        let command = buildFindCommand(base: base, searchPaths: searchPaths)\n        let arguments = buildSSHArguments(for: host, remoteCommand: command)\n        let result = try ShellCommandRunner.run(\n            executable: Self.sshExecutable,\n            arguments: arguments\n        )\n\n        let lines = result.stdout.split(separator: \"\\n\", omittingEmptySubsequences: true)\n        var entries: [RemoteEntry] = []\n        entries.reserveCapacity(lines.count)\n        for line in lines {\n            let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { continue }\n            let components = trimmed.split(separator: \"|\", omittingEmptySubsequences: false)\n            guard components.count >= 3 else { continue }\n            var pathComponent = String(components[0])\n            if pathComponent.hasPrefix(\"./\") { pathComponent.removeFirst(2) }\n            guard !pathComponent.isEmpty else { continue }\n            let size = UInt64(components[1]) ?? 0\n            let timestamp = TimeInterval(components[2]) ?? 0\n            let absolute = joinRemote(base: base, relative: pathComponent)\n            entries.append(\n                RemoteEntry(\n                    relativePath: pathComponent,\n                    absolutePath: absolute,\n                    size: size,\n                    timestamp: timestamp\n                )\n            )\n        }\n        return entries\n    }\n\n    private func downloadBatch(\n        host: SSHHost,\n        kind: RemoteSessionKind,\n        localRoot: URL,\n        downloads: [PendingDownload]\n    ) throws {\n        guard !downloads.isEmpty else { return }\n\n        let remoteBase = normalizeRemoteBaseForTransfer(kind.remoteBasePath)\n        let manifest = downloads.map { $0.entry.relativePath }.joined(separator: \"\\n\")\n        let tempDirectory = fileManager.temporaryDirectory\n        let manifestURL = tempDirectory.appendingPathComponent(\n            \"codemate-rsync-\\(UUID().uuidString).lst\",\n            isDirectory: false\n        )\n        try manifest.write(to: manifestURL, atomically: true, encoding: .utf8)\n        defer { try? fileManager.removeItem(at: manifestURL) }\n\n        let sshCommand = buildRsyncSSHCommand(for: host)\n        let targetHost = connectionTarget(for: host)\n        let sourceArg = \"\\(targetHost):\\(remoteBase)/\"\n\n        let arguments: [String] = [\n            \"-e\", sshCommand,\n            \"--archive\",\n            \"--compress\",\n            \"--prune-empty-dirs\",\n            \"--files-from=\\(manifestURL.path)\",\n            sourceArg,\n            localRoot.path\n        ]\n\n        logger.info(\n            \"Starting rsync mirror host=\\(host.alias, privacy: .public) count=\\(downloads.count)\"\n        )\n        try ShellCommandRunner.run(\n            executable: Self.rsyncExecutable,\n            arguments: arguments\n        )\n\n        for pending in downloads {\n            let attributes: [FileAttributeKey: Any] = [\n                .modificationDate: Date(timeIntervalSince1970: pending.entry.timestamp)\n            ]\n            try? fileManager.setAttributes(attributes, ofItemAtPath: pending.localURL.path)\n        }\n    }\n\n    private func download(host: SSHHost, remoteAbsolutePath: String, to localURL: URL) throws {\n        let remotePathForSCP: String\n        if remoteAbsolutePath.hasPrefix(\"$HOME\") {\n            let tail = remoteAbsolutePath.dropFirst(\"$HOME\".count)\n            remotePathForSCP = \"~\" + tail\n        } else {\n            remotePathForSCP = remoteAbsolutePath\n        }\n\n        let arguments = buildSCPArguments(\n            for: host,\n            remotePath: remotePathForSCP,\n            localPath: localURL.path\n        )\n        logger.info(\n            \"Fetching via scp host=\\(host.alias, privacy: .public) file=\\(remotePathForSCP, privacy: .public)\"\n        )\n        try ShellCommandRunner.run(\n            executable: Self.scpExecutable,\n            arguments: arguments\n        )\n    }\n\n    private func buildSSHArguments(for host: SSHHost, remoteCommand: String) -> [String] {\n        var args = buildBaseSSHOptions(for: host)\n        args.append(connectionTarget(for: host))\n        args.append(remoteCommand)\n        return args\n    }\n\n    private func buildSCPArguments(for host: SSHHost, remotePath: String, localPath: String) -> [String] {\n        var args = buildBaseSCPOptions(for: host)\n        args += [\"-q\", \"-p\"]\n        args.append(\"\\(scpConnectionTarget(for: host)):\\(remotePath)\")\n        args.append(localPath)\n        return args\n    }\n\n    private func buildBaseSSHOptions(for host: SSHHost) -> [String] {\n        var args = Self.sshDefaultOptions\n        if let user = host.user, !user.isEmpty {\n            args += [\"-l\", user]\n        }\n        if let port = host.port {\n            args += [\"-p\", String(port)]\n        }\n        if let identity = host.identityFile, !identity.isEmpty {\n            args += [\"-i\", identity]\n        }\n        if let proxyJump = host.proxyJump, !proxyJump.isEmpty {\n            args += [\"-J\", proxyJump]\n        }\n        if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty {\n            args += [\"-o\", \"ProxyCommand=\\(proxyCommand)\"]\n        }\n        if let forwardAgent = host.forwardAgent {\n            args += [\"-o\", \"ForwardAgent=\\(forwardAgent ? \"yes\" : \"no\")\"]\n        }\n        return args\n    }\n\n    private func buildBaseSCPOptions(for host: SSHHost) -> [String] {\n        // SCP has different option syntax than SSH:\n        // - No -l flag (user goes in target: user@host:path)\n        // - Port uses -P (uppercase) instead of -p\n        var args = Self.sshDefaultOptions\n        if let port = host.port {\n            args += [\"-P\", String(port)]\n        }\n        if let identity = host.identityFile, !identity.isEmpty {\n            args += [\"-i\", identity]\n        }\n        if let proxyJump = host.proxyJump, !proxyJump.isEmpty {\n            args += [\"-o\", \"ProxyJump=\\(proxyJump)\"]\n        }\n        if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty {\n            args += [\"-o\", \"ProxyCommand=\\(proxyCommand)\"]\n        }\n        if let forwardAgent = host.forwardAgent {\n            args += [\"-o\", \"ForwardAgent=\\(forwardAgent ? \"yes\" : \"no\")\"]\n        }\n        return args\n    }\n\n    private func buildRsyncSSHCommand(for host: SSHHost) -> String {\n        let parts = [Self.sshExecutable] + buildBaseSSHOptions(for: host)\n        return parts.map(shellEscaped).joined(separator: \" \")\n    }\n\n    private func connectionTarget(for host: SSHHost) -> String {\n        host.hostname ?? host.alias\n    }\n\n    private func scpConnectionTarget(for host: SSHHost) -> String {\n        // SCP requires user@host format (doesn't support -l flag)\n        let hostname = host.hostname ?? host.alias\n        // If hostname already contains @, don't add user prefix to avoid user@user@host\n        guard !hostname.contains(\"@\") else { return hostname }\n        if let user = host.user, !user.isEmpty {\n            return \"\\(user)@\\(hostname)\"\n        }\n        return hostname\n    }\n\n    private func shellEscaped(_ argument: String) -> String {\n        guard argument.contains(where: { $0.isWhitespace || $0 == \"'\" || $0 == \"\\\"\" }) else {\n            return argument\n        }\n        return \"'\\(argument.replacingOccurrences(of: \"'\", with: \"'\\\"'\\\"'\"))'\"\n    }\n\n    private func buildFindCommand(base: String, searchPaths: [String]) -> String {\n        let quotedBase = doubleQuoted(base)\n        let pathArgs = searchPaths.map { doubleQuoted($0) }.joined(separator: \" \")\n        // Use /bin/sh -c to ensure POSIX shell execution regardless of remote login shell (e.g., fish)\n        // Use double quotes for find arguments to avoid nested single-quote escaping complexity\n        let innerCommand = \"cd \\(quotedBase) && { find \\(pathArgs) -type f -name \\\"*.jsonl\\\" -printf \\\"%p|%s|%T@\\\\n\\\" 2>/dev/null || true; }\"\n        return \"/bin/sh -c '\\(innerCommand.replacingOccurrences(of: \"'\", with: \"'\\\\''\"))'\"\n    }\n\n    private func needsDownload(localURL: URL, remoteSize: UInt64, remoteTimestamp: TimeInterval) -> Bool {\n        guard let attrs = try? fileManager.attributesOfItem(atPath: localURL.path) else { return true }\n        let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0\n        guard size == remoteSize else { return true }\n        if let mtime = attrs[.modificationDate] as? Date {\n            let delta = abs(mtime.timeIntervalSince1970 - remoteTimestamp)\n            if delta > 0.5 { return true }\n        } else {\n            return true\n        }\n        return false\n    }\n\n    private func normalizeRemoteBaseForTransfer(_ base: String) -> String {\n        if base.hasPrefix(\"$HOME\") {\n            let tail = base.dropFirst(\"$HOME\".count)\n            if tail.hasPrefix(\"/\") {\n                return \"~\" + tail\n            }\n            return \"~/\" + tail\n        }\n        return base\n    }\n\n    private func relativeDirectories(for scope: SessionLoadScope) -> [String] {\n        let calendar = Calendar.current\n        switch scope {\n        case .all:\n            return []\n        case .today:\n            let today = calendar.startOfDay(for: Date())\n            return [formatDayComponents(calendar: calendar, date: today)]\n        case .day(let date):\n            let start = calendar.startOfDay(for: date)\n            return [formatDayComponents(calendar: calendar, date: start)]\n        case .month(let date):\n            let comps = calendar.dateComponents([.year, .month], from: date)\n            guard let year = comps.year, let month = comps.month else { return [] }\n            return [String(format: \"%04d/%02d\", year, month)]\n        }\n    }\n\n    private func formatDayComponents(calendar: Calendar, date: Date) -> String {\n        let comps = calendar.dateComponents([.year, .month, .day], from: date)\n        guard let year = comps.year, let month = comps.month, let day = comps.day else { return \".\" }\n        return String(format: \"%04d/%02d/%02d\", year, month, day)\n    }\n\n    private func doubleQuoted(_ text: String) -> String {\n        \"\\\"\" + text.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\") + \"\\\"\"\n    }\n\n    private func joinRemote(base: String, relative: String) -> String {\n        if base.hasSuffix(\"/\") {\n            return base + relative\n        }\n        return base + \"/\" + relative\n    }\n}\n"
  },
  {
    "path": "services/RemoteSessionProvider+Adapter.swift",
    "content": "import Foundation\n\nstruct RemoteSessionProviderAdapter: SessionProvider {\n  let kind: SessionSource.Kind\n  let identifier: String\n  let label: String\n  let remoteKind: RemoteSessionKind\n  let provider: RemoteSessionProvider\n\n  init(kind: SessionSource.Kind, remoteKind: RemoteSessionKind, provider: RemoteSessionProvider, label: String) {\n    self.kind = kind\n    self.remoteKind = remoteKind\n    self.provider = provider\n    self.identifier = \"remote-\\(label.lowercased())\"\n    self.label = label\n  }\n\n  func load(context: SessionProviderContext) async throws -> SessionProviderResult {\n    let hosts = context.enabledRemoteHosts\n    guard !hosts.isEmpty else {\n      return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true)\n    }\n    switch remoteKind {\n    case .codex:\n      let summaries = await provider.codexSessions(scope: context.scope, enabledHosts: hosts)\n      return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false)\n    case .claude:\n      let summaries = await provider.claudeSessions(scope: context.scope, enabledHosts: hosts)\n      return SessionProviderResult(summaries: summaries, coverage: nil, cacheHit: false)\n    }\n  }\n}\n"
  },
  {
    "path": "services/RemoteSessionProvider.swift",
    "content": "import Foundation\n\nenum RemoteSyncState: Equatable {\n    case idle\n    case syncing\n    case succeeded(Date)\n    case failed(Date, String)\n}\n\nactor RemoteSessionProvider {\n    private let hostResolver: SSHConfigResolver\n    private let mirror: RemoteSessionMirror\n    private let indexer: SessionIndexer\n    private let parser = ClaudeSessionParser()\n    private let fileManager: FileManager\n    private var cachedHosts: [SSHHost] = []\n    private var cachedConfigTimestamp: Date?\n    private var lastHostsRefresh: Date?\n    private var mirrorStore: [String: RemoteMirrorOutcome] = [:]\n    private var syncStates: [String: RemoteSyncState] = [:]\n    // Scope-based refresh debouncing: track active and recent refreshes\n    private var activeRefreshes: Set<String> = []  // Currently executing refresh keys\n    private var lastRefreshTimes: [String: Date] = [:]\n    private let recentCompletionWindow: TimeInterval = 0.1  // 100ms to filter rapid duplicates\n\n    init(\n        hostResolver: SSHConfigResolver = SSHConfigResolver(),\n        mirror: RemoteSessionMirror = RemoteSessionMirror(),\n        indexer: SessionIndexer = SessionIndexer(),\n        fileManager: FileManager = .default\n    ) {\n        self.hostResolver = hostResolver\n        self.mirror = mirror\n        self.indexer = indexer\n        self.fileManager = fileManager\n    }\n\n    func codexSessions(scope: SessionLoadScope, enabledHosts: Set<String>) async -> [SessionSummary] {\n        let hosts = filteredHosts(enabledHosts)\n        guard !hosts.isEmpty else { return [] }\n\n        let key = refreshKey(scope: scope, kind: .codex, hosts: enabledHosts)\n\n        // Skip if already executing or just completed\n        if shouldSkipRefresh(key: key) {\n            return []\n        }\n\n        activeRefreshes.insert(key)\n        defer {\n            activeRefreshes.remove(key)\n            lastRefreshTimes[key] = Date()\n        }\n\n        return await fetchCodexSessions(scope: scope, hosts: hosts)\n    }\n\n    func claudeSessions(scope: SessionLoadScope, enabledHosts: Set<String>) async -> [SessionSummary] {\n        let hosts = filteredHosts(enabledHosts)\n        guard !hosts.isEmpty else { return [] }\n\n        let key = refreshKey(scope: scope, kind: .claude, hosts: enabledHosts)\n\n        // Skip if already executing or just completed\n        if shouldSkipRefresh(key: key) {\n            return []\n        }\n\n        activeRefreshes.insert(key)\n        defer {\n            activeRefreshes.remove(key)\n            lastRefreshTimes[key] = Date()\n        }\n\n        let sessions = await fetchClaudeSessions(scope: scope, hosts: hosts)\n        await cacheExternalSummaries(sessions)\n        return sessions\n    }\n\n    func collectCWDAggregates(kind: RemoteSessionKind, enabledHosts: Set<String>) async -> [String: Int] {\n        let hosts = filteredHosts(enabledHosts)\n        guard !hosts.isEmpty else { return [:] }\n        var result: [String: Int] = [:]\n        for host in hosts {\n            do {\n                guard let outcome = mirrorOutcome(host: host, kind: kind) else { continue }\n                switch kind {\n                case .codex:\n                    let counts = try await collectCodexCounts(localRoot: outcome.localRoot)\n                    for (key, value) in counts {\n                        result[key, default: 0] += value\n                    }\n                case .claude:\n                    let counts = collectClaudeCounts(localRoot: outcome.localRoot)\n                    for (key, value) in counts {\n                        result[key, default: 0] += value\n                    }\n                }\n            } catch {\n                continue\n            }\n        }\n        return result\n    }\n\n    func countSessions(kind: RemoteSessionKind, enabledHosts: Set<String>) async -> Int {\n        let hosts = filteredHosts(enabledHosts)\n        guard !hosts.isEmpty else { return 0 }\n        var total = 0\n        for host in hosts {\n            guard let outcome = mirrorOutcome(host: host, kind: kind) else { continue }\n            switch kind {\n            case .codex:\n                let enumerator = fileManager.enumerator(\n                    at: outcome.localRoot,\n                    includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],\n                    options: [.skipsHiddenFiles, .skipsPackageDescendants]\n                )\n                while let url = enumerator?.nextObject() as? URL {\n                    guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n                    let name = url.deletingPathExtension().lastPathComponent\n                    if name.hasPrefix(\"agent-\") { continue }\n                    if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),\n                       let s = values.fileSize, s == 0 { continue }\n                    total += 1\n                }\n            case .claude:\n                let enumerator = fileManager.enumerator(\n                    at: outcome.localRoot,\n                    includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],\n                    options: [.skipsHiddenFiles, .skipsPackageDescendants]\n                )\n                while let url = enumerator?.nextObject() as? URL {\n                    guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n                    let name = url.deletingPathExtension().lastPathComponent\n                    if name.hasPrefix(\"agent-\") { continue }\n                    if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),\n                       let s = values.fileSize, s == 0 { continue }\n                    total += 1\n                }\n            }\n        }\n        return total\n    }\n\n    // MARK: - Private helpers\n\n    private func fetchCodexSessions(scope: SessionLoadScope, hosts: [SSHHost]) async -> [SessionSummary] {\n        var aggregate: [SessionSummary] = []\n        for host in hosts {\n            do {\n                guard let outcome = mirrorOutcome(host: host, kind: .codex) else { continue }\n                let summaries = try await indexer.refreshSessions(\n                    root: outcome.localRoot,\n                    scope: scope,\n                    dateRange: nil,\n                    projectIds: nil,\n                    projectDirectories: nil,\n                    dateDimension: .updated\n                )\n                for summary in summaries {\n                    guard let metadata = outcome.fileMap[summary.fileURL] else { continue }\n                    let remoteSource: SessionSource = .codexRemote(host: host.alias)\n                    aggregate.append(\n                        summary.withRemoteMetadata(\n                            source: remoteSource,\n                            remotePath: metadata.remotePath\n                        )\n                    )\n                }\n            } catch {\n                continue\n            }\n        }\n        return aggregate\n    }\n\n    private func fetchClaudeSessions(scope: SessionLoadScope, hosts: [SSHHost]) async -> [SessionSummary] {\n        var aggregate: [SessionSummary] = []\n        for host in hosts {\n            guard let outcome = mirrorOutcome(host: host, kind: .claude) else { continue }\n            let sessions = loadClaudeSessions(\n                at: outcome.localRoot,\n                scope: scope,\n                host: host.alias,\n                fileMap: outcome.fileMap\n            )\n            aggregate.append(contentsOf: sessions)\n        }\n        return aggregate\n    }\n\n    private func collectCodexCounts(localRoot: URL) async throws -> [String: Int] {\n        let counts = await indexer.collectCWDCounts(root: localRoot)\n        return counts\n    }\n\n    private func collectClaudeCounts(localRoot: URL) -> [String: Int] {\n        guard let enumerator = fileManager.enumerator(\n            at: localRoot,\n            includingPropertiesForKeys: [.isRegularFileKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants]\n        ) else { return [:] }\n        var counts: [String: Int] = [:]\n        for case let url as URL in enumerator {\n            guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n            if let parsed = parser.parse(at: url) {\n                counts[parsed.summary.cwd, default: 0] += 1\n            }\n        }\n        return counts\n    }\n\n    private func loadClaudeSessions(\n        at root: URL,\n        scope: SessionLoadScope,\n        host: String,\n        fileMap: [URL: RemoteMirrorOutcome.MirroredFile]\n    ) -> [SessionSummary] {\n        guard let enumerator = fileManager.enumerator(\n            at: root,\n            includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],\n            options: [.skipsHiddenFiles, .skipsPackageDescendants]\n        ) else { return [] }\n        var sessions: [SessionSummary] = []\n        for case let url as URL in enumerator {\n            guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n            let fileSize = resolveFileSize(for: url)\n            guard let parsed = parser.parse(at: url, fileSize: fileSize) else { continue }\n            guard matches(scope: scope, summary: parsed.summary) else { continue }\n            guard let metadata = fileMap[url] else { continue }\n            sessions.append(\n                parsed.summary.withRemoteMetadata(\n                    source: .claudeRemote(host: host),\n                    remotePath: metadata.remotePath\n                )\n            )\n        }\n        return sessions\n    }\n\n    private func filteredHosts(_ enabledHosts: Set<String>) -> [SSHHost] {\n        guard !enabledHosts.isEmpty else { return [] }\n        if shouldReloadHosts() {\n            cachedHosts = hostResolver.resolvedHosts()\n            cachedConfigTimestamp = currentConfigTimestamp()\n            lastHostsRefresh = Date()\n            mirrorStore.removeAll()\n        }\n        let enabledLowercased = Set(enabledHosts.map { $0.lowercased() })\n        return cachedHosts.filter { enabledLowercased.contains($0.alias.lowercased()) }\n    }\n\n    private func shouldReloadHosts() -> Bool {\n        if cachedHosts.isEmpty { return true }\n        let configChanged = currentConfigTimestamp() != cachedConfigTimestamp\n        if configChanged { return true }\n        return false\n    }\n\n    private func currentConfigTimestamp() -> Date? {\n        let attrs = try? fileManager.attributesOfItem(atPath: hostResolver.configurationURL.path)\n        return attrs?[.modificationDate] as? Date\n    }\n\n    private func cachedMirrorOutcome(\n        host: SSHHost,\n        kind: RemoteSessionKind,\n        scope: SessionLoadScope,\n        force: Bool = false\n    ) async throws -> RemoteMirrorOutcome {\n        let key = mirrorCacheKey(host: host, kind: kind, scope: scope)\n        if !force, let cached = mirrorStore[key] {\n            return cached\n        }\n        let outcome = try await mirror.ensureMirror(host: host, kind: kind, scope: scope)\n        mirrorStore[key] = outcome\n        return outcome\n    }\n\n    private func mirrorOutcome(host: SSHHost, kind: RemoteSessionKind) -> RemoteMirrorOutcome? {\n        mirrorStore[mirrorCacheKey(host: host, kind: kind, scope: .all)]\n    }\n\n    private func mirrorCacheKey(host: SSHHost, kind: RemoteSessionKind, scope: SessionLoadScope) -> String {\n        mirrorCacheKey(alias: host.alias, kind: kind, scope: scope)\n    }\n\n    private func mirrorCacheKey(alias: String, kind: RemoteSessionKind, scope: SessionLoadScope) -> String {\n        alias.lowercased() + \"|\" + kind.rawValue + \"|\" + scopeKey(scope)\n    }\n\n    private func scopeKey(_ scope: SessionLoadScope) -> String {\n        switch scope {\n        case .all: return \"all\"\n        case .today: return \"today\"\n        case .day(let date): return \"day-\\(Int(date.timeIntervalSince1970))\"\n        case .month(let date): return \"month-\\(Int(date.timeIntervalSince1970))\"\n        }\n    }\n\n    func syncHosts(_ enabledHosts: Set<String>, force: Bool) async {\n        let hosts = filteredHosts(enabledHosts)\n        guard !hosts.isEmpty else { return }\n        for host in hosts {\n            syncStates[host.alias] = .syncing\n            do {\n                _ = try await cachedMirrorOutcome(host: host, kind: .codex, scope: .all, force: true)\n                _ = try await cachedMirrorOutcome(host: host, kind: .claude, scope: .all, force: true)\n                syncStates[host.alias] = .succeeded(Date())\n            } catch {\n                syncStates[host.alias] = .failed(Date(), formatSyncError(error))\n            }\n        }\n    }\n\n    func syncStatusSnapshot() -> [String: RemoteSyncState] {\n        syncStates\n    }\n\n    private func formatSyncError(_ error: Error) -> String {\n        if let shell = error as? ShellCommandError {\n            switch shell {\n            case .commandFailed(let executable, _, let stderr, let exitCode):\n                if !stderr.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                    return \"\\(stderr.trimmingCharacters(in: .whitespacesAndNewlines)) (\\(executable) exited \\(exitCode))\"\n                }\n                return \"\\(executable) exited with code \\(exitCode)\"\n            }\n        }\n        return error.localizedDescription\n    }\n\n    private func matches(scope: SessionLoadScope, summary: SessionSummary) -> Bool {\n        let calendar = Calendar.current\n        let referenceDates = [\n            summary.startedAt,\n            summary.lastUpdatedAt ?? summary.startedAt\n        ]\n        switch scope {\n        case .all:\n            return true\n        case .today:\n            return referenceDates.contains(where: { calendar.isDateInToday($0) })\n        case .day(let day):\n            return referenceDates.contains(where: { calendar.isDate($0, inSameDayAs: day) })\n        case .month(let date):\n            return referenceDates.contains {\n                calendar.isDate($0, equalTo: date, toGranularity: .month)\n            }\n        }\n    }\n\n    private func resolveFileSize(for url: URL) -> UInt64? {\n        if let values = try? url.resourceValues(forKeys: [.fileSizeKey]),\n           let size = values.fileSize {\n            return UInt64(size)\n        }\n        if let attributes = try? fileManager.attributesOfItem(atPath: url.path),\n           let number = attributes[.size] as? NSNumber {\n            return number.uint64Value\n        }\n        return nil\n    }\n\n    private func cacheExternalSummaries(_ summaries: [SessionSummary]) async {\n        guard !summaries.isEmpty else { return }\n        await indexer.cacheExternalSummaries(summaries)\n    }\n\n    private func refreshKey(scope: SessionLoadScope, kind: RemoteSessionKind, hosts: Set<String>) -> String {\n        let scopePart = scopeKey(scope)\n        let hostsPart = hosts.sorted().joined(separator: \",\")\n        return \"\\(kind.rawValue)|\\(scopePart)|\\(hostsPart)\"\n    }\n\n    private func shouldSkipRefresh(key: String) -> Bool {\n        // Skip if already executing\n        if activeRefreshes.contains(key) {\n            return true\n        }\n\n        // Skip if just completed (< 100ms) to filter rapid duplicates\n        guard let lastTime = lastRefreshTimes[key] else { return false }\n        return Date().timeIntervalSince(lastTime) < recentCompletionWindow\n    }\n}\n"
  },
  {
    "path": "services/RepoContentSearchService.swift",
    "content": "import Foundation\n#if canImport(Darwin)\nimport Darwin\n#endif\n\nactor RepoContentSearchService {\n  enum SearchError: Error {\n    case executableMissing\n    case failed(String)\n  }\n\n  private var activeProcess: Process?\n\n  func cancel() {\n    activeProcess?.terminate()\n    activeProcess = nil\n  }\n\n  func searchFilesContaining(\n    _ term: String,\n    in root: URL,\n    limit: Int = 4000\n  ) async throws -> Set<String> {\n    let trimmed = term.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return [] }\n    precondition(limit > 0, \"limit must be positive\")\n    activeProcess?.terminate()\n\n    var env = ProcessInfo.processInfo.environment\n    let defaultPath = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n    let existingPath = env[\"PATH\"] ?? ProcessInfo.processInfo.environment[\"PATH\"]\n    env[\"PATH\"] = [defaultPath, existingPath]\n      .compactMap { $0 }\n      .joined(separator: \":\")\n\n    let process = Process()\n    process.environment = env\n    process.currentDirectoryURL = root\n    process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n    process.arguments = [\n      \"rg\",\n      \"--files-with-matches\",\n      \"--hidden\",\n      \"--follow\",\n      \"--no-messages\",\n      \"--ignore-case\",\n      \"--color\",\n      \"never\",\n      \"--fixed-strings\",\n      trimmed,\n      \".\"\n    ]\n\n    let stdout = Pipe()\n    let stderr = Pipe()\n    process.standardOutput = stdout\n    process.standardError = stderr\n\n    do {\n      try process.run()\n    } catch {\n      if (error as NSError).code == ENOENT {\n        throw SearchError.executableMissing\n      }\n      throw error\n    }\n\n    activeProcess = process\n    var files: Set<String> = []\n    var truncated = false\n\n    do {\n      for try await rawLine in stdout.fileHandleForReading.bytes.lines {\n        if Task.isCancelled {\n          process.terminate()\n          throw CancellationError()\n        }\n        let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !line.isEmpty else { continue }\n        let normalized = line.hasPrefix(\"./\") ? String(line.dropFirst(2)) : line\n        files.insert(normalized)\n        if files.count >= limit {\n          truncated = true\n          process.terminate()\n          break\n        }\n      }\n    } catch is CancellationError {\n      process.terminate()\n      throw CancellationError()\n    }\n\n    process.waitUntilExit()\n    activeProcess = nil\n\n    let status = process.terminationStatus\n    if !truncated && status != 0 && status != 1 {\n      let errData = try? stderr.fileHandleForReading.readToEnd()\n      let message = errData.flatMap { String(data: $0, encoding: .utf8) } ?? \"ripgrep exit code \\(status)\"\n      throw SearchError.failed(message.trimmingCharacters(in: .whitespacesAndNewlines))\n    }\n\n    return files\n  }\n}\n"
  },
  {
    "path": "services/RipgrepDiskCache.swift",
    "content": "import Foundation\n\n// Persistent disk cache for ripgrep-derived data.\n// Stores per-file, per-month day coverage and per-file tool invocation counts.\n// Keyed by absolute file path + month key (yyyy-MM) and validated by file mtime.\n\nactor RipgrepDiskCache {\n    private struct CoverageRecord: Codable, Hashable {\n        let path: String\n        let monthKey: String\n        let mtime: TimeInterval?\n        let days: [Int]\n        var lastAccess: TimeInterval = Date().timeIntervalSince1970\n    }\n\n    private struct ToolRecord: Codable, Hashable {\n        let path: String\n        let mtime: TimeInterval?\n        let count: Int\n        var lastAccess: TimeInterval = Date().timeIntervalSince1970\n    }\n\n    private struct Snapshot: Codable {\n        var version: Int\n        var coverage: [CoverageRecord]\n        var tools: [ToolRecord]\n    }\n\n    // LRU limits: prevent unbounded cache growth\n    private let maxCoverageEntries = 10_000  // ~1000 sessions × 12 months × 0.8\n    private let maxToolEntries = 5_000       // ~5000 unique session files\n\n    private let fileManager = FileManager.default\n    private let cacheDirectory: URL\n    private let url: URL\n    private var coverageMap: [String: CoverageRecord] = [:] // key: path|monthKey\n    private var toolMap: [String: ToolRecord] = [:]        // key: path\n    private var saveTask: Task<Void, Never>? = nil\n    private var dirty = false\n\n    init() {\n        let base = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!\n            .appendingPathComponent(\"CodMate\", isDirectory: true)\n        try? fileManager.createDirectory(at: base, withIntermediateDirectories: true)\n        self.cacheDirectory = base\n        self.url = base.appendingPathComponent(\"rg-cache-v1.json\")\n        // Load synchronously in init - safe because actor hasn't started yet\n        if let data = try? Data(contentsOf: url),\n           let snap = try? PropertyListDecoder().decode(Snapshot.self, from: data),\n           snap.version == 1 {\n            for rec in snap.coverage {\n                coverageMap[rec.path + \"|\" + rec.monthKey] = rec\n            }\n            for rec in snap.tools {\n                toolMap[rec.path] = rec\n            }\n        }\n    }\n\n    private func makeKey(path: String, monthKey: String) -> String { path + \"|\" + monthKey }\n\n\n    private func scheduleSave() {\n        guard saveTask == nil else { return }\n        dirty = true\n        saveTask = Task { [weak self] in\n            try? await Task.sleep(nanoseconds: 250_000_000)\n            await self?.saveNow()\n        }\n    }\n\n    private func saveNow() {\n        saveTask = nil\n        guard dirty else { return }\n        dirty = false\n        evictOldEntriesIfNeeded()\n        let snap = Snapshot(\n            version: 1,\n            coverage: Array(coverageMap.values),\n            tools: Array(toolMap.values)\n        )\n        if let data = try? PropertyListEncoder().encode(snap) {\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    /// Evict oldest 20% of entries when exceeding size limits (LRU policy)\n    private func evictOldEntriesIfNeeded() {\n        // Evict coverage entries if over limit\n        if coverageMap.count > maxCoverageEntries {\n            let sortedByAccess = coverageMap.sorted { $0.value.lastAccess < $1.value.lastAccess }\n            let keepCount = Int(Double(maxCoverageEntries) * 0.8)  // Keep 80%, evict 20%\n            let toKeep = Array(sortedByAccess.suffix(keepCount))\n            coverageMap = Dictionary(uniqueKeysWithValues: toKeep.map { ($0.key, $0.value) })\n            dirty = true\n        }\n\n        // Evict tool entries if over limit\n        if toolMap.count > maxToolEntries {\n            let sortedByAccess = toolMap.sorted { $0.value.lastAccess < $1.value.lastAccess }\n            let keepCount = Int(Double(maxToolEntries) * 0.8)\n            let toKeep = Array(sortedByAccess.suffix(keepCount))\n            toolMap = Dictionary(uniqueKeysWithValues: toKeep.map { ($0.key, $0.value) })\n            dirty = true\n        }\n    }\n\n    // MARK: - Coverage\n    func getCoverage(path: String, monthKey: String, mtime: Date?) -> Set<Int>? {\n        let key = makeKey(path: path, monthKey: monthKey)\n        guard var rec = coverageMap[key] else { return nil }\n        let target = mtime?.timeIntervalSince1970\n        guard rec.mtime == target, !rec.days.isEmpty else { return nil }\n\n        // Update last access time for LRU\n        rec.lastAccess = Date().timeIntervalSince1970\n        coverageMap[key] = rec\n        dirty = true\n\n        return Set(rec.days)\n    }\n\n    func setCoverage(path: String, monthKey: String, mtime: Date?, days: Set<Int>) {\n        var rec = CoverageRecord(path: path, monthKey: monthKey, mtime: mtime?.timeIntervalSince1970, days: Array(days))\n        rec.lastAccess = Date().timeIntervalSince1970\n        coverageMap[makeKey(path: path, monthKey: monthKey)] = rec\n        scheduleSave()\n    }\n\n    func invalidateCoverage(path: String) {\n        coverageMap = coverageMap.filter { !$0.key.hasPrefix(path + \"|\") }\n        scheduleSave()\n    }\n\n    func invalidateCoverage(monthKey: String, projectPath: String?) {\n        if let base = projectPath {\n            coverageMap = coverageMap.filter { key, rec in !(rec.monthKey == monthKey && rec.path.hasPrefix(base)) }\n        } else {\n            coverageMap = coverageMap.filter { _, rec in rec.monthKey != monthKey }\n        }\n        scheduleSave()\n    }\n\n    // MARK: - Tools\n    func getToolCount(path: String, mtime: Date?) -> Int? {\n        guard var rec = toolMap[path] else { return nil }\n        let target = mtime?.timeIntervalSince1970\n        guard rec.mtime == target else { return nil }\n\n        // Update last access time for LRU\n        rec.lastAccess = Date().timeIntervalSince1970\n        toolMap[path] = rec\n        dirty = true\n\n        return rec.count\n    }\n\n    func setToolCount(path: String, mtime: Date?, count: Int) {\n        var rec = ToolRecord(path: path, mtime: mtime?.timeIntervalSince1970, count: count)\n        rec.lastAccess = Date().timeIntervalSince1970\n        toolMap[path] = rec\n        scheduleSave()\n    }\n\n    func invalidateTools(path: String) {\n        toolMap.removeValue(forKey: path)\n        scheduleSave()\n    }\n}\n\n"
  },
  {
    "path": "services/RipgrepRunner.swift",
    "content": "import Foundation\n\nenum RipgrepError: Error, LocalizedError {\n    case executableMissing\n    case failed(status: Int32, message: String)\n\n    var errorDescription: String? {\n        switch self {\n        case .executableMissing:\n            return \"ripgrep (rg) is not installed or missing from PATH.\"\n        case let .failed(status, message):\n            return \"ripgrep exited with code \\(status): \\(message)\"\n        }\n    }\n}\n\nstruct RipgrepRunner {\n    private static let defaultPath = \"/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin\"\n\n    static func run(\n        arguments: [String],\n        currentDirectory: URL? = nil\n    ) async throws -> [String] {\n        var env = ProcessInfo.processInfo.environment\n        let existingPath = env[\"PATH\"]\n        env[\"PATH\"] = [defaultPath, existingPath]\n            .compactMap { $0?.isEmpty == false ? $0 : nil }\n            .joined(separator: \":\")\n\n        let process = Process()\n        process.environment = env\n        process.currentDirectoryURL = currentDirectory\n        process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n        process.arguments = [\"rg\"] + arguments\n\n        let stdout = Pipe()\n        let stderr = Pipe()\n        process.standardOutput = stdout\n        process.standardError = stderr\n\n        do {\n            try process.run()\n        } catch {\n            if (error as NSError).code == ENOENT {\n                throw RipgrepError.executableMissing\n            }\n            throw error\n        }\n\n        var lines: [String] = []\n        do {\n            for try await rawLine in stdout.fileHandleForReading.bytes.lines {\n                if Task.isCancelled {\n                    process.terminate()\n                    throw CancellationError()\n                }\n                let trimmed = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)\n                if !trimmed.isEmpty {\n                    lines.append(trimmed)\n                }\n            }\n        } catch is CancellationError {\n            process.terminate()\n            throw CancellationError()\n        }\n\n        process.waitUntilExit()\n        let status = process.terminationStatus\n\n        guard status == 0 || status == 1 else {\n            let errData = try? stderr.fileHandleForReading.readToEnd()\n            let errString = errData.flatMap { String(data: $0, encoding: .utf8) }?\n                .trimmingCharacters(in: .whitespacesAndNewlines)\n                ?? \"unknown error\"\n            throw RipgrepError.failed(status: status, message: errString)\n        }\n\n        return lines\n    }\n}\n"
  },
  {
    "path": "services/SSHConfigResolver.swift",
    "content": "import Foundation\n#if os(Linux)\nimport Glibc\n#else\nimport Darwin\n#endif\nimport OSLog\n\nstruct SSHHost: Hashable, Sendable {\n    let alias: String\n    let hostname: String?\n    let port: Int?\n    let user: String?\n    let identityFile: String?\n    let proxyJump: String?\n    let proxyCommand: String?\n    let forwardAgent: Bool?\n    let additionalOptions: [String: String]\n}\n\nfinal class SSHConfigResolver {\n    private let fileManager: FileManager\n    private let configURL: URL\n    private static let logger = Logger(subsystem: \"io.umate.codemate\", category: \"SSHConfigResolver\")\n\n    var configurationURL: URL { configURL }\n\n    private let nestedSSHDefaults: [String] = [\n        \"-o\", \"ControlMaster=no\",\n        \"-o\", \"ControlPersist=no\",\n        \"-o\", \"ControlPath=none\",\n        \"-o\", \"ServerAliveInterval=60\",\n        \"-o\", \"ServerAliveCountMax=3\",\n        \"-o\", \"StrictHostKeyChecking=accept-new\",\n        \"-o\", \"HashKnownHosts=yes\"\n    ]\n    private let maxResolveDepth = 8\n    private var cachedHosts: [SSHHost] = []\n    private var cachedConfigTimestamp: Date?\n    private let hostCacheQueue = DispatchQueue(label: \"io.umate.codemate.sshHostCache\", qos: .utility)\n\n    private struct HostBlock {\n        let patterns: [String]\n        let options: [(String, String)]\n    }\n\n    init(\n        fileManager: FileManager = .default,\n        configURL: URL = SSHConfigResolver.resolvedHomeDirectory()\n            .appendingPathComponent(\".ssh\", isDirectory: true)\n            .appendingPathComponent(\"config\", isDirectory: false)\n    ) {\n        self.fileManager = fileManager\n        self.configURL = configURL\n    }\n\n    /// Cache the resolved home directory to avoid repeated expensive lookups/log spam.\n    private static let cachedHomeDirectory: URL = {\n        // 1. Try to get from pw_dir (user database)\n        if let pw = getpwuid(getuid()), let home = pw.pointee.pw_dir {\n            let homePath = String(cString: home)\n            if !homePath.contains(\"Library/Containers\") {\n                logger.debug(\"Resolved home via pw_dir: \\(homePath, privacy: .public)\")\n                return URL(fileURLWithPath: homePath, isDirectory: true)\n            }\n        }\n\n        // 2. Try to construct from user name\n        if let userName = getpwuid(getuid())?.pointee.pw_name {\n            let userNameStr = String(cString: userName)\n            let constructedPath = \"/Users/\\(userNameStr)\"\n            if FileManager.default.fileExists(atPath: constructedPath) {\n                logger.debug(\"Resolved home via constructed path: \\(constructedPath, privacy: .public)\")\n                return URL(fileURLWithPath: constructedPath, isDirectory: true)\n            }\n        }\n\n        // 3. Try to use shell to get home directory\n        let task = Process()\n        task.launchPath = \"/bin/sh\"\n        task.arguments = [\"-c\", \"echo $HOME\"]\n        let pipe = Pipe()\n        task.standardOutput = pipe\n        task.launch()\n        let data = pipe.fileHandleForReading.readDataToEndOfFile()\n        task.waitUntilExit()\n        if task.terminationStatus == 0,\n           let output = String(data: data, encoding: .utf8)?\n                .trimmingCharacters(in: .whitespacesAndNewlines),\n           !output.contains(\"Library/Containers\"),\n           !output.isEmpty\n        {\n            logger.debug(\"Resolved home via shell: \\(output, privacy: .public)\")\n            return URL(fileURLWithPath: output, isDirectory: true)\n        }\n\n        // 4. Last resort: use the sandboxed path\n        let sandboxPath = FileManager.default.homeDirectoryForCurrentUser\n        logger.debug(\"Resolved home fallback to sandbox path: \\(sandboxPath.path, privacy: .public)\")\n        return sandboxPath\n    }()\n\n    /// Get the real user home directory, even in sandboxed apps\n    static func resolvedHomeDirectory() -> URL {\n        cachedHomeDirectory\n    }\n\n    private func cachedHostsIfValid() -> [SSHHost]? {\n        hostCacheQueue.sync {\n            guard let cachedTimestamp = cachedConfigTimestamp else { return nil }\n            guard cachedTimestamp == currentConfigTimestamp() else { return nil }\n            return cachedHosts\n        }\n    }\n\n    private func currentConfigTimestamp() -> Date? {\n        let attrs = try? fileManager.attributesOfItem(atPath: configURL.path)\n        return attrs?[.modificationDate] as? Date\n    }\n\n    func resolvedHosts(forceReload: Bool = false) -> [SSHHost] {\n        if !forceReload, let cached = cachedHostsIfValid() {\n            return cached\n        }\n        print(\"SSHConfigResolver: Attempting to read SSH config from: \\(configURL.path)\")\n        print(\"SSHConfigResolver: FileManager.default.homeDirectoryForCurrentUser: \\(FileManager.default.homeDirectoryForCurrentUser.path)\")\n        print(\"SSHConfigResolver: ProcessInfo.HOME: \\(ProcessInfo.processInfo.environment[\"HOME\"] ?? \"not found\")\")\n\n        guard fileManager.fileExists(atPath: configURL.path) else {\n            print(\"SSH config file does not exist at: \\(configURL.path)\")\n            return []\n        }\n\n        guard fileManager.isReadableFile(atPath: configURL.path) else {\n            print(\"SSH config file is not readable at: \\(configURL.path)\")\n            return []\n        }\n\n        var blocks: [HostBlock] = []\n        var visited: Set<URL> = []\n        parseConfig(at: configURL, visited: &visited, into: &blocks)\n        let hosts = buildHosts(from: blocks)\n        hostCacheQueue.sync {\n            cachedHosts = hosts\n            cachedConfigTimestamp = currentConfigTimestamp()\n        }\n        return hosts\n    }\n\n    private func parseConfig(\n        at url: URL,\n        visited: inout Set<URL>,\n        into blocks: inout [HostBlock]\n    ) {\n        let canonical = url.standardizedFileURL\n        guard visited.insert(canonical).inserted else {\n            print(\"SSHConfigResolver: Skipping already processed include at \\(canonical.path)\")\n            return\n        }\n\n        guard let raw = try? String(contentsOf: canonical, encoding: .utf8) else {\n            print(\"SSHConfigResolver: Failed to read config at \\(canonical.path)\")\n            return\n        }\n\n        var currentPatterns: [String]? = nil\n        var currentOptions: [(String, String)] = []\n\n        func flushCurrent() {\n            guard let patterns = currentPatterns else { return }\n            guard !patterns.isEmpty else {\n                currentPatterns = nil\n                currentOptions.removeAll()\n                return\n            }\n            if !currentOptions.isEmpty {\n                blocks.append(HostBlock(patterns: patterns, options: currentOptions))\n            }\n            currentPatterns = nil\n            currentOptions.removeAll()\n        }\n\n        let lines = raw.components(separatedBy: .newlines)\n        let baseDirectory = canonical.deletingLastPathComponent()\n\n        for rawLine in lines {\n            let line = rawLine.trimmingCharacters(in: .whitespaces)\n            guard !line.isEmpty else { continue }\n            guard !line.hasPrefix(\"#\") else { continue }\n\n            let lower = line.lowercased()\n            if lower.hasPrefix(\"include \") {\n                flushCurrent()\n                let patternPart = line.dropFirst(\"include\".count)\n                    .trimmingCharacters(in: .whitespacesAndNewlines)\n                let tokens = patternPart.split(whereSeparator: { $0.isWhitespace }).map(String.init)\n                if tokens.isEmpty {\n                    continue\n                }\n                for token in tokens {\n                    let targets = resolveIncludeTargets(token, relativeTo: baseDirectory)\n                    if targets.isEmpty {\n                        print(\"SSHConfigResolver: Include pattern '\\(token)' had no matches\")\n                    } else {\n                        for target in targets {\n                            parseConfig(at: target, visited: &visited, into: &blocks)\n                        }\n                    }\n                }\n                continue\n            }\n\n            if lower.hasPrefix(\"host \") && !lower.hasPrefix(\"hostname \") {\n                flushCurrent()\n                let hostPart = line.dropFirst(\"host\".count).trimmingCharacters(in: .whitespaces)\n                let patterns = hostPart.split(whereSeparator: { $0.isWhitespace }).map(String.init)\n                currentPatterns = patterns\n                continue\n            }\n\n            guard let (key, value) = parseOption(line) else { continue }\n            if currentPatterns == nil {\n                currentPatterns = [\"*\"]\n            }\n            currentOptions.append((key, value))\n        }\n\n        flushCurrent()\n    }\n\n    private func parseOption(_ line: String) -> (String, String)? {\n        let parts = line.split(separator: \" \", maxSplits: 1, omittingEmptySubsequences: true)\n        guard parts.count == 2 else { return nil }\n        let key = parts[0].lowercased()\n        let value = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)\n        return (key, value)\n    }\n\n    private func resolveIncludeTargets(_ pattern: String, relativeTo base: URL) -> [URL] {\n        var expanded = pattern\n        if expanded.hasPrefix(\"~\") {\n            expanded = NSString(string: expanded).expandingTildeInPath\n        } else if !expanded.hasPrefix(\"/\") {\n            expanded = base.appendingPathComponent(expanded).path\n        }\n\n        var globResult = glob_t()\n        defer { globfree(&globResult) }\n        let status = expanded.withCString { cPattern in\n            glob(cPattern, GLOB_TILDE | GLOB_BRACE, nil, &globResult)\n        }\n        guard status == 0 else { return [] }\n        var urls: [URL] = []\n        for index in 0..<Int(globResult.gl_matchc) {\n            if let pointer = globResult.gl_pathv?[index] {\n                let path = String(cString: pointer)\n                urls.append(URL(fileURLWithPath: path))\n            }\n        }\n        return urls\n    }\n\n    private func buildHosts(from blocks: [HostBlock]) -> [SSHHost] {\n        var aliasOrder: [String] = []\n        var seen: Set<String> = []\n        for block in blocks {\n            for pattern in block.patterns {\n                guard !containsWildcards(pattern) else { continue }\n                let key = pattern.lowercased()\n                if seen.insert(key).inserted {\n                    aliasOrder.append(pattern)\n                }\n            }\n        }\n\n        var optionMap: [String: [String: String]] = [:]\n        for alias in aliasOrder {\n            var options: [String: String] = [:]\n            for block in blocks {\n                guard block.patterns.contains(where: { patternMatches($0, alias: alias) }) else { continue }\n                for (key, value) in block.options {\n                    if options[key] == nil {\n                        options[key] = value\n                    }\n                }\n            }\n            optionMap[alias.lowercased()] = options\n        }\n\n        var hosts: [SSHHost] = []\n        for alias in aliasOrder {\n            guard let options = optionMap[alias.lowercased()] else { continue }\n            let host = makeHost(alias: alias, options: options, optionMap: optionMap)\n            hosts.append(host)\n        }\n        return hosts\n    }\n\n    private func makeHost(\n        alias: String,\n        options: [String: String],\n        optionMap: [String: [String: String]],\n        depth: Int = 0\n    ) -> SSHHost {\n        let hostname = options[\"hostname\"]\n        let port = options[\"port\"].flatMap { Int($0) }\n        let user = options[\"user\"]\n        let identityFile = options[\"identityfile\"].map(expandTildeIfNeeded)\n        let proxyJump = resolvedProxyJump(\n            from: options[\"proxyjump\"],\n            optionMap: optionMap,\n            depth: depth\n        )\n        let proxyCommand = resolvedProxyCommand(\n            from: options[\"proxycommand\"],\n            alias: alias,\n            optionMap: optionMap,\n            hostname: hostname ?? alias,\n            port: port,\n            depth: depth\n        )\n        let forwardAgent: Bool?\n        if let agentRaw = options[\"forwardagent\"]?.lowercased() {\n            forwardAgent = agentRaw == \"yes\" || agentRaw == \"true\"\n        } else {\n            forwardAgent = nil\n        }\n\n        return SSHHost(\n            alias: alias,\n            hostname: hostname,\n            port: port,\n            user: user,\n            identityFile: identityFile,\n            proxyJump: proxyJump,\n            proxyCommand: proxyCommand,\n            forwardAgent: forwardAgent,\n            additionalOptions: options\n        )\n    }\n\n    private func resolvedProxyJump(\n        from raw: String?,\n        optionMap: [String: [String: String]],\n        depth: Int\n    ) -> String? {\n        guard let raw = raw, !raw.isEmpty else { return nil }\n        guard depth < maxResolveDepth else { return nil }\n        let hops = raw.split(separator: \",\")\n        let resolved = hops.map { hop -> String in\n            let trimmed = hop.trimmingCharacters(in: .whitespacesAndNewlines)\n            return resolveEndpoint(trimmed, optionMap: optionMap)\n        }\n        return resolved.joined(separator: \",\")\n    }\n\n    private func resolveEndpoint(_ token: String, optionMap: [String: [String: String]]) -> String {\n        if token.contains(\"@\") || token.contains(\":\") {\n            return token\n        }\n        let key = token.lowercased()\n        guard let options = optionMap[key] else { return token }\n        let hostName = options[\"hostname\"] ?? token\n        var result = \"\"\n        if let user = options[\"user\"] {\n            result += \"\\(user)@\"\n        }\n        result += hostName\n        if let portString = options[\"port\"], let port = Int(portString) {\n            result += \":\\(port)\"\n        }\n        return result\n    }\n\n    private func resolvedProxyCommand(\n        from raw: String?,\n        alias: String,\n        optionMap: [String: [String: String]],\n        hostname: String,\n        port: Int?,\n        depth: Int\n    ) -> String? {\n        guard var command = raw, !command.isEmpty else { return nil }\n        guard depth < maxResolveDepth else { return nil }\n        command = command.replacingOccurrences(of: \"%h\", with: hostname)\n        let portString = port.map(String.init) ?? \"22\"\n        command = command.replacingOccurrences(of: \"%p\", with: portString)\n        if let rewritten = rewriteProxyCommand(command, optionMap: optionMap, depth: depth) {\n            return rewritten\n        }\n        return command\n    }\n\n    private func rewriteProxyCommand(\n        _ command: String,\n        optionMap: [String: [String: String]],\n        depth: Int\n    ) -> String? {\n        guard let tokens = shellSplit(command), !tokens.isEmpty else { return nil }\n        let sshCommand = tokens[0]\n        guard sshCommand.hasSuffix(\"ssh\") || sshCommand == \"ssh\" else { return nil }\n        guard let lastToken = tokens.last else { return nil }\n        guard let aliasOptions = optionMap[lastToken.lowercased()] else { return nil }\n        guard let wIndex = tokens.firstIndex(of: \"-W\"), wIndex + 1 < tokens.count else { return nil }\n        let destination = tokens[wIndex + 1]\n\n        let aliasHost = makeHost(\n            alias: lastToken,\n            options: aliasOptions,\n            optionMap: optionMap,\n            depth: depth + 1\n        )\n\n        var rewritten: [String] = [sshCommand]\n        rewritten += nestedSSHDefaults\n        rewritten += sshTokens(for: aliasHost)\n        var index = 1\n        while index < tokens.count - 1 {\n            if index == wIndex {\n                rewritten.append(\"-W\")\n                rewritten.append(destination)\n                index += 2\n                continue\n            }\n            rewritten.append(tokens[index])\n            index += 1\n        }\n\n        let targetHost = aliasHost.hostname ?? lastToken\n        rewritten.append(targetHost)\n\n        return rewritten.map(shellEscape).joined(separator: \" \")\n    }\n\n    private func sshTokens(for host: SSHHost) -> [String] {\n        var tokens: [String] = []\n        if let user = host.user, !user.isEmpty {\n            tokens += [\"-l\", user]\n        }\n        if let port = host.port {\n            tokens += [\"-p\", String(port)]\n        }\n        if let identity = host.identityFile, !identity.isEmpty {\n            tokens += [\"-i\", identity]\n        }\n        if let proxyJump = host.proxyJump, !proxyJump.isEmpty {\n            tokens += [\"-J\", proxyJump]\n        }\n        if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty {\n            tokens += [\"-o\", \"ProxyCommand=\\(proxyCommand)\"]\n        }\n        if let forwardAgent = host.forwardAgent {\n            tokens += [\"-o\", \"ForwardAgent=\\(forwardAgent ? \"yes\" : \"no\")\"]\n        }\n        return tokens\n    }\n\n    private func shellSplit(_ command: String) -> [String]? {\n        var tokens: [String] = []\n        var current = \"\"\n        var inSingle = false\n        var inDouble = false\n        var escaped = false\n\n        for char in command {\n            if escaped {\n                current.append(char)\n                escaped = false\n                continue\n            }\n            if char == \"\\\\\" && !inSingle {\n                escaped = true\n                continue\n            }\n            if char == \"'\" && !inDouble {\n                inSingle.toggle()\n                continue\n            }\n            if char == \"\\\"\" && !inSingle {\n                inDouble.toggle()\n                continue\n            }\n            if char.isWhitespace && !inSingle && !inDouble {\n                if !current.isEmpty {\n                    tokens.append(current)\n                    current = \"\"\n                }\n                continue\n            }\n            current.append(char)\n        }\n\n        if escaped || inSingle || inDouble {\n            return nil\n        }\n        if !current.isEmpty {\n            tokens.append(current)\n        }\n        return tokens\n    }\n\n    private func shellEscape(_ value: String) -> String {\n        guard value.contains(where: { $0.isWhitespace || $0 == \"'\" || $0 == \"\\\"\" }) else {\n            return value\n        }\n        return \"'\\(value.replacingOccurrences(of: \"'\", with: \"'\\\"'\\\"'\"))'\"\n    }\n\n    private func containsWildcards(_ pattern: String) -> Bool {\n        pattern.contains(\"*\") || pattern.contains(\"?\")\n    }\n\n    private func patternMatches(_ pattern: String, alias: String) -> Bool {\n        pattern.withCString { p in\n            alias.withCString { a in\n                fnmatch(p, a, FNM_CASEFOLD) == 0\n            }\n        }\n    }\n\n    private func expandTildeIfNeeded(_ path: String) -> String {\n        guard path.hasPrefix(\"~\") else { return path }\n        let home = Self.resolvedHomeDirectory().path\n        let suffix = path.dropFirst(1)\n        return home + suffix\n    }\n}\n"
  },
  {
    "path": "services/SandboxPermissionsManager.swift",
    "content": "import Foundation\nimport SwiftUI\n\n/// Get the real user home directory, not the sandbox container\nprivate func getRealUserHome() -> String {\n    // Use POSIX API to get the actual user home directory\n    // This works even in sandbox mode\n    if let homeDir = getpwuid(getuid())?.pointee.pw_dir {\n        return String(cString: homeDir)\n    }\n    // Fallback to HOME environment variable\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n        return home\n    }\n    // Last resort fallback\n    return NSHomeDirectory()\n}\n\n/// Manages sandbox permissions for critical directories needed by CodMate\n@MainActor\nfinal class SandboxPermissionsManager: ObservableObject {\n    static let shared = SandboxPermissionsManager()\n\n    @Published var needsAuthorization: Bool = false\n    @Published var missingPermissions: [RequiredDirectory] = []\n\n    enum RequiredDirectory: String, CaseIterable, Identifiable {\n        case codexSessions = \"~/.codex\"\n        case claudeSessions = \"~/.claude\"\n        case geminiSessions = \"~/.gemini\"\n        case codmateData = \"~/.codmate\"\n        case sshConfig = \"~/.ssh\"\n\n        var id: String { rawValue }\n\n        var displayName: String {\n            switch self {\n            case .codexSessions: return \"Codex Directory\"\n            case .claudeSessions: return \"Claude Code Directory\"\n            case .geminiSessions: return \"Gemini Directory\"\n            case .codmateData: return \"CodMate Data Directory\"\n            case .sshConfig: return \"SSH Configuration\"\n            }\n        }\n\n        var description: String {\n            switch self {\n            case .codexSessions:\n                return \"Access Codex session history and data\"\n            case .claudeSessions:\n                return \"Access Claude Code projects and sessions\"\n            case .geminiSessions:\n                return \"Access Gemini CLI session history\"\n            case .codmateData:\n                return \"Access CodMate configuration, notes, and cache\"\n            case .sshConfig:\n                return \"Read your ~/.ssh/config file to discover remote hosts\"\n            }\n        }\n\n        var expandedPath: URL {\n            // Get the real user home directory, NOT the sandbox container\n            let realHomePath = getRealUserHome()\n            let path = rawValue.replacingOccurrences(of: \"~\", with: realHomePath)\n            return URL(fileURLWithPath: path)\n        }\n\n        /// Bookmark key for this directory\n        var bookmarkKey: String {\n            switch self {\n            case .codexSessions: return \"bookmark.codexSessions\"\n            case .claudeSessions: return \"bookmark.claudeSessions\"\n            case .geminiSessions: return \"bookmark.geminiSessions\"\n            case .codmateData: return \"bookmark.codmateData\"\n            case .sshConfig: return \"bookmark.sshConfig\"\n            }\n        }\n    }\n\n    private let bookmarks = SecurityScopedBookmarks.shared\n    private let defaults = UserDefaults.standard\n    private var didRestorePermissions = false\n\n    private init() {\n        checkPermissions()\n    }\n\n    /// Check if all required directories have been authorized\n    func checkPermissions() {\n        guard bookmarks.isSandboxed else {\n            needsAuthorization = false\n            missingPermissions = []\n            return\n        }\n\n        var missing: [RequiredDirectory] = []\n\n        for dir in RequiredDirectory.allCases {\n            if !hasPermission(for: dir) { missing.append(dir) }\n        }\n\n        missingPermissions = missing\n        needsAuthorization = !missing.isEmpty\n    }\n\n    /// Check if we have permission for a specific directory\n    func hasPermission(for directory: RequiredDirectory) -> Bool {\n        guard bookmarks.isSandboxed else { return true }\n\n        // Check if we have a saved bookmark\n        return defaults.data(forKey: directory.bookmarkKey) != nil\n    }\n\n    /// Request authorization for a specific directory\n    func requestPermission(for directory: RequiredDirectory) async -> Bool {\n        guard bookmarks.isSandboxed else { return true }\n\n        return await withCheckedContinuation { continuation in\n            DispatchQueue.main.async {\n                let panel = NSOpenPanel()\n                panel.message = \"CodMate needs access to \\(directory.displayName)\"\n                panel.prompt = \"Grant Access\"\n                panel.canChooseFiles = false\n                panel.canChooseDirectories = true\n                panel.allowsMultipleSelection = false\n                panel.canCreateDirectories = true\n                panel.showsHiddenFiles = true\n\n                // Set the default directory to the real user home directory\n                let url = directory.expandedPath\n\n                // Debug: print the actual path we're trying to access\n                print(\"[SandboxPermissions] Requesting access to: \\(url.path)\")\n                print(\"[SandboxPermissions] Directory exists: \\(FileManager.default.fileExists(atPath: url.path))\")\n\n                if FileManager.default.fileExists(atPath: url.path) {\n                    panel.directoryURL = url\n                } else {\n                    // Show parent directory (user home) if the target doesn't exist\n                    panel.directoryURL = url.deletingLastPathComponent()\n                    panel.message = \"CodMate needs access to \\(directory.displayName)\\n\\nSelect or create the \\(url.lastPathComponent) directory.\"\n                }\n\n                panel.begin { response in\n                    guard response == .OK, let selectedURL = panel.url else {\n                        print(\"[SandboxPermissions] User cancelled or no URL selected\")\n                        continuation.resume(returning: false)\n                        return\n                    }\n\n                    print(\"[SandboxPermissions] User selected: \\(selectedURL.path)\")\n\n                    // Save the security-scoped bookmark\n                    do {\n                        let bookmarkData = try selectedURL.bookmarkData(\n                            options: [.withSecurityScope],\n                            includingResourceValuesForKeys: nil,\n                            relativeTo: nil\n                        )\n                        self.defaults.set(bookmarkData, forKey: directory.bookmarkKey)\n                        self.defaults.synchronize()\n                        \n                        print(\"[SandboxPermissions] Bookmark saved for \\(directory.displayName)\")\n\n                        // Start accessing immediately\n                        if selectedURL.startAccessingSecurityScopedResource() {\n                            print(\"[SandboxPermissions] Successfully started accessing \\(directory.displayName)\")\n                            // Refresh permission status\n                            Task { @MainActor in\n                                self.checkPermissions()\n                            }\n                            continuation.resume(returning: true)\n                        } else {\n                            print(\"[SandboxPermissions] Failed to start accessing resource\")\n                            continuation.resume(returning: false)\n                        }\n                    } catch {\n                        print(\"[SandboxPermissions] Failed to create bookmark: \\(error)\")\n                        continuation.resume(returning: false)\n                    }\n                }\n            }\n        }\n    }\n\n    /// Request all missing permissions in sequence\n    func requestAllMissingPermissions() async -> Bool {\n        guard bookmarks.isSandboxed else { return true }\n\n        var allGranted = true\n\n        for dir in missingPermissions {\n            let granted = await requestPermission(for: dir)\n            if !granted {\n                allGranted = false\n            }\n        }\n\n        checkPermissions()\n        return allGranted\n    }\n    \n    /// Automatically request permissions for directories that don't exist yet but are needed\n    /// This should be called at app launch after restoring existing bookmarks\n    func ensureCriticalDirectoriesAccess() async {\n        guard bookmarks.isSandboxed else { return }\n        \n        // Only request if we actually need these directories\n        let criticalDirs: [RequiredDirectory] = [.codexSessions, .claudeSessions, .geminiSessions, .sshConfig]\n        \n        for dir in criticalDirs {\n            // Skip if we already have permission\n            if hasPermission(for: dir) {\n                continue\n            }\n            \n            // Only request if the directory actually exists\n            let url = dir.expandedPath\n            if FileManager.default.fileExists(atPath: url.path) {\n                print(\"[SandboxPermissions] Found existing directory without permission: \\(dir.displayName)\")\n                // Don't auto-prompt here, just mark as needing attention\n                // User will see the \"Grant Access\" button in toolbar\n            }\n        }\n        \n        checkPermissions()\n    }\n\n    /// Restore access to all previously authorized directories on app launch\n    func restoreAccess() {\n        guard bookmarks.isSandboxed else { return }\n        guard !didRestorePermissions else { return }\n        didRestorePermissions = true\n\n        for dir in RequiredDirectory.allCases {\n            guard let data = defaults.data(forKey: dir.bookmarkKey) else { continue }\n\n            var isStale = false\n            do {\n                let url = try URL(\n                    resolvingBookmarkData: data,\n                    options: [.withSecurityScope],\n                    relativeTo: nil,\n                    bookmarkDataIsStale: &isStale\n                )\n\n                if isStale {\n                    // Refresh the bookmark\n                    let freshData = try url.bookmarkData(\n                        options: [.withSecurityScope],\n                        includingResourceValuesForKeys: nil,\n                        relativeTo: nil\n                    )\n                    defaults.set(freshData, forKey: dir.bookmarkKey)\n                }\n\n                if url.startAccessingSecurityScopedResource() {\n                    print(\"[SandboxPermissions] Successfully restored access to: \\(dir.displayName) at \\(url.path)\")\n                } else {\n                    print(\"[SandboxPermissions] Failed to start access for: \\(dir.displayName)\")\n                }\n            } catch {\n                print(\"[SandboxPermissions] Failed to restore access for \\(dir.displayName): \\(error)\")\n            }\n        }\n\n        checkPermissions()\n    }\n    \n    /// Start accessing a specific directory if we have permission\n    /// Returns true if access was started successfully\n    @discardableResult\n    func startAccessingIfAuthorized(directory: RequiredDirectory) -> Bool {\n        guard bookmarks.isSandboxed else { return true }\n        guard let data = defaults.data(forKey: directory.bookmarkKey) else { return false }\n        \n        var isStale = false\n        do {\n            let url = try URL(\n                resolvingBookmarkData: data,\n                options: [.withSecurityScope],\n                relativeTo: nil,\n                bookmarkDataIsStale: &isStale\n            )\n            \n            if isStale {\n                let freshData = try url.bookmarkData(\n                    options: [.withSecurityScope],\n                    includingResourceValuesForKeys: nil,\n                    relativeTo: nil\n                )\n                defaults.set(freshData, forKey: directory.bookmarkKey)\n            }\n            \n            return url.startAccessingSecurityScopedResource()\n        } catch {\n            print(\"[SandboxPermissions] Failed to start access for \\(directory.displayName): \\(error)\")\n            return false\n        }\n    }\n    \n    /// Check if we can currently access a specific directory path\n    func canAccess(path: String) -> Bool {\n        guard bookmarks.isSandboxed else { return true }\n        \n        // Check if this path is under any of our authorized directories\n        let realHome = getRealUserHome()\n        let normalizedPath = path.replacingOccurrences(of: \"~\", with: realHome)\n        \n        for dir in RequiredDirectory.allCases {\n            let dirPath = dir.expandedPath.path\n            if normalizedPath.hasPrefix(dirPath) {\n                return hasPermission(for: dir)\n            }\n        }\n        \n        return false\n    }\n}\n"
  },
  {
    "path": "services/SecurityScopedBookmarks.swift",
    "content": "import Foundation\nimport Security\n\n/// Manages security-scoped bookmarks for user-selected directories when running in App Sandbox.\n/// Stores bookmarks in UserDefaults and begins access for the app's lifetime.\n@MainActor\nfinal class SecurityScopedBookmarks {\n    static let shared = SecurityScopedBookmarks()\n\n    enum Key: String, CaseIterable {\n        case sessionsRoot = \"bookmark.sessionsRoot\"\n        case notesRoot = \"bookmark.notesRoot\"\n        case projectsRoot = \"bookmark.projectsRoot\"\n    }\n\n    private let defaults: UserDefaults\n    private var activeURLs: [Key: URL] = [:]\n    private var activeDynamic: [String: URL] = [:] // keyed by canonical path\n\n    init(defaults: UserDefaults = .standard) {\n        self.defaults = defaults\n    }\n\n    /// Returns true when running under an App Sandbox container.\n    var isSandboxed: Bool {\n        // Primary: query entitlement from our own signed task\n        if let task = SecTaskCreateFromSelf(nil) {\n            if let val = SecTaskCopyValueForEntitlement(task, \"com.apple.security.app-sandbox\" as CFString, nil) as? Bool {\n                return val\n            }\n        }\n        // Fallback: environment probe (not always present on Developer ID builds)\n        return ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil\n    }\n\n    func save(url: URL, for key: Key) {\n        guard isSandboxed else { return }\n        do {\n            let data = try url.bookmarkData(options: [.withSecurityScope],\n                                            includingResourceValuesForKeys: nil,\n                                            relativeTo: nil)\n            defaults.set(data, forKey: key.rawValue)\n            // Stop any previous access for this key, then start the new one\n            stopAccess(for: key)\n            _ = startAccess(for: key)\n        } catch {\n            // Silently ignore; UI surfaces I/O errors elsewhere\n        }\n    }\n\n    /// Resolve and start access for a bookmark key. Returns the resolved URL if successful.\n    @discardableResult\n    func startAccess(for key: Key) -> URL? {\n        guard isSandboxed else { return nil }\n        guard let data = defaults.data(forKey: key.rawValue) else { return nil }\n        var isStale = false\n        do {\n            let url = try URL(resolvingBookmarkData: data,\n                               options: [.withSecurityScope],\n                               relativeTo: nil,\n                               bookmarkDataIsStale: &isStale)\n            if isStale {\n                // Refresh the bookmark\n                let fresh = try url.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)\n                defaults.set(fresh, forKey: key.rawValue)\n            }\n            if url.startAccessingSecurityScopedResource() {\n                activeURLs[key] = url\n                return url\n            }\n        } catch {\n            return nil\n        }\n        return nil\n    }\n\n    func stopAccess(for key: Key) {\n        guard let url = activeURLs.removeValue(forKey: key) else { return }\n        url.stopAccessingSecurityScopedResource()\n    }\n\n    /// On app launch, attempt to start access for all stored bookmarks.\n    func restoreAndStartAccess() {\n        guard isSandboxed else { return }\n        for key in Key.allCases {\n            _ = startAccess(for: key)\n        }\n    }\n\n    // MARK: - Dynamic bookmarks (per repository root or arbitrary directory)\n\n    private func canonicalPath(for url: URL) -> String {\n        url.standardizedFileURL.resolvingSymlinksInPath().path\n    }\n\n    private var dynamicPrefix: String { \"bookmark.dynamic.\" }\n    private func dynamicKey(for url: URL) -> String { dynamicPrefix + canonicalPath(for: url) }\n\n    func hasDynamicBookmark(for url: URL) -> Bool {\n        let key = dynamicKey(for: url)\n        return defaults.data(forKey: key) != nil\n    }\n\n    /// Save a dynamic security-scoped bookmark for an arbitrary directory.\n    func saveDynamic(url: URL) {\n        guard isSandboxed else { return }\n        do {\n            let data = try url.bookmarkData(options: [.withSecurityScope],\n                                            includingResourceValuesForKeys: nil,\n                                            relativeTo: nil)\n            let key = dynamicKey(for: url)\n            defaults.set(data, forKey: key)\n            defaults.synchronize() // Force immediate write\n            print(\"[SecurityScopedBookmarks] Saved dynamic bookmark for: \\(url.path)\")\n            \n            // Start access immediately after saving\n            if url.startAccessingSecurityScopedResource() {\n                activeDynamic[canonicalPath(for: url)] = url\n                print(\"[SecurityScopedBookmarks] Started accessing: \\(url.path)\")\n            } else {\n                print(\"[SecurityScopedBookmarks] Failed to start accessing after save: \\(url.path)\")\n            }\n        } catch {\n            print(\"[SecurityScopedBookmarks] Failed to save dynamic bookmark: \\(error)\")\n        }\n    }\n    \n    /// Restore and start access for all saved dynamic bookmarks on app launch\n    func restoreAllDynamicBookmarks() {\n        guard isSandboxed else { return }\n        \n        let dict = defaults.dictionaryRepresentation()\n        let keys = dict.keys.filter { $0.hasPrefix(dynamicPrefix) }\n        \n        print(\"[SecurityScopedBookmarks] Restoring \\(keys.count) dynamic bookmarks...\")\n        \n        for key in keys {\n            guard let data = defaults.data(forKey: key) else { continue }\n            \n            var isStale = false\n            do {\n                let url = try URL(resolvingBookmarkData: data, \n                                 options: [.withSecurityScope], \n                                 relativeTo: nil, \n                                 bookmarkDataIsStale: &isStale)\n                \n                if isStale {\n                    print(\"[SecurityScopedBookmarks] Refreshing stale bookmark for: \\(url.path)\")\n                    let fresh = try url.bookmarkData(options: [.withSecurityScope], \n                                                     includingResourceValuesForKeys: nil, \n                                                     relativeTo: nil)\n                    defaults.set(fresh, forKey: key)\n                }\n                \n                if url.startAccessingSecurityScopedResource() {\n                    activeDynamic[canonicalPath(for: url)] = url\n                    print(\"[SecurityScopedBookmarks] Restored access to: \\(url.path)\")\n                } else {\n                    print(\"[SecurityScopedBookmarks] Failed to start access for: \\(url.path)\")\n                }\n            } catch {\n                print(\"[SecurityScopedBookmarks] Failed to restore bookmark: \\(error)\")\n            }\n        }\n    }\n\n    /// Start access for an existing dynamic bookmark. Returns true if access is granted.\n    @discardableResult\n    func startAccessDynamic(for url: URL) -> Bool {\n        guard isSandboxed else { return true }\n\n        let canonical = canonicalPath(for: url)\n\n        // If already accessing this directory, return success immediately\n        if activeDynamic[canonical] != nil {\n            print(\"[SecurityScopedBookmarks] Already accessing: \\(url.path)\")\n            return true\n        }\n\n        let key = dynamicKey(for: url)\n        guard let data = defaults.data(forKey: key) else {\n            print(\"[SecurityScopedBookmarks] No bookmark found for: \\(url.path)\")\n            return false\n        }\n\n        var stale = false\n        do {\n            let resolved = try URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &stale)\n            if stale {\n                print(\"[SecurityScopedBookmarks] Refreshing stale bookmark for: \\(resolved.path)\")\n                let fresh = try resolved.bookmarkData(options: [.withSecurityScope], includingResourceValuesForKeys: nil, relativeTo: nil)\n                defaults.set(fresh, forKey: key)\n            }\n\n            print(\"[SecurityScopedBookmarks] Starting access for: \\(resolved.path)\")\n            if resolved.startAccessingSecurityScopedResource() {\n                activeDynamic[canonicalPath(for: resolved)] = resolved\n                print(\"[SecurityScopedBookmarks] Successfully started access for: \\(resolved.path)\")\n                return true\n            } else {\n                print(\"[SecurityScopedBookmarks] Failed to start access for: \\(resolved.path)\")\n            }\n        } catch {\n            print(\"[SecurityScopedBookmarks] Error resolving bookmark: \\(error)\")\n            return false\n        }\n        return false\n    }\n\n    func stopAccessDynamic(for url: URL) {\n        let key = canonicalPath(for: url)\n        if let u = activeDynamic.removeValue(forKey: key) { u.stopAccessingSecurityScopedResource() }\n    }\n\n    // List all recorded dynamic repository bookmarks\n    func listDynamic() -> [URL] {\n        let dict = defaults.dictionaryRepresentation()\n        let keys = dict.keys.filter { $0.hasPrefix(dynamicPrefix) }\n        var urls: [URL] = []\n        for k in keys.sorted() {\n            if let data = defaults.data(forKey: k) {\n                var stale = false\n                if let url = try? URL(resolvingBookmarkData: data, options: [.withSecurityScope], relativeTo: nil, bookmarkDataIsStale: &stale) {\n                    urls.append(url)\n                }\n            }\n        }\n        return urls\n    }\n\n    func removeDynamic(url: URL) {\n        let key = dynamicKey(for: url)\n        stopAccessDynamic(for: url)\n        defaults.removeObject(forKey: key)\n    }\n}\n"
  },
  {
    "path": "services/SessionActions+Commands.swift",
    "content": "import AppKit\nimport Security\nimport Foundation\n\nextension SessionActions {\n    @MainActor\n    func resume(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHomeOverride: String? = nil\n    ) async throws\n        -> ProcessResult\n    {\n        // Prefer PATH resolution; allow an optional user-specified executable override when valid.\n        let resolvedExec = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n\n        // Prepare arguments first, including async MCP config if needed\n        var additionalEnv: [String: String] = [:]\n        var args: [String]\n        switch session.source.baseKind {\n        case .codex:\n            args = buildResumeArguments(session: session, options: options)\n        case .claude:\n            args = [\"--resume\", session.id]\n            // Apply Claude advanced flags from resume options\n            if options.claudeVerbose { args.append(\"--verbose\") }\n            if options.claudeDebug {\n                args.append(\"-d\")\n                if let f = options.claudeDebugFilter, !f.isEmpty { args.append(f) }\n            }\n            if let pm = options.claudePermissionMode, pm != .default {\n                args.append(contentsOf: [\"--permission-mode\", pm.rawValue])\n            }\n            if options.claudeSkipPermissions { args.append(\"--dangerously-skip-permissions\") }\n            if options.claudeAllowSkipPermissions { args.append(\"--allow-dangerously-skip-permissions\") }\n            // Claude CLI does not support an \"--allow-unsandboxed-commands\" flag; omit it.\n            if let allowed = options.claudeAllowedTools, !allowed.isEmpty {\n                args.append(contentsOf: [\"--allowed-tools\", allowed])\n            }\n            if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty {\n                args.append(contentsOf: [\"--disallowed-tools\", disallowed])\n            }\n            if let addDirs = options.claudeAddDirs, !addDirs.isEmpty {\n                // Split by comma and add multiple flags\n                let parts = addDirs.split(whereSeparator: { $0 == \",\" || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty }\n                for dir in parts { args.append(contentsOf: [\"--add-dir\", dir]) }\n            }\n            if options.claudeIDE { args.append(\"--ide\") }\n            if options.claudeStrictMCP { args.append(\"--strict-mcp-config\") }\n            // Export MCP servers to ~/.claude/settings.json (Claude Code auto-loads from there)\n            let mcpStore = MCPServersStore()\n            try? await mcpStore.exportEnabledForClaudeConfig()\n            if let fb = options.claudeFallbackModel, !fb.isEmpty { args.append(contentsOf: [\"--fallback-model\", fb]) }\n        case .gemini:\n            let config = geminiRuntimeConfiguration(options: options)\n            args = [\"--resume\", conversationId(for: session)] + config.flags\n            additionalEnv = config.environment\n        }\n        \n        return try await withCheckedThrowingContinuation { continuation in\n            let cwd = self.workingDirectory(for: session, override: workingDirectory)\n            let cwdURL = URL(fileURLWithPath: cwd, isDirectory: true)\n            Task.detached {\n                do {\n                    let process = Process()\n                    if resolvedExec == session.source.baseKind.cliExecutableName {\n                        // Use env to resolve the executable on PATH\n                        process.executableURL = URL(fileURLWithPath: \"/usr/bin/env\")\n                        process.arguments = [resolvedExec] + args\n                    } else {\n                        process.executableURL = URL(fileURLWithPath: resolvedExec)\n                        process.arguments = args\n                    }\n                    // Prefer original session cwd if exists\n                    process.currentDirectoryURL = cwdURL\n\n                    let pipe = Pipe()\n                    process.standardOutput = pipe\n                    process.standardError = pipe\n                    var env = ProcessInfo.processInfo.environment\n                    let basePath = CLIEnvironment.buildBasePATH()\n                    if let current = env[\"PATH\"], !current.isEmpty {\n                        env[\"PATH\"] = basePath + \":\" + current\n                    } else {\n                        env[\"PATH\"] = basePath\n                    }\n                    // Prepare environment overlays (Claude Code picks up Anthropic-compatible vars)\n                    if session.source.baseKind == .claude {\n                        var envOverlays: [String: String] = [:]\n                        let registry = ProvidersRegistryService()\n                        let bindings = await registry.getBindings()\n                        let activeId = bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n                        if let activeId, !activeId.isEmpty {\n                            let providers = await registry.listAllProviders()\n                            if let p = providers.first(where: { $0.id == activeId }) {\n                                let conn = p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n                                let loginMethod = conn?.loginMethod?.lowercased() ?? \"api\"\n                                if let base = conn?.baseURL, !base.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                                    envOverlays[\"ANTHROPIC_BASE_URL\"] = base\n                                }\n                                // Subscription login: do not inject token; rely on `claude login`\n                                if loginMethod != \"subscription\" {\n                                    // Map custom env key to ANTHROPIC_AUTH_TOKEN if available in current env\n                                    if let keyName = (p.envKey ?? conn?.envKey), !keyName.isEmpty {\n                                        if let tokenVal = ProcessInfo.processInfo.environment[keyName], !tokenVal.isEmpty {\n                                            envOverlays[\"ANTHROPIC_AUTH_TOKEN\"] = tokenVal\n                                        } else {\n                                            // If keyName itself looks like a token, use it directly\n                                            let v = keyName\n                                            let looksLikeToken = v.lowercased().contains(\"sk-\") || v.hasPrefix(\"eyJ\") || v.contains(\".\")\n                                            if looksLikeToken { envOverlays[\"ANTHROPIC_AUTH_TOKEN\"] = v }\n                                        }\n                                    } else if let tokenVal = ProcessInfo.processInfo.environment[\"ANTHROPIC_AUTH_TOKEN\"], !tokenVal.isEmpty {\n                                        envOverlays[\"ANTHROPIC_AUTH_TOKEN\"] = tokenVal\n                                    }\n                                }\n                                // Aliases: default and small/fast\n                                if let aliases = conn?.modelAliases {\n                                    if let o = aliases[\"opus\"], !o.isEmpty {\n                                        envOverlays[\"ANTHROPIC_DEFAULT_OPUS_MODEL\"] = o\n                                    }\n                                    if let s = aliases[\"sonnet\"], !s.isEmpty {\n                                        envOverlays[\"ANTHROPIC_DEFAULT_SONNET_MODEL\"] = s\n                                    }\n                                    if let h = aliases[\"haiku\"], !h.isEmpty {\n                                        envOverlays[\"ANTHROPIC_DEFAULT_HAIKU_MODEL\"] = h\n                                        envOverlays[\"ANTHROPIC_SMALL_FAST_MODEL\"] = h\n                                    }\n                                    if let d = aliases[\"default\"], !d.isEmpty {\n                                        envOverlays[\"ANTHROPIC_MODEL\"] = d\n                                        if envOverlays[\"ANTHROPIC_DEFAULT_SONNET_MODEL\"] == nil {\n                                            envOverlays[\"ANTHROPIC_DEFAULT_SONNET_MODEL\"] = d\n                                        }\n                                    }\n                                }\n                                // Fall back to registry default model if alias not set\n                                if envOverlays[\"ANTHROPIC_MODEL\"] == nil,\n                                   let dm = bindings.defaultModel?[ProvidersRegistryService.Consumer.claudeCode.rawValue],\n                                   !dm.isEmpty {\n                                    envOverlays[\"ANTHROPIC_MODEL\"] = dm\n                                }\n                            }\n                        }\n                        for (k, v) in envOverlays { env[k] = v }\n                    } else {\n                        // Built-in (no provider selected): respect login method default (subscription) by not injecting token.\n                        // Nothing to inject here; PATH is already set above.\n                    }\n                    if session.source.baseKind == .gemini {\n                        for (key, value) in additionalEnv {\n                            env[key] = value\n                        }\n                    }\n                    if session.source.baseKind == .codex,\n                       let codexHomeOverride,\n                       !codexHomeOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                        // Ensure sessions symlink exists before setting CODEX_HOME\n                        self.ensureSessionsSymlink(at: codexHomeOverride)\n                        env[\"CODEX_HOME\"] = codexHomeOverride\n                    }\n                    process.environment = env\n\n                    try process.run()\n                    process.waitUntilExit()\n\n                    let data = pipe.fileHandleForReading.readDataToEndOfFile()\n                    let output = String(data: data, encoding: .utf8) ?? \"\"\n\n                    if process.terminationStatus == 0 {\n                        continuation.resume(returning: ProcessResult(output: output))\n                    } else {\n                        continuation.resume(\n                            throwing: SessionActionError.resumeFailed(output: output))\n                    }\n                } catch {\n                    continuation.resume(throwing: error)\n                }\n            }\n        }\n    }\n\n    // MARK: - Resume helpers (copy/open Terminal)\n\n    /// Paths that should be symlinked from project-level CODEX_HOME to global ~/.codex\n    /// to avoid unnecessary data fragmentation while keeping project-level MCP/skills configs isolated.\n    ///\n    /// Rationale:\n    /// - We use project-level CODEX_HOME ONLY to enable project-specific MCP servers and skills\n    /// - Everything else (sessions, logs, auth, history) should remain global for consistency\n    /// - config.toml intentionally NOT included (must stay project-level for MCP configs)\n    /// - skills/ directory NOT included (parent dir must exist for skills/.system, but user skills stay project-level)\n    private static let globalSymlinkPaths: [String] = [\n        \"sessions\",          // Session rollout files - global for CodMate indexing\n        \"log\",               // Codex runtime logs - global for unified debugging\n        \"auth.json\",         // API credentials - global (shared across projects)\n        \"history.jsonl\",     // Command history - global (cross-project context)\n        \"skills/.system\",    // System skills cache - global (avoid duplication)\n        \"shell_snapshots\"    // Shell environment snapshots - temporary files, global storage\n    ]\n\n    /// Ensures that non-config files/directories in project-level CODEX_HOME are symlinked\n    /// to the global ~/.codex directory. This keeps data centralized while allowing\n    /// project-specific MCP servers and skills configurations.\n    ///\n    /// - Parameter codexHome: The project-level CODEX_HOME path (e.g., `/path/to/project/.codex`)\n    private func ensureSessionsSymlink(at codexHome: String) {\n        let globalCodexURL = fileManager.homeDirectoryForCurrentUser\n            .appendingPathComponent(\".codex\", isDirectory: true)\n\n        for relativePath in Self.globalSymlinkPaths {\n            ensureSymlink(\n                projectCodexHome: codexHome,\n                globalCodexHome: globalCodexURL.path,\n                relativePath: relativePath\n            )\n        }\n    }\n\n    /// Creates a symlink from project CODEX_HOME to global ~/.codex for a given relative path.\n    ///\n    /// - Parameters:\n    ///   - projectCodexHome: Project-level CODEX_HOME directory path\n    ///   - globalCodexHome: Global ~/.codex directory path\n    ///   - relativePath: Relative path within CODEX_HOME (e.g., \"sessions\", \"auth.json\", \"skills/.system\")\n    private func ensureSymlink(\n        projectCodexHome: String,\n        globalCodexHome: String,\n        relativePath: String\n    ) {\n        let projectCodexURL = URL(fileURLWithPath: projectCodexHome, isDirectory: true)\n        let globalCodexURL = URL(fileURLWithPath: globalCodexHome, isDirectory: true)\n\n        // Build full paths\n        let projectItemURL = projectCodexURL.appendingPathComponent(relativePath)\n        let globalItemURL = globalCodexURL.appendingPathComponent(relativePath)\n\n        // Check if project path already exists\n        var isDirectory: ObjCBool = false\n        let exists = fileManager.fileExists(atPath: projectItemURL.path, isDirectory: &isDirectory)\n\n        if exists {\n            // If it's already a symlink, verify it points to the right location\n            if let destination = try? fileManager.destinationOfSymbolicLink(atPath: projectItemURL.path) {\n                // Resolve both paths to handle relative symlinks\n                let destinationResolved = (destination as NSString).expandingTildeInPath\n                let globalResolved = globalItemURL.path\n\n                if destinationResolved == globalResolved ||\n                   URL(fileURLWithPath: destinationResolved).standardizedFileURL.path ==\n                   URL(fileURLWithPath: globalResolved).standardizedFileURL.path {\n                    return // Already correctly configured\n                }\n\n                // Points to a different location - respect user's choice\n                NSLog(\"Warning: Project path \\(relativePath) symlink points to \\(destination), expected \\(globalItemURL.path). Keeping existing configuration.\")\n                return\n            }\n\n            // If it exists but is not a symlink (real directory or file), respect it\n            NSLog(\"Note: Project path \\(relativePath) exists but is not a symlink. Keeping existing configuration.\")\n            return\n        }\n\n        // Path doesn't exist, create the symlink\n        do {\n            // Ensure parent directory exists in project .codex\n            let parentURL = projectItemURL.deletingLastPathComponent()\n            try fileManager.createDirectory(at: parentURL, withIntermediateDirectories: true)\n\n            // Create the symlink (allow dangling symlinks for files that don't exist yet)\n            try fileManager.createSymbolicLink(\n                at: projectItemURL,\n                withDestinationURL: globalItemURL\n            )\n\n            NSLog(\"Created symlink: \\(projectItemURL.path) -> \\(globalItemURL.path)\")\n        } catch {\n            // Non-fatal: if symlink creation fails, Codex will create a regular directory/file\n            NSLog(\"Warning: Failed to create symlink for \\(relativePath): \\(error)\")\n        }\n    }\n\n    private func shellEscapedPath(_ path: String) -> String {\n        // Simple escape: wrap in single quotes and escape existing single quotes\n        return \"'\" + path.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\"\n    }\n\n    private func shellQuoteIfNeeded(_ s: String) -> String {\n        // Only quote when the string contains whitespace or shell‑sensitive characters.\n        // Keep it readable (e.g., codex stays unquoted).\n        let unsafe: Set<Character> = Set(\" \\t\\n\\r\\\"'`$&|;<>*?()[]{}\\\\\")\n        if s.contains(where: { unsafe.contains($0) }) {\n            return shellEscapedPath(s)\n        }\n        return s\n    }\n\n    private func sshInvocation(\n        host: String,\n        remoteCommand: String,\n        resolvedArguments: [String]? = nil\n    ) -> String {\n        let contextArguments = resolvedArguments ?? resolvedSSHContext(for: host)\n        if let args = contextArguments {\n            let parts = [\"ssh\", \"-t\"] + args\n            let command = parts.map { shellQuoteIfNeeded($0) }.joined(separator: \" \")\n            return \"\\(command) \\(shellSingleQuoted(remoteCommand))\"\n        }\n        return \"ssh -t \\(shellQuoteIfNeeded(host)) \\(shellSingleQuoted(remoteCommand))\"\n    }\n\n    // Reliable conversation id for resume commands: always use the session_meta id\n    // parsed from the log (SessionSummary.id). This matches Codex CLI's\n    // expectation (UUID) and Claude's native id semantics.\n    private func conversationId(for session: SessionSummary) -> String { session.id }\n\n    private func executableName(for kind: SessionSource.Kind) -> String {\n        kind.cliExecutableName\n    }\n\n    func resolvedExecutablePath(for kind: SessionSource.Kind, executableURL: URL) -> String {\n        let candidate = executableURL.path\n        if candidate != \"/usr/bin/env\", fileManager.isExecutableFile(atPath: candidate) {\n            return candidate\n        }\n        return kind.cliExecutableName\n    }\n\n    private func embeddedExportLines(for source: SessionSource) -> [String] { [] }\n\n    struct GeminiRuntimeConfiguration {\n        let flags: [String]\n        let environment: [String: String]\n    }\n\n    func geminiRuntimeConfiguration(options: ResumeOptions) -> GeminiRuntimeConfiguration {\n        var flags: [String] = []\n        var env: [String: String] = [:]\n\n        if options.dangerouslyBypass {\n            flags.append(\"--yolo\")\n            return GeminiRuntimeConfiguration(flags: flags, environment: env)\n        }\n\n        if options.approval == .never {\n            flags.append(\"--yolo\")\n        } else if options.fullAuto {\n            flags.append(contentsOf: [\"--approval-mode\", \"auto_edit\"])\n        }\n\n        var sandboxPreference = options.sandbox\n        if sandboxPreference == nil && options.fullAuto {\n            sandboxPreference = .workspaceWrite\n        }\n\n        if let sandboxPreference, sandboxPreference != .dangerFullAccess {\n            flags.append(\"--sandbox\")\n            env[\"GEMINI_SANDBOX\"] = \"sandbox-exec\"\n            env[\"SEATBELT_PROFILE\"] = geminiSeatbeltProfile(for: sandboxPreference)\n        }\n\n        // Inject CLI Proxy endpoint if provider is configured\n        let providerId = UserDefaults.standard.string(forKey: \"codmate.gemini.proxyProviderId\")\n        if let providerId, !providerId.isEmpty {\n            let portValue = UserDefaults.standard.integer(forKey: \"codmate.localserver.port\")\n            let port = portValue > 0 ? portValue : Int(CLIProxyService.defaultPort)\n            env[\"CODE_ASSIST_ENDPOINT\"] = \"http://127.0.0.1:\\(port)\"\n        }\n\n        return GeminiRuntimeConfiguration(flags: flags, environment: env)\n    }\n\n    func geminiEnvironmentOverrides(options: ResumeOptions) -> [String: String] {\n        geminiRuntimeConfiguration(options: options).environment\n    }\n\n    private func geminiSeatbeltProfile(for mode: SandboxMode) -> String {\n        switch mode {\n        case .readOnly:\n            // Restrictive profile keeps writes tightly contained while allowing network access\n            return \"restrictive-open\"\n        case .workspaceWrite:\n            return \"permissive-open\"\n        case .dangerFullAccess:\n            return \"permissive-open\"\n        }\n    }\n\n    func geminiEnvironmentExportLines(environment: [String: String]) -> [String] {\n        guard !environment.isEmpty else { return [] }\n        return environment\n            .sorted { $0.key < $1.key }\n            .map { \"export \\($0.key)=\\(shellSingleQuoted($0.value))\" }\n    }\n\n    // Build environment overlay map for embedding (DEV CLI console)\n    func embeddedEnvironment(for source: SessionSource) -> [String: String] {\n        var env: [String: String] = [:]\n        env[\"LANG\"] = \"zh_CN.UTF-8\"\n        env[\"LC_ALL\"] = \"zh_CN.UTF-8\"\n        env[\"LC_CTYPE\"] = \"zh_CN.UTF-8\"\n        env[\"TERM\"] = \"xterm-256color\"\n        if source.baseKind == .codex { env[\"CODEX_DISABLE_COLOR_QUERY\"] = \"1\" }\n        return env\n    }\n\n    private func flags(from options: ResumeOptions) -> [String] {\n        // Highest precedence: dangerously bypass\n        if options.dangerouslyBypass { return [\"--dangerously-bypass-approvals-and-sandbox\"] }\n        // Next: full-auto shortcut\n        if options.fullAuto { return [\"--full-auto\"] }\n        // Otherwise explicit -s and -a when provided\n        var f: [String] = []\n        if let s = options.sandbox { f += [\"-s\", s.rawValue] }\n        if let a = options.approval { f += [\"-a\", a.rawValue] }\n        return f\n    }\n\n    func buildResumeCLIInvocation(\n        session: SessionSummary, executablePath: String, options: ResumeOptions, codexHome: String? = nil\n    ) -> String {\n        let exe = shellQuoteIfNeeded(executablePath)\n        switch session.source.baseKind {\n        case .codex:\n            let f = flags(from: options).map { shellQuoteIfNeeded($0) }\n            let cmd: String\n            if f.isEmpty {\n                cmd = \"\\(exe) resume \\(conversationId(for: session))\"\n            } else {\n                cmd = ([exe] + f + [\"resume\", shellQuoteIfNeeded(conversationId(for: session))]).joined(separator: \" \")\n            }\n            return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind)\n        case .claude:\n            let args = claudeResumeArguments(session: session, options: options).map {\n                shellQuoteIfNeeded($0)\n            }\n            return ([exe] + args).joined(separator: \" \")\n        case .gemini:\n            let config = geminiRuntimeConfiguration(options: options)\n            let args: [String] = [\"--resume\", conversationId(for: session)] + config.flags\n            return ([exe] + args.map { shellQuoteIfNeeded($0) }).joined(separator: \" \")\n        }\n    }\n\n    private func claudeResumeArguments(\n        session: SessionSummary,\n        options: ResumeOptions\n    ) -> [String] {\n        var parts: [String] = [\"--resume\", session.id]\n        parts.append(contentsOf: claudeRuntimeArguments(options: options, fallbackModel: options.claudeFallbackModel))\n        return parts\n    }\n\n    private func claudeRuntimeArguments(\n        options: ResumeOptions,\n        fallbackModel: String?\n    ) -> [String] {\n        var parts: [String] = []\n        if options.claudeVerbose { parts.append(\"--verbose\") }\n        if options.claudeDebug {\n            parts.append(\"-d\")\n            if let f = options.claudeDebugFilter, !f.isEmpty { parts.append(f) }\n        }\n        if let pm = options.claudePermissionMode, pm != .default {\n            parts.append(contentsOf: [\"--permission-mode\", pm.rawValue])\n        }\n        if options.claudeSkipPermissions { parts.append(\"--dangerously-skip-permissions\") }\n        if options.claudeAllowSkipPermissions { parts.append(\"--allow-dangerously-skip-permissions\") }\n        if let allowed = options.claudeAllowedTools, !allowed.isEmpty {\n            parts.append(contentsOf: [\"--allowed-tools\", allowed])\n        }\n        if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty {\n            parts.append(contentsOf: [\"--disallowed-tools\", disallowed])\n        }\n        if let addDirs = options.claudeAddDirs, !addDirs.isEmpty {\n            let dirParts = addDirs.split(whereSeparator: { $0 == \",\" || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty }\n            for dir in dirParts { parts.append(contentsOf: [\"--add-dir\", dir]) }\n        }\n        if options.claudeIDE { parts.append(\"--ide\") }\n        if options.claudeStrictMCP { parts.append(\"--strict-mcp-config\") }\n        if let fb = fallbackModel, !fb.isEmpty {\n            parts.append(contentsOf: [\"--fallback-model\", fb])\n        }\n        return parts\n    }\n\n    func buildNewSessionArguments(session: SessionSummary, options: ResumeOptions) -> [String] {\n        switch session.source.baseKind {\n        case .codex:\n            var args: [String] = []\n            if let normalized = normalizedCodexModelName(session.model) {\n                args += [\"--model\", normalized]\n            }\n            args += flags(from: options)\n            return args\n        case .claude:\n            return []\n        case .gemini:\n            var args: [String] = []\n            if let rawModel = session.model?.trimmingCharacters(in: .whitespacesAndNewlines),\n               !rawModel.isEmpty {\n                args += [\"--model\", rawModel]\n            }\n            args.append(contentsOf: geminiRuntimeConfiguration(options: options).flags)\n            return args\n        }\n    }\n\n    func buildNewSessionCLIInvocation(\n        session: SessionSummary,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        executablePath: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        // Check if this is a remote session and return SSH command if so\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remoteCommand = buildRemoteNewShellCommand(\n                session: session,\n                options: options,\n                initialPrompt: initialPrompt\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remoteCommand,\n                resolvedArguments: sshContext\n            )\n        }\n        \n        // Local session handling\n        return buildLocalNewSessionCLIInvocation(\n            session: session,\n            options: options,\n            initialPrompt: initialPrompt,\n            executablePath: executablePath,\n            codexHome: codexHome\n        )\n    }\n\n    func buildLocalNewSessionCLIInvocation(\n        session: SessionSummary,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        executablePath: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        // Local session handling (without checking remote status)\n        switch session.source.baseKind {\n        case .codex:\n            // Launch a fresh Codex session by invoking `codex` directly (no \"new\" subcommand).\n            let exe = shellQuoteIfNeeded(executablePath ?? \"codex\")\n            var parts: [String] = [exe]\n            let args = buildNewSessionArguments(session: session, options: options).map {\n                arg -> String in\n                if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                    return shellEscapedPath(arg)\n                }\n                return arg\n            }\n            parts.append(contentsOf: args)\n            if let prompt = initialPrompt, !prompt.isEmpty {\n                parts.append(shellSingleQuoted(prompt))\n            }\n            let cmd = parts.joined(separator: \" \")\n            return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind)\n        case .claude:\n            var parts: [String] = [shellQuoteIfNeeded(executablePath ?? \"claude\")]\n\n            // Apply model if specified\n            // For Built-in provider: either omit --model or use short alias (sonnet/haiku/opus)\n            // Built-in models follow pattern: claude-3-X-Y-latest or claude-3-5-X-latest\n            // Also handle fallback names like \"Claude\", \"Sonnet\", \"Haiku\", \"Opus\"\n            if let model = session.model, !model.trimmingCharacters(in: .whitespaces).isEmpty {\n                let trimmed = model.trimmingCharacters(in: .whitespaces)\n                let lowerModel = trimmed.lowercased()\n\n                // Check if this is a generic fallback name (Claude) - omit it\n                if lowerModel == \"claude\" {\n                    // Generic fallback - don't pass --model, let CLI use default\n                } else if lowerModel == \"sonnet\" || lowerModel == \"haiku\" || lowerModel == \"opus\" {\n                    // Already a short alias - pass as-is (lowercase)\n                    parts.append(\"--model\")\n                    parts.append(lowerModel)\n                } else if trimmed.hasPrefix(\"claude-\") && trimmed.hasSuffix(\"-latest\") {\n                    // Built-in format detected: use short alias\n                    let shortAlias: String?\n                    if lowerModel.contains(\"sonnet\") {\n                        shortAlias = \"sonnet\"\n                    } else if lowerModel.contains(\"haiku\") {\n                        shortAlias = \"haiku\"\n                    } else if lowerModel.contains(\"opus\") {\n                        shortAlias = \"opus\"\n                    } else {\n                        shortAlias = nil  // Unknown built-in model, omit --model\n                    }\n                    if let alias = shortAlias {\n                        parts.append(\"--model\")\n                        parts.append(alias)\n                    }\n                } else {\n                    // Third-party or custom model: pass as-is\n                    parts.append(\"--model\")\n                    parts.append(shellQuoteIfNeeded(trimmed))\n                }\n            }\n\n            // Apply Claude runtime configuration from options (matching resume behavior)\n            if options.claudeVerbose { parts.append(\"--verbose\") }\n            if options.claudeDebug {\n                parts.append(\"-d\")\n                if let f = options.claudeDebugFilter, !f.isEmpty { parts.append(shellQuoteIfNeeded(f)) }\n            }\n            if let pm = options.claudePermissionMode, pm != .default {\n                parts.append(contentsOf: [\"--permission-mode\", shellQuoteIfNeeded(pm.rawValue)])\n            }\n            if options.claudeSkipPermissions { parts.append(\"--dangerously-skip-permissions\") }\n            if options.claudeAllowSkipPermissions { parts.append(\"--allow-dangerously-skip-permissions\") }\n            // Claude CLI does not support an \"--allow-unsandboxed-commands\" flag; omit it.\n            if let allowed = options.claudeAllowedTools, !allowed.isEmpty {\n                parts.append(contentsOf: [\"--allowed-tools\", shellQuoteIfNeeded(allowed)])\n            }\n            if let disallowed = options.claudeDisallowedTools, !disallowed.isEmpty {\n                parts.append(contentsOf: [\"--disallowed-tools\", shellQuoteIfNeeded(disallowed)])\n            }\n            if let addDirs = options.claudeAddDirs, !addDirs.isEmpty {\n                let dirParts = addDirs.split(whereSeparator: { $0 == \",\" || $0.isWhitespace }).map { String($0) }.filter { !$0.isEmpty }\n                for dir in dirParts { parts.append(contentsOf: [\"--add-dir\", shellQuoteIfNeeded(dir)]) }\n            }\n            if options.claudeIDE { parts.append(\"--ide\") }\n            if options.claudeStrictMCP { parts.append(\"--strict-mcp-config\") }\n            if let fb = options.claudeFallbackModel, !fb.isEmpty { parts.append(contentsOf: [\"--fallback-model\", shellQuoteIfNeeded(fb)]) }\n\n            // Note: MCP config file is only attached in actual process execution (resume method),\n            // not in CLI invocation strings for external terminals, as it requires async export\n\n            if let prompt = initialPrompt, !prompt.isEmpty {\n                parts.append(shellSingleQuoted(prompt))\n            }\n            return parts.joined(separator: \" \")\n        case .gemini:\n            let exe = shellQuoteIfNeeded(executablePath ?? \"gemini\")\n            var parts: [String] = [exe]\n            let args = buildNewSessionArguments(session: session, options: options).map {\n                arg -> String in\n                if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                    return shellEscapedPath(arg)\n                }\n                return arg\n            }\n            parts.append(contentsOf: args)\n            if let prompt = initialPrompt, !prompt.isEmpty {\n                parts.append(shellSingleQuoted(prompt))\n            }\n            return parts.joined(separator: \" \")\n        }\n    }\n\n    func buildResumeArguments(session: SessionSummary, options: ResumeOptions) -> [String] {\n        switch session.source.baseKind {\n        case .codex:\n            let f = flags(from: options)\n            return f + [\"resume\", conversationId(for: session)]\n        case .claude:\n            return claudeResumeArguments(session: session, options: options)\n        case .gemini:\n            let config = geminiRuntimeConfiguration(options: options)\n            return [\"--resume\", conversationId(for: session)] + config.flags\n        }\n    }\n\n    func buildResumeCommandLines(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        #if APPSTORE\n        let cwd = self.workingDirectory(for: session, override: workingDirectory)\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        let exports = embeddedExportLines(for: session.source).joined(separator: \"; \")\n        // MAS sandbox: do not auto-execute external CLI inside the app. Only prepare directory and env.\n        // The user can copy or insert the real command via UI prompts.\n        let cliName = executableName(for: session.source.baseKind)\n        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.\\\"\"\n        return cd + \"\\n\" + exports + \"\\n\" + notice + \"\\n\"\n        #else\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteResumeShellCommand(\n                session: session,\n                options: options\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd = self.workingDirectory(for: session, override: workingDirectory)\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        var exportLines = embeddedExportLines(for: session.source)\n        if session.source.baseKind == .gemini {\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exportLines.append(contentsOf: envLines)\n        }\n        let exports = exportLines.joined(separator: \"; \")\n        let injectedPATH = CLIEnvironment.buildInjectedPATH()\n        // Use override executable when configured; otherwise fall back to PATH resolution.\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let invocation = buildResumeCLIInvocation(\n            session: session, executablePath: execPath, options: options, codexHome: codexHome)\n        let resume = \"PATH=\\(injectedPATH) \\(invocation)\"\n        return cd + \"\\n\" + exports + \"\\n\" + resume + \"\\n\"\n        #endif\n    }\n\n    // Embedded terminal: avoid PATH=... inline to keep command display clean.\n    func buildEmbeddedResumeCommandLines(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil,\n        includeCd: Bool = true\n    ) -> String {\n        #if APPSTORE\n        return buildResumeCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            workingDirectory: workingDirectory,\n            codexHome: codexHome\n        )\n        #else\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteResumeShellCommand(\n                session: session,\n                options: options\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        var exportLines = embeddedExportLines(for: session.source)\n        if session.source.baseKind == .gemini {\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exportLines.append(contentsOf: envLines)\n        }\n        let exports = exportLines.joined(separator: \"; \")\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let resume = buildResumeCLIInvocation(\n            session: session, executablePath: execPath, options: options, codexHome: codexHome)\n        var lines: [String] = []\n        if includeCd {\n            let cwd = self.workingDirectory(for: session, override: workingDirectory)\n            let cd = \"cd \" + shellEscapedPath(cwd)\n            lines.append(cd)\n        }\n        if !exports.isEmpty {\n            lines.append(exports)\n        }\n        lines.append(resume)\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n        #endif\n    }\n\n    func buildEmbeddedNewSessionCommandLines(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        #if APPSTORE\n        return buildNewSessionCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            codexHome: codexHome\n        )\n        #else\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteNewShellCommand(\n                session: session,\n                options: options,\n                initialPrompt: initialPrompt\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        var exportLines: [String] = []\n        if session.source.baseKind == .gemini {\n            exportLines = embeddedExportLines(for: session.source)\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exportLines.append(contentsOf: envLines)\n        }\n        let exports = exportLines.isEmpty ? nil : exportLines.joined(separator: \"; \")\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let invocation = buildNewSessionCLIInvocation(\n            session: session,\n            options: options,\n            initialPrompt: initialPrompt,\n            executablePath: execPath,\n            codexHome: codexHome\n        )\n        var lines = [cd]\n        if let exports { lines.append(exports) }\n        lines.append(invocation)\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n        #endif\n    }\n\n    func buildEmbeddedNewProjectCommandLines(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        let cdLine: String? = {\n            if let dir = project.directory,\n                !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n            {\n                return \"cd \" + shellEscapedPath(dir)\n            }\n            return nil\n        }()\n        let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL)\n        let invocation = buildNewProjectCLIInvocation(\n            project: project, options: options, executablePath: execPath, codexHome: codexHome)\n        if let cd = cdLine {\n            return cd + \"\\n\" + invocation + \"\\n\"\n        } else {\n            return invocation + \"\\n\"\n        }\n    }\n\n    func buildNewSessionCommandLines(\n        session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil\n    ) -> String {\n        #if APPSTORE\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        // MAS: do not execute external CLI in embedded terminal; only show a notice.\n        let notice = \"echo \\\"[CodMate] App Store sandbox cannot directly run \\(session.source.baseKind.cliExecutableName) CLI. Please execute the copied command in an external terminal.\\\"\"\n        return cd + \"\\n\" + notice + \"\\n\"\n        #else\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteNewShellCommand(\n                session: session,\n                options: options,\n                initialPrompt: nil\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        var exportLines: [String] = []\n        if session.source.baseKind == .gemini {\n            exportLines = embeddedExportLines(for: session.source)\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exportLines.append(contentsOf: envLines)\n        }\n        let exports = exportLines.isEmpty ? nil : exportLines.joined(separator: \"; \")\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let invocation = buildNewSessionCLIInvocation(\n            session: session, options: options, executablePath: execPath, codexHome: codexHome)\n        var lines = [cd]\n        if let exports { lines.append(exports) }\n        lines.append(invocation)\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n        #endif\n    }\n\n    func buildExternalNewSessionCommands(\n        session: SessionSummary, executableURL: URL, options: ResumeOptions, codexHome: String? = nil\n    ) -> String {\n        buildEmbeddedNewSessionCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            codexHome: codexHome\n        )\n    }\n\n    // Simplified two-line command for external terminals\n    func buildExternalResumeCommands(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        buildEmbeddedResumeCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            workingDirectory: workingDirectory,\n            codexHome: codexHome\n        )\n    }\n\n    // MARK: - Warp-optimized clipboard commands\n    //\n    // Warp appears to derive a new tab title from the first pasted \"command\" line.\n    // When our external clipboard text starts with `cd ...`, the tab title becomes `cd`.\n    // For Warp flows we prepend a harmless comment line and omit `cd` entirely because\n    // we already open Warp at the target directory via URL scheme.\n    private func warpTitleCommentLine(_ title: String?) -> String? {\n        guard var s = title else { return nil }\n        s = s.replacingOccurrences(of: \"\\r\", with: \" \")\n        s = s.replacingOccurrences(of: \"\\n\", with: \" \")\n        s = s.replacingOccurrences(of: \"\\t\", with: \" \")\n        s = s.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !s.isEmpty else { return nil }\n        if s.count > 80 { s = String(s.prefix(80)) }\n        let collapsed = s.split(whereSeparator: { $0.isWhitespace }).joined(separator: \"-\")\n        guard !collapsed.isEmpty else { return nil }\n        return \"#\" + collapsed\n    }\n\n    private func warpScope(from session: SessionSummary) -> String? {\n        if let title = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines),\n            !title.isEmpty\n        {\n            return title\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let dirName = URL(fileURLWithPath: cwd).lastPathComponent\n        if !dirName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            return dirName\n        }\n        return session.displayName\n    }\n\n    func buildWarpResumeCommands(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteResumeShellCommand(session: session, options: options)\n            let cmd = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext)\n            let lines = [warpTitleCommentLine(titleHint ?? session.effectiveTitle), cmd].compactMap { $0 }\n            return lines.joined(separator: \"\\n\") + \"\\n\"\n        }\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let resume = buildResumeCLIInvocation(\n            session: session,\n            executablePath: execPath,\n            options: options,\n            codexHome: codexHome\n        )\n        var lines: [String] = []\n        if let title = warpTitleCommentLine(titleHint ?? session.effectiveTitle) { lines.append(title) }\n        if session.source.baseKind == .gemini {\n            lines.append(contentsOf: embeddedExportLines(for: session.source))\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            lines.append(contentsOf: envLines)\n        }\n        lines.append(resume)\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n    }\n\n    func buildWarpNewSessionCommands(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteNewShellCommand(\n                session: session,\n                options: options,\n                initialPrompt: nil\n            )\n            let cmd = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext)\n            let extras = [host]\n            let base = titleHint ?? WarpTitleBuilder.newSessionLabel(\n                scope: warpScope(from: session),\n                task: nil,\n                extras: extras\n            )\n            let title = warpTitleCommentLine(base)\n            let lines = [title, cmd].compactMap { $0 }\n            return lines.joined(separator: \"\\n\") + \"\\n\"\n        }\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let base = titleHint ?? WarpTitleBuilder.newSessionLabel(\n            scope: warpScope(from: session),\n            task: nil\n        )\n        let newCommand = buildNewSessionCLIInvocation(\n            session: session, options: options, executablePath: execPath, codexHome: codexHome)\n        var lines: [String] = []\n        if let title = warpTitleCommentLine(base) { lines.append(title) }\n        if session.source.baseKind == .gemini {\n            lines.append(contentsOf: embeddedExportLines(for: session.source))\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            lines.append(contentsOf: envLines)\n        }\n        lines.append(newCommand)\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n    }\n\n    func buildWarpNewProjectCommands(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        let base = titleHint ?? WarpTitleBuilder.newSessionLabel(\n            scope: project.name,\n            task: nil\n        )\n        let title = warpTitleCommentLine(base)\n        let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL)\n        let cmd = buildNewProjectCLIInvocation(\n            project: project, options: options, executablePath: execPath, codexHome: codexHome)\n        let lines = [title, cmd].compactMap { $0 }\n        return lines.joined(separator: \"\\n\") + \"\\n\"\n    }\n\n    func copyResumeCommands(\n        session: SessionSummary, executableURL: URL, options: ResumeOptions,\n        simplifiedForExternal: Bool = true,\n        destinationApp: ExternalTerminalProfile? = nil,\n        titleHint: String? = nil,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil\n    ) {\n        let commands: String\n        if simplifiedForExternal, destinationApp?.usesWarpCommands == true {\n            commands = buildWarpResumeCommands(\n                session: session,\n                executableURL: executableURL,\n                options: options,\n                titleHint: titleHint,\n                codexHome: codexHome\n            )\n        } else {\n            commands =\n                simplifiedForExternal\n                ? buildExternalResumeCommands(\n                    session: session,\n                    executableURL: executableURL,\n                    options: options,\n                    workingDirectory: workingDirectory,\n                    codexHome: codexHome\n                )\n                : buildResumeCommandLines(\n                    session: session,\n                    executableURL: executableURL,\n                    options: options,\n                    workingDirectory: workingDirectory,\n                    codexHome: codexHome\n                )\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(commands, forType: .string)\n    }\n\n    func copyNewSessionCommands(\n        session: SessionSummary, executableURL: URL, options: ResumeOptions,\n        simplifiedForExternal: Bool = true,\n        destinationApp: ExternalTerminalProfile? = nil,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) {\n        let commands: String\n        if simplifiedForExternal, destinationApp?.usesWarpCommands == true {\n            commands = buildWarpNewSessionCommands(\n                session: session,\n                executableURL: executableURL,\n                options: options,\n                titleHint: titleHint,\n                codexHome: codexHome\n            )\n        } else {\n            commands =\n                simplifiedForExternal\n                ? buildExternalNewSessionCommands(\n                    session: session, executableURL: executableURL, options: options, codexHome: codexHome)\n                : buildNewSessionCommandLines(\n                    session: session, executableURL: executableURL, options: options, codexHome: codexHome)\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(commands, forType: .string)\n    }\n\n    // MARK: - Project-level new session helpers\n    private func buildNewProjectArguments(project: Project, options: ResumeOptions) -> [String] {\n        var args: [String] = []\n        // Embedded per-project profile config (preferred)\n        let pp = project.profile\n        let profileId = project.profileId?.trimmingCharacters(in: .whitespaces)\n        let provider = readTopLevelConfigString(\"model_provider\")?.trimmingCharacters(\n            in: .whitespacesAndNewlines)\n\n        // Flags only; avoid explicit --model for Codex new to keep behavior consistent\n        if let pp {\n            if pp.dangerouslyBypass == true {\n                args += [\"--dangerously-bypass-approvals-and-sandbox\"]\n            } else if pp.fullAuto == true {\n                args += [\"--full-auto\"]\n            } else {\n                if let s = pp.sandbox { args += [\"-s\", s.rawValue] }\n                if let a = pp.approval { args += [\"-a\", a.rawValue] }\n            }\n        } else {\n            // Fallback to explicit flags\n            args += flags(from: options)\n        }\n\n        // Always use -c to inject inline profile (zero-write approach)\n        if let profileId, !profileId.isEmpty {\n            // Resolve effective approval/sandbox for project-level new inline profile\n            var approvalRaw: String? = pp?.approval?.rawValue\n            var sandboxRaw: String? = pp?.sandbox?.rawValue\n            if sandboxRaw == nil {\n                if pp?.dangerouslyBypass == true {\n                    sandboxRaw = SandboxMode.dangerFullAccess.rawValue\n                } else if let opt = options.sandbox?.rawValue {\n                    sandboxRaw = opt\n                }\n            }\n            if approvalRaw == nil {\n                if let opt = options.approval?.rawValue {\n                    approvalRaw = opt\n                } else {\n                    approvalRaw = ApprovalPolicy.onRequest.rawValue\n                }\n            }\n            if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue }\n\n            let modelFromProject = pp?.model\n            let modelForInline = resolveInlineModel(provider: provider, candidate: modelFromProject)\n            if let inline = renderInlineProfileConfig(\n                key: profileId,\n                model: modelForInline,\n                modelProvider: provider,\n                approvalPolicy: approvalRaw,\n                sandboxMode: sandboxRaw\n            ) {\n                args += [\"--profile\", profileId, \"-c\", inline]\n            } else {\n                // profile id provided but nothing to inject; omit --profile to avoid referring to a non-existent profile\n            }\n        }\n        return args\n    }\n\n    func buildNewProjectCLIInvocation(\n        project: Project,\n        options: ResumeOptions,\n        executablePath: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        let exe = shellQuoteIfNeeded(executablePath ?? \"codex\")\n        let args = buildNewProjectArguments(project: project, options: options).map {\n            arg -> String in\n            if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                return shellEscapedPath(arg)\n            }\n            return arg\n        }\n        // Invoke `codex` directly without a \"new\" subcommand\n        let cmd = ([exe] + args).joined(separator: \" \")\n        return applyCodexHomePrefix(cmd, codexHome: codexHome, source: .codex)\n    }\n\n    func buildClaudeProjectCLIInvocation(\n        executablePath: String,\n        options: ResumeOptions,\n        model: String?\n    ) -> String {\n        var parts: [String] = [shellQuoteIfNeeded(executablePath)]\n        parts.append(contentsOf: claudeRuntimeArguments(options: options, fallbackModel: options.claudeFallbackModel)\n            .map { shellQuoteIfNeeded($0) })\n        if let m = model?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty {\n            parts.append(\"--model\")\n            parts.append(shellQuoteIfNeeded(m))\n        }\n        return parts.joined(separator: \" \")\n    }\n\n    func buildGeminiCLIInvocation(\n        executablePath: String,\n        options: ResumeOptions\n    ) -> String {\n        let config = geminiRuntimeConfiguration(options: options)\n        var parts: [String] = [shellQuoteIfNeeded(executablePath)]\n        parts.append(contentsOf: config.flags.map(shellQuoteIfNeeded))\n        let cmd = parts.joined(separator: \" \")\n        let envLines = geminiEnvironmentExportLines(environment: config.environment)\n        if envLines.isEmpty {\n            return cmd\n        }\n        return (envLines + [cmd]).joined(separator: \"\\n\")\n    }\n\n    func buildNewProjectCommandLines(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        let cdLine: String? = {\n            if let dir = project.directory,\n                !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n            {\n                return \"cd \" + shellEscapedPath(dir)\n            }\n            return nil\n        }()\n        // PATH injection: prepend project-specific paths if any\n        let prepend = project.profile?.pathPrepend ?? []\n        let prependString = prepend.filter {\n            !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        }.joined(separator: \":\")\n        let injectedPATH = CLIEnvironment.buildInjectedPATH(\n            additionalPaths: prependString.isEmpty ? [] : [prependString]\n        )\n        // Exports: locale defaults + project env\n        var exportLines: [String] = [\n            \"export LANG=zh_CN.UTF-8\",\n            \"export LC_ALL=zh_CN.UTF-8\",\n            \"export LC_CTYPE=zh_CN.UTF-8\",\n            \"export TERM=xterm-256color\",\n            \"export CODEX_DISABLE_COLOR_QUERY=1\",\n        ]\n        if let env = project.profile?.env {\n            for (k, v) in env {\n                let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !key.isEmpty else { continue }\n                exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n            }\n        }\n        let exports = exportLines.joined(separator: \"; \")\n        let execPath = resolvedExecutablePath(for: .codex, executableURL: executableURL)\n        let invocation = buildNewProjectCLIInvocation(\n            project: project, options: options, executablePath: execPath, codexHome: codexHome)\n        let command = \"PATH=\\(injectedPATH) \\(invocation)\"\n        if let cd = cdLine {\n            return cd + \"\\n\" + exports + \"\\n\" + command + \"\\n\"\n        } else {\n            return exports + \"\\n\" + command + \"\\n\"\n        }\n    }\n\n    func buildExternalNewProjectCommands(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        buildEmbeddedNewProjectCommandLines(\n            project: project,\n            executableURL: executableURL,\n            options: options,\n            codexHome: codexHome\n        )\n    }\n\n    func copyNewProjectCommands(\n        project: Project, executableURL: URL, options: ResumeOptions,\n        simplifiedForExternal: Bool = true,\n        destinationApp: ExternalTerminalProfile? = nil,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) {\n        let commands: String\n        if simplifiedForExternal, destinationApp?.usesWarpCommands == true {\n            commands = buildWarpNewProjectCommands(\n                project: project,\n                executableURL: executableURL,\n                options: options,\n                titleHint: titleHint,\n                codexHome: codexHome\n            )\n        } else {\n            commands =\n                simplifiedForExternal\n                ? buildExternalNewProjectCommands(\n                    project: project, executableURL: executableURL, options: options, codexHome: codexHome)\n                : buildNewProjectCommandLines(\n                    project: project, executableURL: executableURL, options: options, codexHome: codexHome)\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(commands, forType: .string)\n    }\n\n    @discardableResult\n    func openNewProject(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> Bool {\n        let scriptText = {\n            let lines = buildEmbeddedNewProjectCommandLines(\n                project: project, executableURL: executableURL, options: options, codexHome: codexHome\n            )\n            .replacingOccurrences(of: \"\\n\", with: \"; \")\n            return \"\"\"\n                tell application \"Terminal\"\n                  activate\n                  do script \"\\(lines)\"\n                end tell\n                \"\"\"\n        }()\n\n        if let script = NSAppleScript(source: scriptText) {\n            var errorDict: NSDictionary?\n            script.executeAndReturnError(&errorDict)\n            return errorDict == nil\n        }\n        return false\n    }\n\n    // MARK: - Detail New using Project Profile (cd = session.cwd)\n    private func buildNewSessionArguments(\n        using project: Project, fallbackModel: String?, options: ResumeOptions\n    ) -> [String] {\n        var args: [String] = []\n        let pid = project.profileId?.trimmingCharacters(in: .whitespaces)\n        let provider = readTopLevelConfigString(\"model_provider\")?.trimmingCharacters(\n            in: .whitespacesAndNewlines)\n\n        // Flags precedence: danger -> full-auto -> explicit -s/-a when present in project profile\n        if project.profile?.dangerouslyBypass == true {\n            args += [\"--dangerously-bypass-approvals-and-sandbox\"]\n        } else if project.profile?.fullAuto == true {\n            args += [\"--full-auto\"]\n        } else {\n            if let s = project.profile?.sandbox { args += [\"-s\", s.rawValue] }\n            if let a = project.profile?.approval { args += [\"-a\", a.rawValue] }\n        }\n\n        // Always use -c to inject inline profile (zero-write approach)\n        if let pid, !pid.isEmpty {\n            // Do not append explicit --model for Codex new; rely on project profile (persisted or inline) or global config\n            let modelFromProject = project.profile?.model\n\n            // Effective policies for inline profile injection (New using project):\n            // - approval: prefer explicit; otherwise prefer options; else default to on-request\n            // - sandbox: prefer explicit; otherwise Danger Bypass => danger-full-access; otherwise options; else default to workspace-write\n            var approvalRaw: String? = project.profile?.approval?.rawValue\n            var sandboxRaw: String? = project.profile?.sandbox?.rawValue\n            if sandboxRaw == nil {\n                if project.profile?.dangerouslyBypass == true {\n                    sandboxRaw = SandboxMode.dangerFullAccess.rawValue\n                } else if let opt = options.sandbox?.rawValue {\n                    sandboxRaw = opt\n                }\n            }\n            if approvalRaw == nil {\n                if let opt = options.approval?.rawValue {\n                    approvalRaw = opt\n                } else {\n                    approvalRaw = ApprovalPolicy.onRequest.rawValue\n                }\n            }\n            if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue }\n\n            let preferredModel = modelFromProject ?? fallbackModel\n            let modelForInline = resolveInlineModel(provider: provider, candidate: preferredModel)\n            if let inline = renderInlineProfileConfig(\n                key: pid,\n                model: modelForInline,\n                modelProvider: provider,\n                approvalPolicy: approvalRaw,\n                sandboxMode: sandboxRaw\n            ) {\n                // Zero-write: inject the inline profile and select it\n                args += [\"--profile\", pid, \"-c\", inline]\n            }\n        }\n        return args\n    }\n\n    func buildNewSessionUsingProjectProfileCLIInvocation(\n        session: SessionSummary,\n        project: Project,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        executablePath: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        // Launch using project profile; choose executable based on session source.\n        let exe = shellQuoteIfNeeded(executablePath ?? executableName(for: session.source.baseKind))\n        var parts: [String] = [exe]\n\n        // For Claude, only include model if specified; profile settings don't apply.\n        if session.source.baseKind == .claude {\n            if let model = session.model, !model.trimmingCharacters(in: .whitespaces).isEmpty {\n                parts.append(\"--model\")\n                parts.append(shellQuoteIfNeeded(model))\n            }\n            if let prompt = initialPrompt, !prompt.isEmpty {\n                parts.append(shellSingleQuoted(prompt))\n            }\n            return parts.joined(separator: \" \")\n        }\n\n        if session.source.baseKind == .gemini {\n            if let prompt = initialPrompt, !prompt.isEmpty {\n                parts.append(shellSingleQuoted(prompt))\n            }\n            return parts.joined(separator: \" \")\n        }\n        // For Codex, use full project profile arguments\n        let args = buildNewSessionArguments(\n            using: project, fallbackModel: effectiveCodexModel(for: session), options: options\n        ).map { arg -> String in\n            if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                return shellEscapedPath(arg)\n            }\n            return arg\n        }\n        parts.append(contentsOf: args)\n        if let prompt = initialPrompt, !prompt.isEmpty {\n            parts.append(shellSingleQuoted(prompt))\n        }\n        let cmd = parts.joined(separator: \" \")\n        return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind)\n    }\n\n    func buildNewSessionUsingProjectProfileCommandLines(\n        session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote, let host = session.remoteHost {\n            let invocation = buildNewSessionUsingProjectProfileCLIInvocation(\n                session: session,\n                project: project,\n                options: options,\n                initialPrompt: initialPrompt,\n                codexHome: codexHome\n            )\n            var exportLines: [String] = [\n                \"export LANG=zh_CN.UTF-8\",\n                \"export LC_ALL=zh_CN.UTF-8\",\n                \"export LC_CTYPE=zh_CN.UTF-8\",\n                \"export TERM=xterm-256color\",\n            ]\n            if let env = project.profile?.env {\n                for (k, v) in env {\n                    let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                    guard !key.isEmpty else { continue }\n                    exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n                }\n            }\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteShellCommand(\n                session: session,\n                exports: exportLines,\n                invocation: invocation\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let invocation = buildNewSessionUsingProjectProfileCLIInvocation(\n            session: session,\n            project: project,\n            options: options,\n            initialPrompt: initialPrompt,\n            executablePath: execPath,\n            codexHome: codexHome\n        )\n        // Local project-profile New: only emit `cd` + bare CLI invocation.\n        return cd + \"\\n\" + invocation + \"\\n\"\n    }\n\n    func buildExternalNewSessionUsingProjectProfileCommands(\n        session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            var exportLines: [String] = [\n                \"export LANG=zh_CN.UTF-8\",\n                \"export LC_ALL=zh_CN.UTF-8\",\n                \"export LC_CTYPE=zh_CN.UTF-8\",\n                \"export TERM=xterm-256color\",\n            ]\n            if let env = project.profile?.env {\n                for (k, v) in env {\n                    let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                    guard !key.isEmpty else { continue }\n                    exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n                }\n            }\n            let invocation = buildNewSessionUsingProjectProfileCLIInvocation(\n                session: session,\n                project: project,\n                options: options,\n                initialPrompt: initialPrompt,\n                codexHome: codexHome\n            )\n            let remote = buildRemoteShellCommand(\n                session: session,\n                exports: exportLines,\n                invocation: invocation\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        let cmd = buildNewSessionUsingProjectProfileCLIInvocation(\n            session: session,\n            project: project,\n            options: options,\n            initialPrompt: initialPrompt,\n            executablePath: resolvedExecutablePath(\n                for: session.source.baseKind,\n                executableURL: executableURL\n            ),\n            codexHome: codexHome\n        )\n        return cd + \"\\n\" + cmd + \"\\n\"\n    }\n\n    func copyNewSessionUsingProjectProfileCommands(\n        session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions,\n        simplifiedForExternal: Bool = true,\n        destinationApp: ExternalTerminalProfile? = nil,\n        initialPrompt: String? = nil,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) {\n        let commands: String\n        if simplifiedForExternal, destinationApp?.usesWarpCommands == true {\n            let invocation: String\n            if session.isRemote {\n                invocation = buildNewSessionUsingProjectProfileCLIInvocation(\n                    session: session,\n                    project: project,\n                    options: options,\n                    initialPrompt: initialPrompt,\n                    codexHome: codexHome\n                )\n            } else {\n                let execPath = resolvedExecutablePath(\n                    for: session.source.baseKind,\n                    executableURL: executableURL\n                )\n                invocation = buildNewSessionUsingProjectProfileCLIInvocation(\n                    session: session,\n                    project: project,\n                    options: options,\n                    initialPrompt: initialPrompt,\n                    executablePath: execPath,\n                    codexHome: codexHome\n                )\n            }\n            let extraHost = session.isRemote ? session.remoteHost : nil\n            let base = titleHint ?? WarpTitleBuilder.newSessionLabel(\n                scope: project.name,\n                task: nil,\n                extras: extraHost.flatMap { [$0] } ?? []\n            )\n            let title = warpTitleCommentLine(base)\n            if session.isRemote, let host = session.remoteHost {\n                let sshContext = resolvedSSHContext(for: host)\n                var exportLines: [String] = [\n                    \"export LANG=zh_CN.UTF-8\",\n                    \"export LC_ALL=zh_CN.UTF-8\",\n                    \"export LC_CTYPE=zh_CN.UTF-8\",\n                    \"export TERM=xterm-256color\",\n                ]\n                if let env = project.profile?.env {\n                    for (k, v) in env {\n                        let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                        guard !key.isEmpty else { continue }\n                        exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n                    }\n                }\n                let remote = buildRemoteShellCommand(\n                    session: session,\n                    exports: exportLines,\n                    invocation: invocation\n                )\n                let ssh = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext)\n                commands = [title, ssh].compactMap { $0 }.joined(separator: \"\\n\") + \"\\n\"\n            } else {\n                commands = [title, invocation].compactMap { $0 }.joined(separator: \"\\n\") + \"\\n\"\n            }\n        } else {\n            commands =\n                simplifiedForExternal\n                ? buildExternalNewSessionUsingProjectProfileCommands(\n                    session: session, project: project, executableURL: executableURL, options: options,\n                    initialPrompt: initialPrompt, codexHome: codexHome)\n                : buildNewSessionUsingProjectProfileCommandLines(\n                    session: session, project: project, executableURL: executableURL, options: options,\n                    initialPrompt: initialPrompt, codexHome: codexHome)\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(commands, forType: .string)\n    }\n\n    // MARK: - Resume (detail) respecting Project Profile\n    private func buildResumeArguments(\n        using project: Project, fallbackModel: String?, options: ResumeOptions\n    ) -> [String] {\n        var args: [String] = []\n        let pid = project.profileId?.trimmingCharacters(in: .whitespaces)\n        let provider = readTopLevelConfigString(\"model_provider\")?.trimmingCharacters(\n            in: .whitespacesAndNewlines)\n\n        // Always use -c to inject inline profile (zero-write approach)\n        // Only select profile; do not pass flags to preserve original resume semantics\n        if let pid, !pid.isEmpty {\n            // Compute effective approval/sandbox for resume inline profile\n            // approval: prefer explicit; else options; else default on-request\n            // sandbox: prefer explicit; else Danger Bypass => danger-full-access; else options; else default workspace-write\n            var approvalRaw: String? = project.profile?.approval?.rawValue\n            var sandboxRaw: String? = project.profile?.sandbox?.rawValue\n            if sandboxRaw == nil {\n                if project.profile?.dangerouslyBypass == true {\n                    sandboxRaw = SandboxMode.dangerFullAccess.rawValue\n                } else if let opt = options.sandbox?.rawValue {\n                    sandboxRaw = opt\n                }\n            }\n            if approvalRaw == nil {\n                if let opt = options.approval?.rawValue {\n                    approvalRaw = opt\n                } else {\n                    approvalRaw = ApprovalPolicy.onRequest.rawValue\n                }\n            }\n            if sandboxRaw == nil { sandboxRaw = SandboxMode.workspaceWrite.rawValue }\n\n            let preferredModel = project.profile?.model ?? fallbackModel\n            let modelForInline = resolveInlineModel(provider: provider, candidate: preferredModel)\n            if let inline = renderInlineProfileConfig(\n                key: pid,\n                model: modelForInline,\n                modelProvider: provider,\n                approvalPolicy: approvalRaw,\n                sandboxMode: sandboxRaw\n            ) {\n                // Zero-write: inject the inline profile and select it\n                args += [\"--profile\", pid, \"-c\", inline]\n            }\n        }\n        return args\n    }\n\n    func buildResumeUsingProjectProfileCLIInvocation(\n        session: SessionSummary, project: Project, options: ResumeOptions, codexHome: String? = nil\n    ) -> String {\n        // Choose executable based on session source; select profile (no flags for Claude).\n        let exe = executableName(for: session.source.baseKind)\n        var parts: [String] = [exe]\n\n        // For Claude, profiles don't apply; use simple resume command.\n        if session.source.baseKind == .claude {\n            parts.append(\"--resume\")\n            parts.append(session.id)\n            return parts.joined(separator: \" \")\n        }\n\n        // For Codex, place flags + profile before subcommand: codex <flags> --profile <pid> resume <id>\n        let globalFlags = flags(from: options).map { arg -> String in\n            arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) ? shellEscapedPath(arg) : arg\n        }\n        let args = buildResumeArguments(\n            using: project, fallbackModel: effectiveCodexModel(for: session), options: options\n        ).map { arg -> String in\n            if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                return shellEscapedPath(arg)\n            }\n            return arg\n        }\n        parts.append(contentsOf: globalFlags + args)\n        parts.append(\"resume\")\n        parts.append(conversationId(for: session))\n        let cmd = parts.joined(separator: \" \")\n        return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind)\n    }\n\n    func buildResumeUsingProjectProfileCLIInvocation(\n        session: SessionSummary,\n        project: Project,\n        executablePath: String,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        let exe = shellQuoteIfNeeded(executablePath)\n        var parts: [String] = [exe]\n\n        // For Claude, profiles don't apply; use simple resume command.\n        if session.source.baseKind == .claude {\n            parts.append(\"--resume\")\n            parts.append(session.id)\n            return parts.joined(separator: \" \")\n        }\n\n        // For Codex, place flags + profile before subcommand: codex <flags> --profile <pid> resume <id>\n        let globalFlags = flags(from: options).map { arg -> String in\n            arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) ? shellEscapedPath(arg) : arg\n        }\n        let args = buildResumeArguments(\n            using: project, fallbackModel: effectiveCodexModel(for: session), options: options\n        ).map { arg -> String in\n            if arg.contains(where: { $0.isWhitespace || $0 == \"'\" }) {\n                return shellEscapedPath(arg)\n            }\n            return arg\n        }\n        parts.append(contentsOf: globalFlags + args)\n        parts.append(\"resume\")\n        parts.append(conversationId(for: session))\n        let cmd = parts.joined(separator: \" \")\n        return applyCodexHomePrefix(cmd, codexHome: codexHome, source: session.source.baseKind)\n    }\n\n    func buildResumeUsingProjectProfileCommandLines(\n        session: SessionSummary,\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        var exportLines: [String] = [\n            \"export LANG=zh_CN.UTF-8\",\n            \"export LC_ALL=zh_CN.UTF-8\",\n            \"export LC_CTYPE=zh_CN.UTF-8\",\n            \"export TERM=xterm-256color\",\n        ]\n        if let env = project.profile?.env {\n            for (k, v) in env {\n                let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                guard !key.isEmpty else { continue }\n                exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n            }\n        }\n        if session.isRemote, let host = session.remoteHost {\n            let invocation = buildResumeUsingProjectProfileCLIInvocation(\n                session: session, project: project, options: options, codexHome: codexHome)\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteShellCommand(\n                session: session,\n                exports: exportLines,\n                invocation: invocation\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        let exports = exportLines.joined(separator: \"; \")\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let command = buildResumeUsingProjectProfileCLIInvocation(\n            session: session,\n            project: project,\n            executablePath: execPath,\n            options: options,\n            codexHome: codexHome\n        )\n        return cd + \"\\n\" + exports + \"\\n\" + command + \"\\n\"\n    }\n\n    func buildExternalResumeUsingProjectProfileCommands(\n        session: SessionSummary,\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            var exportLines: [String] = [\n                \"export LANG=zh_CN.UTF-8\",\n                \"export LC_ALL=zh_CN.UTF-8\",\n                \"export LC_CTYPE=zh_CN.UTF-8\",\n                \"export TERM=xterm-256color\",\n            ]\n            if let env = project.profile?.env {\n                for (k, v) in env {\n                    let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                    guard !key.isEmpty else { continue }\n                    exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n                }\n            }\n            let invocation = buildResumeUsingProjectProfileCLIInvocation(\n                session: session, project: project, options: options, codexHome: codexHome)\n            let remote = buildRemoteShellCommand(\n                session: session,\n                exports: exportLines,\n                invocation: invocation\n            )\n            return sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            ) + \"\\n\"\n        }\n        let cwd =\n            FileManager.default.fileExists(atPath: session.cwd)\n            ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        let cd = \"cd \" + shellEscapedPath(cwd)\n        let execPath = resolvedExecutablePath(\n            for: session.source.baseKind,\n            executableURL: executableURL\n        )\n        let cmd = buildResumeUsingProjectProfileCLIInvocation(\n            session: session,\n            project: project,\n            executablePath: execPath,\n            options: options,\n            codexHome: codexHome\n        )\n        return cd + \"\\n\" + cmd + \"\\n\"\n    }\n\n    func copyResumeUsingProjectProfileCommands(\n        session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions,\n        simplifiedForExternal: Bool = true,\n        destinationApp: ExternalTerminalProfile? = nil,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) {\n        let commands: String\n        if simplifiedForExternal, destinationApp?.usesWarpCommands == true {\n            let invocation: String\n            if session.isRemote {\n                invocation = buildResumeUsingProjectProfileCLIInvocation(\n                    session: session, project: project, options: options, codexHome: codexHome)\n            } else {\n                let execPath = resolvedExecutablePath(\n                    for: session.source.baseKind,\n                    executableURL: executableURL\n                )\n                invocation = buildResumeUsingProjectProfileCLIInvocation(\n                    session: session,\n                    project: project,\n                    executablePath: execPath,\n                    options: options,\n                    codexHome: codexHome\n                )\n            }\n            let title = warpTitleCommentLine(titleHint ?? session.effectiveTitle)\n            if session.isRemote, let host = session.remoteHost {\n                let sshContext = resolvedSSHContext(for: host)\n                var exportLines: [String] = [\n                    \"export LANG=zh_CN.UTF-8\",\n                    \"export LC_ALL=zh_CN.UTF-8\",\n                    \"export LC_CTYPE=zh_CN.UTF-8\",\n                    \"export TERM=xterm-256color\",\n                ]\n                if let env = project.profile?.env {\n                    for (k, v) in env {\n                        let key = k.trimmingCharacters(in: .whitespacesAndNewlines)\n                        guard !key.isEmpty else { continue }\n                        exportLines.append(\"export \\(key)=\\(shellSingleQuoted(v))\")\n                    }\n                }\n                let remote = buildRemoteShellCommand(\n                    session: session,\n                    exports: exportLines,\n                    invocation: invocation\n                )\n                let ssh = sshInvocation(host: host, remoteCommand: remote, resolvedArguments: sshContext)\n                commands = [title, ssh].compactMap { $0 }.joined(separator: \"\\n\") + \"\\n\"\n            } else {\n                commands = [title, invocation].compactMap { $0 }.joined(separator: \"\\n\") + \"\\n\"\n            }\n        } else {\n            commands =\n                simplifiedForExternal\n                ? buildExternalResumeUsingProjectProfileCommands(\n                    session: session,\n                    project: project,\n                    executableURL: executableURL,\n                    options: options,\n                    codexHome: codexHome\n                )\n                : buildResumeUsingProjectProfileCommandLines(\n                    session: session,\n                    project: project,\n                    executableURL: executableURL,\n                    options: options,\n                    codexHome: codexHome\n                )\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(commands, forType: .string)\n    }\n\n    @discardableResult\n    func openNewSessionUsingProjectProfile(\n        session: SessionSummary, project: Project, executableURL: URL, options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> Bool {\n        let scriptText = {\n            let lines = buildNewSessionUsingProjectProfileCommandLines(\n                session: session, project: project, executableURL: executableURL, options: options,\n                initialPrompt: initialPrompt, codexHome: codexHome\n            )\n            .replacingOccurrences(of: \"\\n\", with: \"; \")\n            return \"\"\"\n                tell application \"Terminal\"\n                  activate\n                  do script \"\\(lines)\"\n                end tell\n                \"\"\"\n        }()\n\n        if let script = NSAppleScript(source: scriptText) {\n            var errorDict: NSDictionary?\n            script.executeAndReturnError(&errorDict)\n            return errorDict == nil\n        }\n        return false\n    }\n\n    // MARK: - Helpers\n    private func shellSingleQuoted(_ v: String) -> String {\n        \"'\" + v.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\"\n    }\n\n    private func codexHomePrefix(_ codexHome: String?) -> String? {\n        guard let codexHome, !codexHome.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {\n            return nil\n        }\n        // Ensure sessions symlink exists when generating command strings\n        // (e.g., for copy-to-clipboard or external terminal execution)\n        ensureSessionsSymlink(at: codexHome)\n        return \"CODEX_HOME=\\(shellEscapedPath(codexHome))\"\n    }\n\n    private func applyCodexHomePrefix(\n        _ command: String,\n        codexHome: String?,\n        source: SessionSource.Kind\n    ) -> String {\n        guard source == .codex, let prefix = codexHomePrefix(codexHome) else { return command }\n        return \"\\(prefix) \\(command)\"\n    }\n\n    func copyRealResumeInvocation(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) {\n        let command: String\n        if session.isRemote, let host = session.remoteHost {\n            let sshContext = resolvedSSHContext(for: host)\n            let remote = buildRemoteResumeShellCommand(\n                session: session,\n                options: options\n            )\n            command = sshInvocation(\n                host: host,\n                remoteCommand: remote,\n                resolvedArguments: sshContext\n            )\n        } else {\n            let execName = resolvedExecutablePath(\n                for: session.source.baseKind,\n                executableURL: executableURL\n            )\n            command = buildResumeCLIInvocation(\n                session: session,\n                executablePath: execName,\n                options: options,\n                codexHome: codexHome\n            )\n        }\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(command + \"\\n\", forType: .string)\n    }\n}\n"
  },
  {
    "path": "services/SessionActions+Config.swift",
    "content": "import Foundation\n\nextension SessionActions {\n    func normalizedCodexModelName(_ raw: String?) -> String? {\n        guard let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {\n            return nil\n        }\n        let lower = text.lowercased()\n        switch lower {\n        case \"gpt-5\", \"gpt5\":\n            return \"gpt-5.2\"\n        case \"gpt-5-codex\", \"gpt5-codex\":\n            return \"gpt-5.2-codex\"\n        case \"gpt-5-codex-max\", \"gpt5-codex-max\":\n            return \"gpt-5.1-codex-max\"\n        case \"gpt-5-codex-mini\", \"gpt5-codex-mini\":\n            return \"gpt-5.1-codex-mini\"\n        default:\n            return text\n        }\n    }\n\n    func listPersistedProfiles() -> Set<String> {\n        let configURL = FileManager.default.homeDirectoryForCurrentUser\n            .appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"config.toml\", isDirectory: false)\n        guard let data = try? Data(contentsOf: configURL),\n            let raw = String(data: data, encoding: .utf8)\n        else {\n            return []\n        }\n        var out: Set<String> = []\n        for line in raw.split(separator: \"\\n\", omittingEmptySubsequences: false) {\n            let t = line.trimmingCharacters(in: CharacterSet.whitespaces)\n            if t.hasPrefix(\"[profiles.\") && t.hasSuffix(\"]\") {\n                let start = \"[profiles.\".count\n                let endIndex = t.index(before: t.endIndex)\n                let id = String(t[t.index(t.startIndex, offsetBy: start)..<endIndex])\n                let trimmed = id.trimmingCharacters(in: CharacterSet.whitespaces)\n                if !trimmed.isEmpty { out.insert(trimmed) }\n            }\n        }\n        return out\n    }\n\n    func persistedProfileExists(_ id: String?) -> Bool {\n        guard let id, !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {\n            return false\n        }\n        return listPersistedProfiles().contains(id)\n    }\n\n    func readTopLevelConfigString(_ key: String) -> String? {\n        let url = FileManager.default.homeDirectoryForCurrentUser\n            .appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"config.toml\", isDirectory: false)\n        guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n        for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init) {\n            let t = raw.trimmingCharacters(in: CharacterSet.whitespaces)\n            guard t.hasPrefix(key + \" \") || t.hasPrefix(key + \"=\") else { continue }\n            guard let eq = t.firstIndex(of: \"=\") else { continue }\n            var value = String(t[t.index(after: eq)...]).trimmingCharacters(in: CharacterSet.whitespaces)\n            if value.hasPrefix(\"\\\"\") && value.hasSuffix(\"\\\"\") {\n                value.removeFirst()\n                value.removeLast()\n            }\n            return value\n        }\n        return nil\n    }\n\n    func effectiveCodexModel(for session: SessionSummary) -> String? {\n        if let configured = normalizedCodexModelName(readTopLevelConfigString(\"model\")) {\n            return configured\n        }\n        if session.source.baseKind == .codex {\n            if let normalized = normalizedCodexModelName(session.model) {\n                return normalized\n            }\n        }\n        return nil\n    }\n\n    func renderInlineProfileConfig(\n        key id: String,\n        model: String?,\n        modelProvider: String?,\n        approvalPolicy: String?,\n        sandboxMode: String?\n    ) -> String? {\n        var pairs: [String] = []\n        if let normalized = normalizedCodexModelName(model) {\n            let val = normalized.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n            pairs.append(\"model=\\\"\\(val)\\\"\")\n        }\n        if let approval = approvalPolicy,\n            !approval.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            let val = approval.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n            pairs.append(\"approval_policy=\\\"\\(val)\\\"\")\n        }\n        if let sandbox = sandboxMode,\n            !sandbox.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            let val = sandbox.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n            pairs.append(\"sandbox_mode=\\\"\\(val)\\\"\")\n        }\n        if let provider = modelProvider,\n            !provider.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            let val = provider.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n            pairs.append(\"model_provider=\\\"\\(val)\\\"\")\n        }\n        guard !pairs.isEmpty else { return nil }\n        return \"profiles.\\(id)={ \\(pairs.joined(separator: \", \")) }\"\n    }\n\n    func isLikelyBuiltinCodexModel(_ raw: String?) -> Bool {\n        guard let text = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {\n            return false\n        }\n        let lower = text.lowercased()\n        return lower.hasPrefix(\"gpt-\") || lower.hasPrefix(\"gpt5\")\n    }\n\n    // TODO: Enhance model identification strategy. Currently using simple string prefix matching\n    // to filter incompatible models when switching between providers (e.g. Auto vs Proxy).\n    func resolveInlineModel(provider: String?, candidate: String?) -> String? {\n        guard let model = candidate, !model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }\n        \n        let p = provider?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n        \n        if p.isEmpty {\n            // Auto/Built-in provider: only allow known builtin models (gpt-*), discard incompatible ones (e.g. gemini/claude)\n            return isLikelyBuiltinCodexModel(model) ? model : nil\n        } else if p == \"codmate-proxy\" {\n            // Proxy provider: suppress builtin models to avoid conflicts, pass through others\n            return isLikelyBuiltinCodexModel(model) ? nil : model\n        }\n        \n        return model\n    }\n}\n"
  },
  {
    "path": "services/SessionActions+FileActions.swift",
    "content": "import AppKit\nimport Foundation\n\nextension SessionActions {\n    func revealInFinder(session: SessionSummary) {\n        NSWorkspace.shared.activateFileViewerSelecting([session.fileURL])\n    }\n\n    func delete(summaries: [SessionSummary]) throws {\n        for summary in summaries {\n            var resulting: NSURL?\n            do {\n                try fileManager.trashItem(at: summary.fileURL, resultingItemURL: &resulting)\n            } catch {\n                throw SessionActionError.deletionFailed(summary.fileURL)\n            }\n\n            // For Claude Code sessions, also delete associated agent-*.jsonl files\n            if summary.source.baseKind == .claude {\n                deleteAssociatedAgentFiles(for: summary)\n            }\n        }\n    }\n\n    /// Delete agent-*.jsonl files associated with a Claude Code session.\n    /// Agent files are sidechain warmup files that share the same sessionId.\n    private func deleteAssociatedAgentFiles(for summary: SessionSummary) {\n        let directory = summary.fileURL.deletingLastPathComponent()\n        guard let enumerator = fileManager.enumerator(\n            at: directory,\n            includingPropertiesForKeys: [URLResourceKey.isRegularFileKey],\n            options: [\n                FileManager.DirectoryEnumerationOptions.skipsHiddenFiles,\n                FileManager.DirectoryEnumerationOptions.skipsSubdirectoryDescendants,\n            ]\n        ) else { return }\n\n        for case let url as URL in enumerator {\n            let filename = url.deletingPathExtension().lastPathComponent\n            guard filename.hasPrefix(\"agent-\"),\n                  url.pathExtension.lowercased() == \"jsonl\" else { continue }\n\n            // Check if this agent file belongs to the session being deleted\n            if agentFileMatchesSession(agentURL: url, sessionId: summary.id) {\n                var resulting: NSURL?\n                try? fileManager.trashItem(at: url, resultingItemURL: &resulting)\n            }\n        }\n    }\n\n    /// Check if an agent file belongs to a specific session by reading its sessionId.\n    private func agentFileMatchesSession(agentURL: URL, sessionId: String) -> Bool {\n        guard let data = try? Data(contentsOf: agentURL, options: [.mappedIfSafe]),\n              !data.isEmpty else { return false }\n\n        // Read first line to extract sessionId\n        let lines = data.split(separator: 0x0A, maxSplits: 1, omittingEmptySubsequences: true)\n        guard let firstLine = lines.first else { return false }\n\n        // Simple JSON check for sessionId (avoid full JSON parsing for performance)\n        let lineStr = String(decoding: firstLine, as: UTF8.self)\n        return lineStr.contains(\"\\\"sessionId\\\":\\\"\\(sessionId)\\\"\")\n    }\n}\n"
  },
  {
    "path": "services/SessionActions+Terminal.swift",
    "content": "import AppKit\nimport Foundation\n\nextension SessionActions {\n    func openInTerminal(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil\n    ) -> Bool\n    {\n        #if APPSTORE\n        // App Store build: avoid Apple Events. Copy command and open Terminal at directory.\n        copyResumeCommands(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            workingDirectory: workingDirectory,\n            codexHome: codexHome\n        )\n        let cwd = self.workingDirectory(for: session, override: workingDirectory)\n        _ = openAppleTerminal(at: cwd)\n        return true\n        #else\n        let scriptText = {\n            let lines = buildEmbeddedResumeCommandLines(\n                session: session,\n                executableURL: executableURL,\n                options: options,\n                workingDirectory: workingDirectory,\n                codexHome: codexHome\n            )\n            .replacingOccurrences(of: \"\\n\", with: \"; \")\n            return \"\"\"\n                tell application \"Terminal\"\n                  activate\n                  do script \"\\(lines)\"\n                end tell\n                \"\"\"\n        }()\n\n        if let script = NSAppleScript(source: scriptText) {\n            var errorDict: NSDictionary?\n            script.executeAndReturnError(&errorDict)\n            return errorDict == nil\n        }\n        return false\n        #endif\n    }\n\n    @discardableResult\n    func openNewSession(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> Bool\n    {\n        #if APPSTORE\n        // App Store build: copy command and open Terminal without Apple Events\n        copyNewSessionCommands(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            codexHome: codexHome\n        )\n        let cwd = FileManager.default.fileExists(atPath: session.cwd) ? session.cwd : session.fileURL.deletingLastPathComponent().path\n        _ = openAppleTerminal(at: cwd)\n        return true\n        #else\n        let scriptText = {\n            let lines = buildEmbeddedNewSessionCommandLines(\n                session: session, executableURL: executableURL, options: options, codexHome: codexHome\n            )\n            .replacingOccurrences(of: \"\\n\", with: \"; \")\n            return \"\"\"\n                tell application \"Terminal\"\n                  activate\n                  do script \"\\(lines)\"\n                end tell\n                \"\"\"\n        }()\n\n        if let script = NSAppleScript(source: scriptText) {\n            var errorDict: NSDictionary?\n            script.executeAndReturnError(&errorDict)\n            return errorDict == nil\n        }\n        return false\n        #endif\n    }\n\n    // Open a terminal app without auto-executing; user can paste clipboard\n    func openTerminalApp(_ profile: ExternalTerminalProfile) {\n        guard let bundleID = profile.resolvedBundleIdentifier else { return }\n\n        if let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) {\n            let config = NSWorkspace.OpenConfiguration()\n            config.activates = true\n            NSWorkspace.shared.openApplication(\n                at: appURL, configuration: config, completionHandler: nil)\n        }\n    }\n\n    // Optional: open using URL schemes (iTerm2 / Warp) when available\n    func openTerminalViaScheme(_ profile: ExternalTerminalProfile, directory: String?, command: String? = nil) {\n        terminalLaunchQueue.async {\n            self.openTerminalViaSchemeSync(profile: profile, directory: directory, command: command)\n        }\n    }\n\n    private func openTerminalViaSchemeSync(profile: ExternalTerminalProfile, directory: String?, command: String?) {\n        let dir = directory ?? NSHomeDirectory()\n        switch profile.id {\n        case \"iterm2\":\n            if openITermViaAppleScript(directory: dir, command: command) {\n                return\n            }\n            var comps = URLComponents()\n            comps.scheme = \"iterm2\"\n            comps.path = \"/command\"\n            comps.queryItems = [URLQueryItem(name: \"d\", value: dir)]\n            if let command {\n                comps.queryItems?.append(URLQueryItem(name: \"c\", value: command))\n            }\n            if let url = comps.url {\n                NSWorkspace.shared.open(url)\n            } else {\n                openTerminalApp(profile)\n            }\n        case \"warp\":\n            var comps = URLComponents()\n            comps.scheme = \"warp\"\n            comps.host = \"action\"\n            comps.path = \"/new_tab\"\n            comps.queryItems = [URLQueryItem(name: \"path\", value: dir)]\n            if let url = comps.url {\n                NSWorkspace.shared.open(url)\n            } else {\n                openTerminalApp(profile)\n            }\n        default:\n            if profile.isTerminal {\n                _ = openAppleTerminal(at: dir)\n                return\n            }\n            if let urlTemplate = profile.urlTemplate,\n               let url = buildLaunchURL(template: urlTemplate, directory: dir, command: command, profile: profile) {\n                NSWorkspace.shared.open(url)\n                return\n            }\n            openTerminalApp(profile)\n        }\n    }\n\n    // Open Terminal.app at a given directory (no auto-run). Returns success.\n    @discardableResult\n    func openAppleTerminal(at directory: String) -> Bool {\n        // Use `open -a Terminal <dir>` to spawn a new window in that path\n        let proc = Process()\n        proc.executableURL = URL(fileURLWithPath: \"/usr/bin/open\")\n        proc.arguments = [\"-a\", \"Terminal\", directory]\n        do {\n            try proc.run()\n            proc.waitUntilExit()\n            return proc.terminationStatus == 0\n        } catch { return false }\n    }\n\n    private func buildLaunchURL(\n        template: String,\n        directory: String,\n        command: String?,\n        profile: ExternalTerminalProfile\n    ) -> URL? {\n        let encodedDir = directory.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? directory\n        let encodedCommand: String? = {\n            guard let command, profile.supportsCommandResolved else { return nil }\n            return command.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? command\n        }()\n        var result = template.replacingOccurrences(of: \"{cwd}\", with: encodedDir)\n        result = result.replacingOccurrences(of: \"{command}\", with: encodedCommand ?? \"\")\n        return URL(string: result)\n    }\n\n    // MARK: - Warp Launch Configuration\n    @discardableResult\n    func openWarpLaunchConfig(\n        session: SessionSummary,\n        options: ResumeOptions,\n        executableURL: URL,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil\n    ) -> Bool {\n        let cwd = self.workingDirectory(for: session, override: workingDirectory)\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        let folder = home.appendingPathComponent(\".warp\", isDirectory: true)\n            .appendingPathComponent(\"launch_configurations\", isDirectory: true)\n        do {\n            try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)\n        } catch { /* ignore */  }\n\n        let baseName = \"codmate-resume-\\(session.id)\"\n        let fileName = baseName + \".yaml\"\n        let fileURL = folder.appendingPathComponent(fileName)\n        let commandString: String = {\n            let execPath = resolvedExecutablePath(\n                for: session.source.baseKind,\n                executableURL: executableURL\n            )\n            return buildResumeCLIInvocation(\n                session: session,\n                executablePath: execPath,\n                options: options,\n                codexHome: codexHome\n            )\n        }()\n\n        let yaml = \"\"\"\n            version: 1\n            name: CodMate Resume \\(session.id)\n            windows:\n              - tabs:\n                  - title: Codex\n                    panes:\n                      - cwd: \\(cwd)\n                        commands:\n                          - exec: \\(commandString)\n            \"\"\"\n        do { try yaml.data(using: String.Encoding.utf8)?.write(to: fileURL) } catch {}\n\n        // Prefer warp://launch/<config_name> (Warp resolves in its config dir), fallback to absolute path.\n        if let urlByName = URL(string: \"warp://launch/\\(baseName)\") {\n            let ok = NSWorkspace.shared.open(urlByName)\n            if ok { return true }\n        }\n        var comps = URLComponents()\n        comps.scheme = \"warp\"\n        comps.host = \"launch\"\n        comps.path = \"/\" + fileURL.path\n        if let url = comps.url { return NSWorkspace.shared.open(url) }\n        return false\n    }\n}\n\n// MARK: - iTerm helpers\nextension SessionActions {\n    private func openITermViaAppleScript(directory: String, command: String?) -> Bool {\n        let cdLine = \"cd \\(shellEscapedPathForScripts(directory))\"\n        var script = \"\"\"\n        tell application \"iTerm2\"\n          activate\n          set newWindow to (create window with default profile)\n          tell current session of newWindow\n            write text \"\\(appleScriptEscaped(cdLine))\"\n        \"\"\"\n        if let command,\n           !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            script += \"\"\"\n\n            write text \"\\(appleScriptEscaped(command))\"\n            \"\"\"\n        }\n        script += \"\"\"\n\n          end tell\n        end tell\n        \"\"\"\n        guard let appleScript = NSAppleScript(source: script) else { return false }\n        var errorDict: NSDictionary?\n        appleScript.executeAndReturnError(&errorDict)\n        return errorDict == nil\n    }\n\n    private func shellEscapedPathForScripts(_ path: String) -> String {\n        \"'\" + path.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\"\n    }\n\n    private func appleScriptEscaped(_ text: String) -> String {\n        var result = text.replacingOccurrences(of: \"\\\\\", with: \"\\\\\\\\\")\n        result = result.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n        result = result.replacingOccurrences(of: \"\\n\", with: \"; \")\n        result = result.replacingOccurrences(of: \"\\r\", with: \"\")\n        return result\n    }\n}\n"
  },
  {
    "path": "services/SessionActions.swift",
    "content": "import AppKit\nimport Foundation\n\nstruct ProcessResult {\n    let output: String\n}\n\nenum SessionActionError: LocalizedError {\n    case executableNotFound(URL)\n    case resumeFailed(output: String)\n    case deletionFailed(URL)\n    case featureUnavailable(String)\n\n    var errorDescription: String? {\n        switch self {\n        case .executableNotFound(let url):\n            return \"Executable codex CLI not found: \\(url.path)\"\n        case .resumeFailed(let output):\n            return \"Failed to resume session: \\(output)\"\n        case .deletionFailed(let url):\n            return \"Failed to move file to Trash: \\(url.path)\"\n        case .featureUnavailable(let message):\n            return message\n        }\n    }\n}\n\nstruct SessionActions {\n    let fileManager: FileManager = .default\n    private let codexHome: URL = FileManager.default.homeDirectoryForCurrentUser\n        .appendingPathComponent(\".codex\", isDirectory: true)\n    private let sshExecutablePath = \"/usr/bin/ssh\"\n    private let defaultPathInjection =\n        \"source ~/.bashrc; . \\\"$HOME/.nvm/nvm.sh\\\"; . \\\"$HOME/.nvm/bash_completion\\\"\"\n    private let sshConfigResolver = SSHConfigResolver()\n    let terminalLaunchQueue = DispatchQueue(label: \"io.umate.codemate.terminalLaunch\", qos: .userInitiated)\n    \n    func configuredProfiles() async -> Set<String> {\n        let persisted = listPersistedProfiles()\n        // Use listProviders() instead of configuredProfiles() which doesn't exist\n        let configured = await codexConfigService.listProviders().map { $0.id }\n        let merged = persisted.union(Set(configured))\n        return merged\n    }\n\n    func resolveModel(for session: SessionSummary) -> String? {\n        // For now, just check if the model is non-empty\n        // The configuredProfiles check requires async context, so we'll handle it differently\n        if session.source.baseKind == .codex {\n            if let m = session.model?.trimmingCharacters(in: .whitespacesAndNewlines), !m.isEmpty {\n                return m\n            }\n        }\n        return session.model\n    }\n\n    func resolveExecutableURL(preferred: URL, executableName: String) -> URL? {\n        // Prefer user-specified path if it exists\n        if fileManager.fileExists(atPath: preferred.path) {\n            return preferred\n        }\n        // Fallback to PATH resolution\n        let basePath = CLIEnvironment.buildBasePATH()\n        let currentPath = ProcessInfo.processInfo.environment[\"PATH\"] ?? \"\"\n        let combined = currentPath.isEmpty ? basePath : basePath + \":\" + currentPath\n        let components = combined.split(separator: \":\")\n        for component in components {\n            let candidate = URL(fileURLWithPath: String(component)).appendingPathComponent(executableName)\n            if fileManager.fileExists(atPath: candidate.path) {\n                return candidate\n            }\n        }\n        return nil\n    }\n\n    // MARK: - Resume helpers (moved to extension to avoid conflicts)\n    internal func resumeRemote(\n        session: SessionSummary,\n        host: String,\n        options: ResumeOptions\n    ) async throws -> ProcessResult {\n        let sshArguments = resolvedSSHContext(for: host)\n        let command = buildRemoteResumeShellCommand(\n            session: session,\n            options: options\n        )\n        let sshPath = sshExecutablePath\n        return try await withCheckedThrowingContinuation { continuation in\n            Task.detached {\n                do {\n                    let process = Process()\n                    process.executableURL = URL(fileURLWithPath: sshPath)\n                    var arguments: [String] = [\"-t\"]\n                    if let sshArguments {\n                        arguments.append(contentsOf: sshArguments)\n                    } else {\n                        arguments.append(host)\n                    }\n                    arguments.append(command)\n                    process.arguments = arguments\n\n                    let pipe = Pipe()\n                    process.standardOutput = pipe\n                    process.standardError = pipe\n\n                    try process.run()\n                    process.waitUntilExit()\n\n                    let data = pipe.fileHandleForReading.readDataToEndOfFile()\n                    let output = String(data: data, encoding: .utf8) ?? \"\"\n                    if process.terminationStatus == 0 {\n                        continuation.resume(returning: ProcessResult(output: output))\n                    } else {\n                        continuation.resume(\n                            throwing: SessionActionError.resumeFailed(output: output))\n                    }\n                } catch {\n                    continuation.resume(throwing: error)\n                }\n            }\n        }\n    }\n\n    // MARK: - Resume helpers (copy/open Terminal)\n    private func shellEscapedPath(_ path: String) -> String {\n        // Simple escape: wrap in single quotes and escape existing single quotes\n        let escaped = path.replacingOccurrences(of: \"'\", with: \"'\\\"'\\\"'\")\n        return \"'\\(escaped)'\"\n    }\n\n    private func shellQuoteIfNeeded(_ text: String) -> String {\n        if text.contains(\" \") || text.contains(\";\") || text.contains(\"&\") || text.contains(\"|\") {\n            return shellEscapedPath(text)\n        }\n        return text\n    }\n\n    private func shellSingleQuoted(_ text: String) -> String {\n        let escaped = text.replacingOccurrences(of: \"'\", with: \"'\\\"'\\\"'\")\n        return \"'\\(escaped)'\"\n    }\n\n    private func embeddedExportLines(for source: SessionSource) -> [String] {\n        var lines: [String] = [\n            \"export LANG=zh_CN.UTF-8\",\n            \"export LC_ALL=zh_CN.UTF-8\",\n            \"export LC_CTYPE=zh_CN.UTF-8\",\n            \"export TERM=xterm-256color\",\n        ]\n        if source.baseKind == .codex {\n            lines.append(\"export CODEX_DISABLE_COLOR_QUERY=1\")\n        }\n        return lines\n    }\n\n    func workingDirectory(for session: SessionSummary, override: String? = nil) -> String {\n        if session.isRemote {\n            let trimmed = session.cwd.trimmingCharacters(in: .whitespacesAndNewlines)\n            if !trimmed.isEmpty {\n                return trimmed\n            }\n            if let remotePath = session.remotePath {\n                let parent = (remotePath as NSString).deletingLastPathComponent\n                if !parent.isEmpty { return parent }\n            }\n            return session.cwd\n        }\n        if let override {\n            let trimmed = override.trimmingCharacters(in: .whitespacesAndNewlines)\n            if !trimmed.isEmpty, fileManager.fileExists(atPath: trimmed) {\n                return trimmed\n            }\n        }\n        if fileManager.fileExists(atPath: session.cwd) {\n            return session.cwd\n        }\n        return session.fileURL.deletingLastPathComponent().path\n    }\n\n    private func remoteExecutableName(for session: SessionSummary) -> String {\n        session.source.baseKind.cliExecutableName\n    }\n\n    func resolvedSSHContext(for alias: String) -> [String]? {\n        let hosts = sshConfigResolver.resolvedHosts()\n        guard let host = hosts.first(where: { $0.alias.caseInsensitiveCompare(alias) == .orderedSame })\n        else { return nil }\n        return sshArguments(for: host)\n    }\n\n    private func sshArguments(for host: SSHHost) -> [String] {\n        var args: [String] = []\n        if let user = host.user, !user.isEmpty {\n            args += [\"-l\", user]\n        }\n        if let port = host.port {\n            args += [\"-p\", String(port)]\n        }\n        if let identity = host.identityFile, !identity.isEmpty {\n            args += [\"-i\", identity]\n        }\n        if let proxyJump = host.proxyJump, !proxyJump.isEmpty {\n            args += [\"-J\", proxyJump]\n        }\n        if let proxyCommand = host.proxyCommand, !proxyCommand.isEmpty {\n            args += [\"-o\", \"ProxyCommand=\\(proxyCommand)\"]\n        }\n        if let forwardAgent = host.forwardAgent {\n            args += [\"-o\", \"ForwardAgent=\\(forwardAgent ? \"yes\" : \"no\")\"]\n        }\n        args.append(host.hostname ?? host.alias)\n        return args\n    }\n\n    private func buildSSHInvocation(host: String, arguments: [String]?, remoteCommand: String) -> String {\n        let args = arguments ?? [host]\n        let sshParts = ([\"ssh\", \"-t\"] + args).map { shellQuoteIfNeeded($0) }.joined(separator: \" \")\n        return \"\\(sshParts) \\(shellSingleQuoted(remoteCommand))\"\n    }\n\n    func remoteResumeInvocationForTerminal(\n        session: SessionSummary,\n        options: ResumeOptions\n    ) -> String? {\n        guard session.isRemote, let host = session.remoteHost else { return nil }\n        let remoteCommand = buildRemoteResumeShellCommand(session: session, options: options)\n        let args = resolvedSSHContext(for: host)\n        return buildSSHInvocation(host: host, arguments: args, remoteCommand: remoteCommand)\n    }\n\n    func remoteNewInvocationForTerminal(\n        session: SessionSummary,\n        options: ResumeOptions,\n        initialPrompt: String? = nil\n    ) -> String? {\n        guard session.isRemote, let host = session.remoteHost else { return nil }\n        let remoteCommand = buildRemoteNewShellCommand(\n            session: session,\n            options: options,\n            initialPrompt: initialPrompt\n        )\n        let args = resolvedSSHContext(for: host)\n        return buildSSHInvocation(host: host, arguments: args, remoteCommand: remoteCommand)\n    }\n\n    func buildRemoteShellCommand(\n        session: SessionSummary,\n        exports: [String],\n        invocation: String\n    ) -> String {\n        let cwd = workingDirectory(for: session)\n        var scriptParts: [String] = [defaultPathInjection]\n        scriptParts.append(\"cd \\(shellEscapedPath(cwd))\")\n        if !exports.isEmpty {\n            scriptParts.append(exports.joined(separator: \"; \"))\n        }\n        scriptParts.append(invocation)\n        let script = scriptParts.joined(separator: \"; \")\n        let sanitized = script.replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\")\n        return #\"bash -lc \"\\#(sanitized)\"\"#\n    }\n\n    func buildRemoteResumeShellCommand(\n        session: SessionSummary,\n        options: ResumeOptions\n    ) -> String {\n        var exports = embeddedExportLines(for: session.source)\n        if session.source.baseKind == .gemini {\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exports.append(contentsOf: envLines)\n        }\n        let invocation = buildResumeCLIInvocation(\n            session: session,\n            executablePath: remoteExecutableName(for: session),\n            options: options\n        )\n        return buildRemoteShellCommand(\n            session: session,\n            exports: exports,\n            invocation: invocation\n        )\n    }\n\n    func buildRemoteNewShellCommand(\n        session: SessionSummary,\n        options: ResumeOptions,\n        initialPrompt: String? = nil\n    ) -> String {\n        var exports = embeddedExportLines(for: session.source)\n        if session.source.baseKind == .gemini {\n            let envLines = geminiEnvironmentExportLines(\n                environment: geminiRuntimeConfiguration(options: options).environment)\n            exports.append(contentsOf: envLines)\n        }\n        let invocation = buildLocalNewSessionCLIInvocation(\n            session: session,\n            options: options,\n            initialPrompt: initialPrompt\n        )\n        return buildRemoteShellCommand(\n            session: session,\n            exports: exports,\n            invocation: invocation\n        )\n    }\n\n    private func flags(from options: ResumeOptions) -> [String] {\n        // Highest precedence: dangerously bypass\n        if options.dangerouslyBypass { return [\"--dangerously-bypass-approvals-and-sandbox\"] }\n        // Next: sandbox mode\n        switch options.sandbox {\n        case .none: return []\n        case .readOnly: return [\"--sandbox=read-only\"]\n        case .workspaceWrite: return [\"--sandbox=workspace-write\"]\n        case .dangerFullAccess: return [\"--sandbox=danger-full-access\"]\n        }\n    }\n\n    // Note: buildResumeArguments and buildClaudeResumeArguments are implemented in SessionActions+Commands.swift\n    // to avoid conflicts\n\n    // Note: buildNewSessionCLIInvocation is implemented in SessionActions+Commands.swift\n    // to avoid conflicts\n\n    // Note: buildResumeCommandLines is implemented in SessionActions+Commands.swift\n    // to avoid conflicts\n\n    // Note: buildNewSessionCommandLines is implemented in SessionActions+Commands.swift\n    // to avoid conflicts\n\n    // Note: buildExternalResumeCommands is implemented in SessionActions+Commands.swift\n    // to avoid conflicts\n\n    // Additional helper methods would continue here...\n    // For brevity, I'll add the essential ones needed for remote support\n\n    private func conversationId(for session: SessionSummary) -> String {\n        return session.id\n    }\n\n    private let codexConfigService = CodexConfigService()\n}\n"
  },
  {
    "path": "services/SessionActivityTracker.swift",
    "content": "import Foundation\n\n/// Tracks real-time session activity and followup status\n@MainActor\nfinal class SessionActivityTracker: ObservableObject {\n    @Published private(set) var activeUpdatingIDs: Set<String> = []\n    @Published private(set) var awaitingFollowupIDs: Set<String> = []\n\n    private var activityHeartbeat: [String: Date] = [:]\n    private var fileMTimeCache: [String: Date] = [:]\n    private var activityPruneTask: Task<Void, Never>?\n    private var quickPulseTask: Task<Void, Never>?\n    private var lastQuickPulseAt: Date = .distantPast\n\n    init() {\n        startPruneTicker()\n        observeAgentCompletions()\n    }\n\n    deinit {\n        activityPruneTask?.cancel()\n        quickPulseTask?.cancel()\n    }\n\n    func registerHeartbeat(previous: [SessionSummary], current: [SessionSummary]) {\n        var prevMap: [String: Date] = [:]\n        for s in previous { if let t = s.lastUpdatedAt { prevMap[s.id] = t } }\n        let now = Date()\n        for s in current {\n            guard let newT = s.lastUpdatedAt else { continue }\n            if let oldT = prevMap[s.id], newT > oldT {\n                activityHeartbeat[s.id] = now\n            }\n        }\n        recomputeActiveIDs()\n    }\n\n    func isActivelyUpdating(_ id: String) -> Bool {\n        activeUpdatingIDs.contains(id)\n    }\n\n    func isAwaitingFollowup(_ id: String) -> Bool {\n        awaitingFollowupIDs.contains(id)\n    }\n\n    func markAwaitingFollowup(_ id: String) {\n        awaitingFollowupIDs.insert(id)\n    }\n\n    func quickPulse(sessions: [SessionSummary]) {\n        let now = Date()\n        guard now.timeIntervalSince(lastQuickPulseAt) > 0.4 else { return }\n        lastQuickPulseAt = now\n        quickPulseTask?.cancel()\n\n        let displayed = sessions.prefix(200)\n        quickPulseTask = Task.detached { [weak self] in\n            guard let self else { return }\n            let fm = FileManager.default\n            var modified: [String: Date] = [:]\n            for s in displayed {\n                let path = s.fileURL.path\n                if let attrs = try? fm.attributesOfItem(atPath: path),\n                    let m = attrs[.modificationDate] as? Date\n                {\n                    modified[s.id] = m\n                }\n            }\n            let snapshot = modified\n            await MainActor.run {\n                let now = Date()\n                for (id, m) in snapshot {\n                    let previous = self.fileMTimeCache[id]\n                    self.fileMTimeCache[id] = m\n                    if let previous, m > previous {\n                        self.activityHeartbeat[id] = now\n                    }\n                }\n                self.recomputeActiveIDs()\n            }\n        }\n    }\n\n    func cancelPulse() {\n        quickPulseTask?.cancel()\n        quickPulseTask = nil\n    }\n\n    private func startPruneTicker() {\n        activityPruneTask?.cancel()\n        activityPruneTask = Task { [weak self] in\n            while !Task.isCancelled {\n                try? await Task.sleep(nanoseconds: 1_000_000_000)\n                await MainActor.run { self?.recomputeActiveIDs() }\n            }\n        }\n    }\n\n    private func recomputeActiveIDs() {\n        let cutoff = Date().addingTimeInterval(-3.0)\n        activeUpdatingIDs = Set(activityHeartbeat.filter { $0.value > cutoff }.keys)\n    }\n\n    private func observeAgentCompletions() {\n        NotificationCenter.default.addObserver(\n            forName: .codMateAgentCompleted,\n            object: nil,\n            queue: .main\n        ) { [weak self] note in\n            guard let id = note.userInfo?[\"sessionID\"] as? String else { return }\n            Task { @MainActor in\n                self?.awaitingFollowupIDs.insert(id)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "services/SessionCacheStore.swift",
    "content": "import Foundation\n\nactor SessionCacheStore {\n    private struct Entry: Codable {\n        let path: String\n        let modificationTime: TimeInterval?\n        let summary: SessionSummary\n    }\n\n    private var map: [String: Entry] = [:]  // key: file path\n    private let url: URL\n    private var needsSave = false\n\n    init(fileManager: FileManager = .default) {\n        let dir = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!\n            .appendingPathComponent(\"CodMate\", isDirectory: true)\n        try? fileManager.createDirectory(at: dir, withIntermediateDirectories: true)\n        url = dir.appendingPathComponent(\"sessionIndex-v2.json\")\n\n        // Load cache synchronously in init (init is nonisolated)\n        if let data = try? Data(contentsOf: url),\n            let entries = try? JSONDecoder().decode([Entry].self, from: data)\n        {\n            map = Dictionary(uniqueKeysWithValues: entries.map { ($0.path, $0) })\n        }\n    }\n\n    private func saveIfNeededDebounced() {\n        guard needsSave else { return }\n        needsSave = false\n        let entries = Array(map.values)\n        if let data = try? JSONEncoder().encode(entries) {\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    func get(path: String, modificationDate: Date?) -> SessionSummary? {\n        guard let entry = map[path] else { return nil }\n        let mt = modificationDate?.timeIntervalSince1970\n        if entry.modificationTime == mt {\n            return entry.summary\n        }\n        return nil\n    }\n\n    func set(path: String, modificationDate: Date?, summary: SessionSummary) {\n        let mt = modificationDate?.timeIntervalSince1970\n        map[path] = Entry(path: path, modificationTime: mt, summary: summary)\n        needsSave = true\n        saveIfNeededDebounced()\n    }\n}\n"
  },
  {
    "path": "services/SessionCommandGenerator.swift",
    "content": "import Foundation\n\nstruct SessionCommandGenerator {\n    let actions: SessionActions\n\n    func embeddedResume(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        workingDirectory: String? = nil,\n        codexHome: String? = nil,\n        includeCd: Bool = true\n    ) -> String {\n        actions.buildEmbeddedResumeCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            workingDirectory: workingDirectory,\n            codexHome: codexHome,\n            includeCd: includeCd\n        )\n    }\n\n    func embeddedNew(\n        session: SessionSummary,\n        project: Project? = nil,\n        executableURL: URL,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.source == .codexLocal,\n           let project,\n           project.profile != nil || (project.profileId?.isEmpty == false)\n        {\n            return actions.buildNewSessionUsingProjectProfileCommandLines(\n                session: session,\n                project: project,\n                executableURL: executableURL,\n                options: options,\n                initialPrompt: initialPrompt,\n                codexHome: codexHome\n            )\n        }\n        return actions.buildEmbeddedNewSessionCommandLines(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            initialPrompt: initialPrompt,\n            codexHome: codexHome\n        )\n    }\n\n    func embeddedNewProject(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        actions.buildEmbeddedNewProjectCommandLines(\n            project: project,\n            executableURL: executableURL,\n            options: options,\n            codexHome: codexHome\n        )\n    }\n\n    func inlineResume(\n        session: SessionSummary,\n        project: Project? = nil,\n        executablePath: String,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote,\n           let remote = actions.remoteResumeInvocationForTerminal(\n                session: session,\n                options: options\n            ) {\n            return remote\n        }\n        if session.source == .codexLocal,\n           let project,\n           project.profile != nil || (project.profileId?.isEmpty == false)\n        {\n            return actions.buildResumeUsingProjectProfileCLIInvocation(\n                session: session,\n                project: project,\n                executablePath: executablePath,\n                options: options,\n                codexHome: codexHome\n            )\n        }\n        return actions.buildResumeCLIInvocation(\n            session: session,\n            executablePath: executablePath,\n            options: options,\n            codexHome: codexHome\n        )\n    }\n\n    func inlineNew(\n        session: SessionSummary,\n        project: Project? = nil,\n        executablePath: String,\n        options: ResumeOptions,\n        initialPrompt: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        if session.isRemote,\n           let remote = actions.remoteNewInvocationForTerminal(\n                session: session,\n                options: options,\n                initialPrompt: initialPrompt\n            ) {\n            return remote\n        }\n        if session.source == .codexLocal,\n           let project,\n           project.profile != nil || (project.profileId?.isEmpty == false)\n        {\n            return actions.buildNewSessionUsingProjectProfileCLIInvocation(\n                session: session,\n                project: project,\n                options: options,\n                initialPrompt: initialPrompt,\n                executablePath: executablePath,\n                codexHome: codexHome\n            )\n        }\n        return actions.buildNewSessionCLIInvocation(\n            session: session,\n            options: options,\n            initialPrompt: initialPrompt,\n            executablePath: executablePath,\n            codexHome: codexHome\n        )\n    }\n\n    func inlineNewProject(\n        project: Project,\n        executablePath: String,\n        options: ResumeOptions,\n        codexHome: String? = nil\n    ) -> String {\n        actions.buildNewProjectCLIInvocation(\n            project: project,\n            options: options,\n            executablePath: executablePath,\n            codexHome: codexHome\n        )\n    }\n\n    func warpResume(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        actions.buildWarpResumeCommands(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            titleHint: titleHint,\n            codexHome: codexHome\n        )\n    }\n\n    func warpNewSession(\n        session: SessionSummary,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        actions.buildWarpNewSessionCommands(\n            session: session,\n            executableURL: executableURL,\n            options: options,\n            titleHint: titleHint,\n            codexHome: codexHome\n        )\n    }\n\n    func warpNewProject(\n        project: Project,\n        executableURL: URL,\n        options: ResumeOptions,\n        titleHint: String? = nil,\n        codexHome: String? = nil\n    ) -> String {\n        actions.buildWarpNewProjectCommands(\n            project: project,\n            executableURL: executableURL,\n            options: options,\n            titleHint: titleHint,\n            codexHome: codexHome\n        )\n    }\n\n    func projectClaudeInvocation(\n        project: Project,\n        executablePath: String,\n        options: ResumeOptions,\n        fallbackModel: String?\n    ) -> String {\n        let effectiveModel = (project.profile?.model ?? fallbackModel)\n        return actions.buildClaudeProjectCLIInvocation(\n            executablePath: executablePath,\n            options: options,\n            model: effectiveModel\n        )\n    }\n\n    func projectGeminiInvocation(\n        executablePath: String,\n        options: ResumeOptions\n    ) -> String {\n        actions.buildGeminiCLIInvocation(\n            executablePath: executablePath,\n            options: options\n        )\n    }\n}\n"
  },
  {
    "path": "services/SessionEnrichmentService.swift",
    "content": "import Foundation\n\n/// Service responsible for background enrichment of session summaries\n@MainActor\nfinal class SessionEnrichmentService {\n    private let indexer: SessionIndexer\n    private let claudeProvider: ClaudeSessionProvider\n\n    private var enrichmentTask: Task<Void, Never>?\n    private var enrichmentSnapshots: [String: Set<String>] = [:]\n\n    var isEnriching = false\n    var enrichmentProgress: Int = 0\n    var enrichmentTotal: Int = 0\n\n    init(indexer: SessionIndexer, claudeProvider: ClaudeSessionProvider) {\n        self.indexer = indexer\n        self.claudeProvider = claudeProvider\n    }\n\n    func startEnrichment(\n        sessions: [SessionSummary],\n        cacheKey: String,\n        notesSnapshot: [String: SessionNote],\n        onUpdate: @escaping ([SessionSummary]) -> Void\n    ) {\n        enrichmentTask?.cancel()\n\n        let currentIDs = Set(sessions.map(\\.id))\n        if let cached = enrichmentSnapshots[cacheKey], cached == currentIDs {\n            isEnriching = false\n            enrichmentProgress = 0\n            enrichmentTotal = 0\n            return\n        }\n\n        if sessions.isEmpty {\n            isEnriching = false\n            enrichmentProgress = 0\n            enrichmentTotal = 0\n            enrichmentSnapshots[cacheKey] = currentIDs\n            return\n        }\n\n        enrichmentTask = Task { [weak self] in\n            guard let self else { return }\n\n            await MainActor.run {\n                self.isEnriching = true\n                self.enrichmentProgress = 0\n                self.enrichmentTotal = sessions.count\n            }\n\n            let concurrency = max(2, ProcessInfo.processInfo.processorCount / 2)\n            try? await withThrowingTaskGroup(of: (String, SessionSummary)?.self) { group in\n                var iterator = sessions.makeIterator()\n                var processedCount = 0\n\n                func addNext(_ n: Int) {\n                    for _ in 0..<n {\n                        guard let s = iterator.next() else { return }\n                        group.addTask { [weak self] in\n                            guard let self else { return nil }\n                            if s.source.baseKind == .claude {\n                                if let enriched = await self.claudeProvider.enrich(summary: s) {\n                                    return (s.id, enriched)\n                                }\n                                return (s.id, s)\n                            } else if let enriched = try await self.indexer.enrich(url: s.fileURL) {\n                                return (s.id, enriched)\n                            }\n                            return (s.id, s)\n                        }\n                    }\n                }\n\n                addNext(concurrency)\n                var updatesBuffer: [(String, SessionSummary)] = []\n                var lastFlushTime = ContinuousClock.now\n\n                func flush() async {\n                    guard !updatesBuffer.isEmpty else { return }\n                    var enrichedSessions = sessions\n                    var map = Dictionary(uniqueKeysWithValues: enrichedSessions.map { ($0.id, $0) })\n                    for (id, item) in updatesBuffer {\n                        var enriched = item\n                        if let note = notesSnapshot[id] {\n                            enriched.userTitle = note.title\n                            enriched.userComment = note.comment\n                        }\n                        map[id] = enriched\n                    }\n                    let newEnrichedSessions = Array(map.values)\n                    enrichedSessions = newEnrichedSessions\n                    await MainActor.run {\n                        onUpdate(newEnrichedSessions)\n                    }\n                    updatesBuffer.removeAll(keepingCapacity: true)\n                    lastFlushTime = ContinuousClock.now\n                }\n\n                while let result = try await group.next() {\n                    if let (_, enriched) = result {\n                        updatesBuffer.append((enriched.id, enriched))\n                        processedCount += 1\n\n                        await MainActor.run {\n                            self.enrichmentProgress = processedCount\n                        }\n\n                        let now = ContinuousClock.now\n                        let elapsed = lastFlushTime.duration(to: now)\n                        if updatesBuffer.count >= 50 || elapsed.components.seconds >= 1 {\n                            await flush()\n                        }\n                    }\n                    addNext(1)\n                }\n                await flush()\n\n                await MainActor.run {\n                    self.isEnriching = false\n                    self.enrichmentProgress = 0\n                    self.enrichmentTotal = 0\n                    self.enrichmentSnapshots[cacheKey] = currentIDs\n                }\n            }\n        }\n    }\n\n    func cancel() {\n        enrichmentTask?.cancel()\n        enrichmentTask = nil\n        isEnriching = false\n    }\n\n    func invalidateCache(for key: String) {\n        enrichmentSnapshots.removeValue(forKey: key)\n    }\n\n    func clearAllCaches() {\n        enrichmentSnapshots.removeAll()\n    }\n}\n"
  },
  {
    "path": "services/SessionIndexSQLiteStore.swift",
    "content": "import CryptoKit\nimport Foundation\nimport OSLog\nimport SQLite3\n\nprivate let SQLITE_TRANSIENT = unsafeBitCast(-1, to: sqlite3_destructor_type.self)\n\n/// 持久化缓存的一条记录，包含 SessionSummary 以及文件元数据。\nstruct SessionIndexRecord: Sendable {\n  let summary: SessionSummary\n  let filePath: String\n  let fileModificationTime: Date?\n  let fileSize: UInt64?\n  let project: String?\n  let schemaVersion: Int\n  let parseError: String?\n  let tokenBreakdown: SessionTokenBreakdown?\n  let parseLevel: String?  // \"metadata\" | \"full\" | \"enriched\"\n  let parsedAt: Date?       // When this parse was done\n}\n\nstruct SessionIndexMeta: Sendable {\n  let lastFullIndexAt: Date?\n  let sessionCount: Int\n}\n\nenum SessionIndexSQLiteStoreError: Error {\n  case openFailed(String)\n  case stepFailed(String)\n  case bindFailed(String)\n  case decodeFailed(String)\n}\n\n/// SQLite 持久化缓存，负责 sessions 汇总数据的存储与读取。\n  actor SessionIndexSQLiteStore {\n    static let schemaVersion = 3\n    static let instructionsPreviewLimit = 128\n\n    private let logger = Logger(subsystem: \"io.umate.codmate\", category: \"SessionIndexSQLiteStore\")\n    private let dbURL: URL\n    private var db: OpaquePointer?\n    private var missingDbLogged = false\n\n  init(baseDirectory: URL? = nil, fileManager: FileManager = .default) {\n    let directory: URL\n    if let baseDirectory {\n      directory = baseDirectory\n    } else {\n      directory = fileManager.homeDirectoryForCurrentUser.appendingPathComponent(\".codmate\", isDirectory: true)\n    }\n    let legacyURL = directory.appendingPathComponent(\"sessionIndex-v3.db\")\n    if fileManager.fileExists(atPath: legacyURL.path) {\n      try? fileManager.removeItem(at: legacyURL)\n    }\n    try? fileManager.createDirectory(at: directory, withIntermediateDirectories: true)\n    dbURL = directory.appendingPathComponent(\"sessionIndex-v4.db\")\n  }\n\n  // MARK: - Public API\n\n  func reset() throws {\n    closeDatabase()\n    try? FileManager.default.removeItem(at: dbURL)\n    let legacy = dbURL.deletingLastPathComponent().appendingPathComponent(\"sessionIndex-v3.db\")\n    try? FileManager.default.removeItem(at: legacy)\n  }\n\n  /// 更新全量索引完成时间和记录数。\n  func setMeta(lastFullIndexAt: Date, sessionCount: Int) throws {\n    try openIfNeeded()\n    let sql =\n      \"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\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    sqlite3_bind_double(stmt, 1, lastFullIndexAt.timeIntervalSince1970)\n    sqlite3_bind_int(stmt, 2, Int32(sessionCount))\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n\n  func fetchMeta() throws -> SessionIndexMeta {\n    try openIfNeeded()\n    let sql = \"SELECT last_full_index_at, session_count FROM meta WHERE key = 'global' LIMIT 1\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    if sqlite3_step(stmt) == SQLITE_ROW {\n      let ts = sqlite3_column_double(stmt, 0)\n      let count = Int(sqlite3_column_int(stmt, 1))\n      let date = ts == 0 ? nil : Date(timeIntervalSince1970: ts)\n      return SessionIndexMeta(lastFullIndexAt: date, sessionCount: count)\n    }\n    return SessionIndexMeta(lastFullIndexAt: nil, sessionCount: 0)\n  }\n\n  /// 按文件路径 + mtime（可选 fileSize 校验）命中缓存，用于索引快速路径。\n  func fetch(path: String, modificationDate: Date?, fileSize: UInt64?) throws -> SessionSummary? {\n    guard let modificationDate else { return nil }\n    try openIfNeeded()\n    let sql =\n      \"SELECT payload, file_size, schema_version FROM sessions WHERE file_path = ?1 AND file_mtime = ?2 LIMIT 1\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    sqlite3_bind_text(stmt, 1, path, -1, SQLITE_TRANSIENT)\n    sqlite3_bind_double(stmt, 2, modificationDate.timeIntervalSince1970)\n\n    if sqlite3_step(stmt) == SQLITE_ROW {\n      let storedSize = columnInt64(stmt, index: 1).flatMap { UInt64($0) }\n      let schemaVersion = Int(sqlite3_column_int(stmt, 2))\n      guard schemaVersion == Self.schemaVersion else {\n        logger.info(\"cache miss (schema mismatch) for path=\\(path, privacy: .public)\")\n        return nil\n      }\n      if let fileSize, let storedSize, fileSize != storedSize {\n        logger.info(\"cache miss (size mismatch) for path=\\(path, privacy: .public)\")\n        return nil\n      }\n      guard let payload = columnData(stmt, index: 0) else { return nil }\n      let summary = try JSONDecoder().decode(SessionSummary.self, from: payload)\n\n      // Invalidate cache if Claude session was parsed with old schema (before timeline-based counting)\n      if summary.source.baseKind == .claude && summary.parseLevel != .enriched {\n        logger.info(\"cache miss (schema upgrade) for path=\\(path, privacy: .public)\")\n        return nil\n      }\n\n      logger.info(\"cache hit (path+mtime) kind=\\(summary.source.baseKind.rawValue, privacy: .public) path=\\(path, privacy: .public)\")\n      return summary\n    }\n    logger.info(\"cache miss (path+mtime) path=\\(path, privacy: .public)\")\n    return nil\n  }\n\n  func fetch(sessionId: String) throws -> SessionIndexRecord? {\n    try openIfNeeded()\n    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\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n\n    if sqlite3_step(stmt) == SQLITE_ROW {\n      guard let payload = columnData(stmt, index: 0) else {\n        throw SessionIndexSQLiteStoreError.decodeFailed(\"Missing payload for session_id=\\(sessionId)\")\n      }\n      var summary = try JSONDecoder().decode(SessionSummary.self, from: payload)\n      let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path\n      let fileMtime = columnDate(stmt, index: 2)\n      let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) }\n      let project = columnText(stmt, index: 4)\n      let schemaVersion = Int(sqlite3_column_int(stmt, 5))\n      guard schemaVersion == Self.schemaVersion else { return nil }\n      let parseError = columnText(stmt, index: 6)\n      let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7)\n      summary = summary.withTokenBreakdownFallback(tokenBreakdown)\n      let parseLevel = columnText(stmt, index: 11)\n      let parsedAt = columnDate(stmt, index: 12)\n      return SessionIndexRecord(\n        summary: summary,\n        filePath: filePath,\n        fileModificationTime: fileMtime,\n        fileSize: fileSize,\n        project: project,\n        schemaVersion: schemaVersion,\n        parseError: parseError,\n        tokenBreakdown: tokenBreakdown,\n        parseLevel: parseLevel,\n        parsedAt: parsedAt\n      )\n    }\n    return nil\n  }\n\n  func fetchAll(limit: Int? = nil) throws -> [SessionIndexRecord] {\n    try openIfNeeded()\n    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\"\n    if let limit { sql += \" LIMIT \\(limit)\" }\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    var result: [SessionIndexRecord] = []\n    let decoder = JSONDecoder()\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard let payload = columnData(stmt, index: 0) else { continue }\n      guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue }\n      let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path\n      let fileMtime = columnDate(stmt, index: 2)\n      let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) }\n      let project = columnText(stmt, index: 4)\n      let schemaVersion = Int(sqlite3_column_int(stmt, 5))\n      let parseError = columnText(stmt, index: 6)\n      let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7)\n      summary = summary.withTokenBreakdownFallback(tokenBreakdown)\n      let parseLevel = columnText(stmt, index: 11)\n      let parsedAt = columnDate(stmt, index: 12)\n      result.append(\n        SessionIndexRecord(\n          summary: summary,\n          filePath: filePath,\n          fileModificationTime: fileMtime,\n          fileSize: fileSize,\n          project: project,\n          schemaVersion: schemaVersion,\n          parseError: parseError,\n          tokenBreakdown: tokenBreakdown,\n          parseLevel: parseLevel,\n          parsedAt: parsedAt\n        )\n      )\n    }\n    return result\n  }\n\n  /// 聚合 Overview 统计（全部来源，使用缓存数据）。\n  func fetchOverviewAggregate(scope: OverviewAggregateScope? = nil) throws -> OverviewAggregate {\n    let started = Date()\n    try openIfNeeded()\n    let totals = try fetchTotals(scope: scope)\n    let sources = try fetchSourceAggregates(scope: scope)\n    let daily = try fetchDailyAggregates(scope: scope)\n    let elapsed = Date().timeIntervalSince(started)\n    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\")\n    return OverviewAggregate(\n      totalSessions: totals.sessions,\n      totalTokens: totals.tokens,\n      totalDuration: totals.duration,\n      userMessages: totals.userMessages,\n      assistantMessages: totals.assistantMessages,\n      toolInvocations: totals.toolInvocations,\n      sources: sources,\n      daily: daily,\n      generatedAt: Date()\n    )\n  }\n\n  /// 缓存覆盖范围（命中源、记录数、全量完成时间）。\n  func fetchCoverage() throws -> SessionIndexCoverage {\n    let started = Date()\n    try openIfNeeded()\n    let meta = try fetchMeta()\n    let sources = try distinctSources()\n    let sessionCount = try countSessions()\n    let elapsed = Date().timeIntervalSince(started)\n    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\")\n    return SessionIndexCoverage(\n      sessionCount: sessionCount,\n      lastFullIndexAt: meta.lastFullIndexAt,\n      sources: sources\n    )\n  }\n\n  func upsert(\n    summary: SessionSummary,\n    project: String?,\n    fileModificationTime: Date?,\n    fileSize: UInt64?,\n    tokenBreakdown: SessionTokenBreakdown?,\n    fullInstructions: String? = nil,\n    parseError: String? = nil,\n    parseLevel: String = \"full\"  // \"metadata\" | \"full\" | \"enriched\"\n  ) throws {\n    try openIfNeeded()\n\n    // Downgrade protection:\n    // If we already have a record for this session, check if the new data would overwrite\n    // a high-quality parse (full/enriched) with a low-quality one (metadata) when the file hasn't changed.\n    if let oldRecord = try? fetch(sessionId: summary.id) {\n      // Check if file is effectively unchanged\n      let mtimeChanged = (fileModificationTime != nil && oldRecord.fileModificationTime != nil) &&\n                         (abs(fileModificationTime!.timeIntervalSince1970 - oldRecord.fileModificationTime!.timeIntervalSince1970) > 0.001)\n      let sizeChanged = (fileSize != nil && oldRecord.fileSize != nil) && (fileSize != oldRecord.fileSize)\n      let fileUnchanged = !mtimeChanged && !sizeChanged\n\n      if fileUnchanged {\n        let oldRank = parseLevelRank(oldRecord.parseLevel)\n        let newRank = parseLevelRank(parseLevel)\n\n        // If trying to overwrite higher rank with lower rank (e.g. Full -> Metadata),\n        // we SKIP the update for all content fields to preserve the better data.\n        // However, we might want to update last_updated_at if the new one is fresher\n        // (though usually full parse has better timestamp too).\n        // For safety, we just abort the upsert entirely if we are downgrading on same file.\n        if newRank < oldRank {\n          // logger.debug(\"Skipping upsert for \\(summary.id): preventing downgrade from \\(oldRecord.parseLevel ?? \"nil\") to \\(parseLevel)\")\n          return\n        }\n      }\n    }\n\n    let resolvedProjectKey = Self.resolveProjectKey(projectId: project, cwd: summary.cwd)\n    let normalizedFullInstructions = Self.normalizeInstructions(fullInstructions ?? summary.instructions)\n    let instructionsPreview = Self.instructionsPreview(from: normalizedFullInstructions)\n    let cachedSummary = summary.withInstructionPreview(instructionsPreview)\n\n    let sql = \"\"\"\n    INSERT INTO sessions (\n      session_id, file_path, file_mtime, file_size, schema_version, parse_error,\n      project, source, source_host, started_at, ended_at, last_updated_at,\n      active_duration, cli_version, cwd, originator, instructions, model,\n      approval_policy, user_message_count, assistant_message_count,\n      tool_invocation_count, reasoning_count, response_counts_json,\n      turn_context_count, tokens_input, tokens_output, tokens_cache_read,\n      tokens_cache_creation, tokens_total, event_count, line_count, remote_path,\n      user_title, user_comment, task_id, has_terminal, has_review, payload,\n      parse_level, parsed_at\n    ) VALUES (\n      ?1, ?2, ?3, ?4, ?5, ?6,\n      ?7, ?8, ?9, ?10, ?11, ?12,\n      ?13, ?14, ?15, ?16, ?17, ?18,\n      ?19, ?20, ?21, ?22, ?23, ?24,\n      ?25, ?26, ?27, ?28, ?29, ?30,\n      ?31, ?32, ?33, ?34, ?35, ?36, ?37, ?38, ?39,\n      ?40, ?41\n    )\n    ON CONFLICT(session_id) DO UPDATE SET\n      file_path=excluded.file_path,\n      file_mtime=excluded.file_mtime,\n      file_size=excluded.file_size,\n      schema_version=excluded.schema_version,\n      parse_error=excluded.parse_error,\n      project=excluded.project,\n      source=excluded.source,\n      source_host=excluded.source_host,\n      started_at=excluded.started_at,\n      ended_at=excluded.ended_at,\n      last_updated_at=excluded.last_updated_at,\n      active_duration=excluded.active_duration,\n      cli_version=excluded.cli_version,\n      cwd=excluded.cwd,\n      originator=excluded.originator,\n      instructions=excluded.instructions,\n      model=excluded.model,\n      approval_policy=excluded.approval_policy,\n      user_message_count=excluded.user_message_count,\n      assistant_message_count=excluded.assistant_message_count,\n      tool_invocation_count=excluded.tool_invocation_count,\n      reasoning_count=excluded.reasoning_count,\n      response_counts_json=excluded.response_counts_json,\n      turn_context_count=excluded.turn_context_count,\n      tokens_input=excluded.tokens_input,\n      tokens_output=excluded.tokens_output,\n      tokens_cache_read=excluded.tokens_cache_read,\n      tokens_cache_creation=excluded.tokens_cache_creation,\n      tokens_total=excluded.tokens_total,\n      event_count=excluded.event_count,\n      line_count=excluded.line_count,\n      remote_path=excluded.remote_path,\n      user_title=excluded.user_title,\n      user_comment=excluded.user_comment,\n      task_id=excluded.task_id,\n      has_terminal=excluded.has_terminal,\n      has_review=excluded.has_review,\n      payload=excluded.payload,\n      parse_level=excluded.parse_level,\n      parsed_at=excluded.parsed_at\n    \"\"\"\n\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    let responseCountsJSON = (try? JSONEncoder().encode(cachedSummary.responseCounts)).flatMap {\n      String(data: $0, encoding: .utf8)\n    }\n    let summaryData = try JSONEncoder().encode(cachedSummary)\n\n    bindText(stmt, index: 1, value: cachedSummary.id)\n    bindText(stmt, index: 2, value: cachedSummary.fileURL.path)\n    bindDate(stmt, index: 3, value: fileModificationTime)\n    bindInt64(stmt, index: 4, value: fileSize.map(Int64.init))\n    sqlite3_bind_int(stmt, 5, Int32(Self.schemaVersion))\n    bindText(stmt, index: 6, value: parseError)\n    bindText(stmt, index: 7, value: project)\n    let sourceEncoding = encode(source: cachedSummary.source)\n    bindText(stmt, index: 8, value: sourceEncoding.kind)\n    bindText(stmt, index: 9, value: sourceEncoding.host)\n    bindDate(stmt, index: 10, value: cachedSummary.startedAt)\n    bindDate(stmt, index: 11, value: cachedSummary.endedAt)\n    bindDate(stmt, index: 12, value: cachedSummary.lastUpdatedAt)\n    bindDouble(stmt, index: 13, value: cachedSummary.activeDuration)\n    bindText(stmt, index: 14, value: cachedSummary.cliVersion)\n    bindText(stmt, index: 15, value: cachedSummary.cwd)\n    bindText(stmt, index: 16, value: cachedSummary.originator)\n    bindText(stmt, index: 17, value: instructionsPreview)\n    bindText(stmt, index: 18, value: cachedSummary.model)\n    bindText(stmt, index: 19, value: cachedSummary.approvalPolicy)\n    sqlite3_bind_int(stmt, 20, Int32(cachedSummary.userMessageCount))\n    sqlite3_bind_int(stmt, 21, Int32(cachedSummary.assistantMessageCount))\n    sqlite3_bind_int(stmt, 22, Int32(cachedSummary.toolInvocationCount))\n    sqlite3_bind_int(stmt, 23, Int32(cachedSummary.responseCounts[\"reasoning\"] ?? 0))\n    bindText(stmt, index: 24, value: responseCountsJSON)\n    sqlite3_bind_int(stmt, 25, Int32(cachedSummary.turnContextCount))\n    bindInt(stmt, index: 26, value: tokenBreakdown?.input)\n    bindInt(stmt, index: 27, value: tokenBreakdown?.output)\n    bindInt(stmt, index: 28, value: tokenBreakdown?.cacheRead)\n    bindInt(stmt, index: 29, value: tokenBreakdown?.cacheCreation)\n    bindInt(stmt, index: 30, value: cachedSummary.totalTokens)\n    sqlite3_bind_int(stmt, 31, Int32(cachedSummary.eventCount))\n    sqlite3_bind_int(stmt, 32, Int32(cachedSummary.lineCount))\n    bindText(stmt, index: 33, value: cachedSummary.remotePath)\n    bindText(stmt, index: 34, value: cachedSummary.userTitle)\n    bindText(stmt, index: 35, value: cachedSummary.userComment)\n    bindText(stmt, index: 36, value: cachedSummary.taskId?.uuidString)\n    sqlite3_bind_int(stmt, 37, 0) // has_terminal (placeholder)\n    sqlite3_bind_int(stmt, 38, 0) // has_review (placeholder)\n    bindData(stmt, index: 39, data: summaryData)\n    bindText(stmt, index: 40, value: parseLevel)\n    bindDate(stmt, index: 41, value: Date()) // parsed_at = now\n\n    let stepResult = sqlite3_step(stmt)\n    guard stepResult == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n\n    if let resolvedProjectKey, let normalizedFullInstructions {\n      try upsertProject(\n        projectKey: resolvedProjectKey,\n        fullInstructions: normalizedFullInstructions,\n        preview: instructionsPreview\n      )\n    }\n  }\n\n  /// Fetch full instructions for the first matching project key.\n  func fetchProjectInstructions(keys: [String]) throws -> String? {\n    try openIfNeeded()\n    guard !keys.isEmpty else { return nil }\n    let sql = \"SELECT instructions_full FROM projects WHERE project_key = ?1 LIMIT 1\"\n    for key in keys {\n      var stmt: OpaquePointer?\n      guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n        throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n      }\n      defer { sqlite3_finalize(stmt) }\n      sqlite3_bind_text(stmt, 1, key, -1, SQLITE_TRANSIENT)\n      if sqlite3_step(stmt) == SQLITE_ROW {\n        return columnText(stmt, index: 0)\n      }\n    }\n    return nil\n  }\n\n  /// Update project assignment for a session without touching other fields.\n  func updateProject(sessionId: String, project: String?) throws {\n    try openIfNeeded()\n    let sql = \"UPDATE sessions SET project = ?1 WHERE session_id = ?2\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    bindText(stmt, index: 1, value: project)\n    sqlite3_bind_text(stmt, 2, sessionId, -1, SQLITE_TRANSIENT)\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n\n  func updateUserMetadata(sessionId: String, title: String?, comment: String?) throws {\n    try openIfNeeded()\n    let sql = \"UPDATE sessions SET user_title = ?1, user_comment = ?2 WHERE session_id = ?3\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    bindText(stmt, index: 1, value: title)\n    bindText(stmt, index: 2, value: comment)\n    sqlite3_bind_text(stmt, 3, sessionId, -1, SQLITE_TRANSIENT)\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n\n  private func upsertProject(\n    projectKey: String,\n    fullInstructions: String,\n    preview: String?\n  ) throws {\n    try openIfNeeded()\n    let sql = \"\"\"\n    INSERT INTO projects (project_key, instructions_full, instructions_preview, updated_at)\n    VALUES (?1, ?2, ?3, ?4)\n    ON CONFLICT(project_key) DO UPDATE SET\n      instructions_full=excluded.instructions_full,\n      instructions_preview=excluded.instructions_preview,\n      updated_at=excluded.updated_at\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    sqlite3_bind_text(stmt, 1, projectKey, -1, SQLITE_TRANSIENT)\n    sqlite3_bind_text(stmt, 2, fullInstructions, -1, SQLITE_TRANSIENT)\n    bindText(stmt, index: 3, value: preview)\n    bindDate(stmt, index: 4, value: Date())\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n\n  func delete(sessionId: String) throws {\n    try openIfNeeded()\n    let sql = \"DELETE FROM sessions WHERE session_id = ?1\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n\n  /// 批量 upsert，使用单个事务降低开销。\n  func upsertBatch(summaries: [SessionSummary]) throws {\n    guard !summaries.isEmpty else { return }\n    try openIfNeeded()\n    try exec(\"BEGIN IMMEDIATE TRANSACTION;\")\n    do {\n      for summary in summaries {\n        try upsert(\n          summary: summary,\n          project: nil,\n          fileModificationTime: nil,\n          fileSize: summary.fileSizeBytes,\n          tokenBreakdown: summary.tokenBreakdown,\n          parseError: nil\n        )\n      }\n      try exec(\"COMMIT;\")\n    } catch {\n      let _ = try? exec(\"ROLLBACK;\")\n      throw error\n    }\n  }\n\n  // MARK: - Private\n\n  private func openIfNeeded() throws {\n    if db != nil {\n      if !FileManager.default.fileExists(atPath: dbURL.path) {\n        if !missingDbLogged {\n          logger.error(\"Database file missing while connection open; recreating new store.\")\n          missingDbLogged = true\n        }\n        closeDatabase()\n      } else {\n        return\n      }\n    }\n\n    if !FileManager.default.fileExists(atPath: dbURL.path) {\n      if !missingDbLogged {\n        logger.error(\"Database file missing; auto-creating a fresh cache store.\")\n        missingDbLogged = true\n      }\n      let directory = dbURL.deletingLastPathComponent()\n      try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)\n    }\n\n    let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX\n    if sqlite3_open_v2(dbURL.path, &db, flags, nil) != SQLITE_OK {\n      throw SessionIndexSQLiteStoreError.openFailed(errorMessage)\n    }\n    missingDbLogged = false\n    try applyPragmas()\n    try createSchema()\n  }\n\n  private func closeDatabase() {\n    if let db {\n      sqlite3_close(db)\n    }\n    db = nil\n  }\n\n  private func applyPragmas() throws {\n    try exec(\"PRAGMA journal_mode=WAL;\")\n    try exec(\"PRAGMA synchronous=NORMAL;\")\n    try exec(\"PRAGMA foreign_keys=ON;\")\n    try exec(\"PRAGMA temp_store=MEMORY;\")\n    try exec(\"PRAGMA cache_size=-2000;\") // ~2MB page cache\n    try exec(\"PRAGMA busy_timeout=5000;\")\n  }\n\n  private func predicate(\n    for scope: OverviewAggregateScope?\n  ) -> (clause: String, binder: (OpaquePointer?, Int32) -> Int32) {\n    guard let scope else {\n      return (\"\", { _, idx in idx })\n    }\n    let dateColumn: String\n    switch scope.dateDimension {\n    case .created:\n      dateColumn = \"started_at\"\n    case .updated:\n      dateColumn = \"COALESCE(last_updated_at, started_at)\"\n    }\n\n    var components: [String] = []\n    components.append(\"\\(dateColumn) >= ?\")\n    components.append(\"\\(dateColumn) <= ?\")\n\n    let projects = Array(scope.projectIds ?? [])\n    if !projects.isEmpty {\n      let placeholders = Array(repeating: \"?\", count: projects.count).joined(separator: \",\")\n      components.append(\"project IN (\\(placeholders))\")\n    }\n\n    let clause = components.joined(separator: \" AND \")\n    let start = scope.start.timeIntervalSince1970\n    let end = scope.end.timeIntervalSince1970\n\n    let binder: (OpaquePointer?, Int32) -> Int32 = { stmt, startIndex in\n      var idx = startIndex\n      sqlite3_bind_double(stmt, idx, start)\n      idx += 1\n      sqlite3_bind_double(stmt, idx, end)\n      idx += 1\n      if !projects.isEmpty {\n        for project in projects {\n          sqlite3_bind_text(stmt, idx, project, -1, SQLITE_TRANSIENT)\n          idx += 1\n        }\n      }\n      return idx\n    }\n    return (clause, binder)\n  }\n\n  private func createSchema() throws {\n    let createSQL = \"\"\"\n    CREATE TABLE IF NOT EXISTS meta (\n      key TEXT PRIMARY KEY,\n      last_full_index_at REAL,\n      session_count INTEGER\n    );\n    CREATE TABLE IF NOT EXISTS sessions (\n      session_id TEXT PRIMARY KEY,\n      file_path TEXT NOT NULL,\n      file_mtime REAL,\n      file_size INTEGER,\n      schema_version INTEGER NOT NULL,\n      parse_error TEXT,\n      project TEXT,\n      source TEXT NOT NULL,\n      source_host TEXT,\n      started_at REAL NOT NULL,\n      ended_at REAL,\n      last_updated_at REAL,\n      active_duration REAL,\n      cli_version TEXT NOT NULL,\n      cwd TEXT NOT NULL,\n      originator TEXT NOT NULL,\n      instructions TEXT,\n      model TEXT,\n      approval_policy TEXT,\n      user_message_count INTEGER NOT NULL,\n      assistant_message_count INTEGER NOT NULL,\n      tool_invocation_count INTEGER NOT NULL,\n      reasoning_count INTEGER NOT NULL,\n      response_counts_json TEXT,\n      turn_context_count INTEGER NOT NULL,\n      tokens_input INTEGER,\n      tokens_output INTEGER,\n      tokens_cache_read INTEGER,\n      tokens_cache_creation INTEGER,\n      tokens_total INTEGER,\n      event_count INTEGER NOT NULL,\n      line_count INTEGER NOT NULL,\n      remote_path TEXT,\n      user_title TEXT,\n      user_comment TEXT,\n      task_id TEXT,\n      has_terminal INTEGER,\n      has_review INTEGER,\n      payload BLOB NOT NULL,\n      parse_level TEXT DEFAULT 'metadata',\n      parsed_at REAL\n    );\n    CREATE TABLE IF NOT EXISTS projects (\n      project_key TEXT PRIMARY KEY,\n      instructions_full TEXT,\n      instructions_preview TEXT,\n      updated_at REAL\n    );\n    CREATE TABLE IF NOT EXISTS timeline_previews (\n      session_id TEXT NOT NULL,\n      turn_id TEXT NOT NULL,\n      turn_index INTEGER NOT NULL,\n      timestamp REAL NOT NULL,\n      user_preview TEXT,\n      outputs_preview TEXT,\n      output_count INTEGER,\n      has_tool_calls INTEGER,\n      has_thinking INTEGER,\n      file_mtime REAL,\n      file_size INTEGER,\n      PRIMARY KEY (session_id, turn_id)\n    );\n    \"\"\"\n    try exec(createSQL)\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project);\")\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(last_updated_at);\")\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at);\")\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_sessions_source ON sessions(source);\")\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_sessions_parse_level ON sessions(parse_level);\")\n    try exec(\"CREATE INDEX IF NOT EXISTS idx_timeline_previews_session ON timeline_previews(session_id);\")\n  }\n\n  @discardableResult\n  private func exec(_ sql: String) throws -> Int32 {\n    var errorMessagePointer: UnsafeMutablePointer<Int8>?\n    let code = sqlite3_exec(db, sql, nil, nil, &errorMessagePointer)\n    if let errorMessagePointer {\n      let message = String(cString: errorMessagePointer)\n      sqlite3_free(errorMessagePointer)\n      if code != SQLITE_OK {\n        throw SessionIndexSQLiteStoreError.stepFailed(message)\n      }\n    } else if code != SQLITE_OK {\n      throw SessionIndexSQLiteStoreError.stepFailed(\"Unknown SQLite error\")\n    }\n    return code\n  }\n\n  // MARK: - Instructions & project key helpers\n\n  static func normalizeInstructions(_ text: String?) -> String? {\n    guard let text else { return nil }\n    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)\n    return trimmed.isEmpty ? nil : trimmed\n  }\n\n  static func instructionsPreview(from text: String?) -> String? {\n    guard let text else { return nil }\n    let collapsed = text.components(separatedBy: .whitespacesAndNewlines).filter { !$0.isEmpty }.joined(separator: \" \")\n    guard !collapsed.isEmpty else { return nil }\n    if collapsed.count <= instructionsPreviewLimit { return collapsed }\n    let idx = collapsed.index(collapsed.startIndex, offsetBy: instructionsPreviewLimit)\n    return String(collapsed[..<idx])\n  }\n\n  static func resolveProjectKey(projectId: String?, cwd: String?) -> String? {\n    if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      return projectId.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n    guard let cwd else { return nil }\n    return makeCwdHash(from: cwd)\n  }\n\n  static func candidateProjectKeys(projectId: String?, cwd: String?) -> [String] {\n    var keys: [String] = []\n    if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      keys.append(projectId.trimmingCharacters(in: .whitespacesAndNewlines))\n    }\n    if let cwd, let cwdHash = makeCwdHash(from: cwd) {\n      if !keys.contains(cwdHash) { keys.append(cwdHash) }\n    }\n    return keys\n  }\n\n  private static func makeCwdHash(from cwd: String) -> String? {\n    let expanded = (cwd as NSString).expandingTildeInPath\n    let canonical = URL(fileURLWithPath: expanded).standardizedFileURL.path\n    guard let data = canonical.data(using: .utf8) else { return nil }\n    let digest = SHA256.hash(data: data)\n    return digest.map { String(format: \"%02x\", $0) }.joined()\n  }\n\n  private var errorMessage: String {\n    if let cString = sqlite3_errmsg(db) {\n      return String(cString: cString)\n    }\n    return \"Unknown SQLite error\"\n  }\n\n  // MARK: - Binding helpers\n\n  private func bindText(_ stmt: OpaquePointer?, index: Int32, value: String?) {\n    if let value {\n      sqlite3_bind_text(stmt, index, value, -1, SQLITE_TRANSIENT)\n    } else {\n      sqlite3_bind_null(stmt, index)\n    }\n  }\n\n  private func bindInt(_ stmt: OpaquePointer?, index: Int32, value: Int?) {\n    if let value {\n      sqlite3_bind_int(stmt, index, Int32(value))\n    } else {\n      sqlite3_bind_null(stmt, index)\n    }\n  }\n\n  private func bindInt64(_ stmt: OpaquePointer?, index: Int32, value: Int64?) {\n    if let value {\n      sqlite3_bind_int64(stmt, index, value)\n    } else {\n      sqlite3_bind_null(stmt, index)\n    }\n  }\n\n  private func bindDouble(_ stmt: OpaquePointer?, index: Int32, value: TimeInterval?) {\n    if let value {\n      sqlite3_bind_double(stmt, index, value)\n    } else {\n      sqlite3_bind_null(stmt, index)\n    }\n  }\n\n  private func bindDate(_ stmt: OpaquePointer?, index: Int32, value: Date?) {\n    if let value {\n      sqlite3_bind_double(stmt, index, value.timeIntervalSince1970)\n    } else {\n      sqlite3_bind_null(stmt, index)\n    }\n  }\n\n  private func bindData(_ stmt: OpaquePointer?, index: Int32, data: Data) {\n    _ = data.withUnsafeBytes { ptr in\n      sqlite3_bind_blob(stmt, index, ptr.baseAddress, Int32(data.count), SQLITE_TRANSIENT)\n    }\n  }\n\n  // MARK: - Column helpers\n\n  private func columnText(_ stmt: OpaquePointer?, index: Int32) -> String? {\n    guard let cString = sqlite3_column_text(stmt, index) else { return nil }\n    return String(cString: cString)\n  }\n\n  private func columnData(_ stmt: OpaquePointer?, index: Int32) -> Data? {\n    guard let bytes = sqlite3_column_blob(stmt, index) else { return nil }\n    let length = Int(sqlite3_column_bytes(stmt, index))\n    return Data(bytes: bytes, count: length)\n  }\n\n  private func columnDate(_ stmt: OpaquePointer?, index: Int32) -> Date? {\n    let value = sqlite3_column_double(stmt, index)\n    if value == 0 { return nil }\n    return Date(timeIntervalSince1970: value)\n  }\n\n  private func columnInt64(_ stmt: OpaquePointer?, index: Int32) -> Int64? {\n    let value = sqlite3_column_int64(stmt, index)\n    if sqlite3_column_type(stmt, index) == SQLITE_NULL { return nil }\n    return value\n  }\n\n  private func tokenBreakdownFromColumns(_ stmt: OpaquePointer?, startIndex: Int32) -> SessionTokenBreakdown? {\n    let input = columnInt64(stmt, index: startIndex).map(Int.init)\n    let output = columnInt64(stmt, index: startIndex + 1).map(Int.init)\n    let cacheRead = columnInt64(stmt, index: startIndex + 2).map(Int.init)\n    let cacheCreation = columnInt64(stmt, index: startIndex + 3).map(Int.init)\n    if input == nil && output == nil && cacheRead == nil && cacheCreation == nil {\n      return nil\n    }\n    return SessionTokenBreakdown(\n      input: input ?? 0,\n      output: output ?? 0,\n      cacheRead: cacheRead ?? 0,\n      cacheCreation: cacheCreation ?? 0)\n  }\n\n  // MARK: - Source encoding helpers\n\n  private func encode(source: SessionSource) -> (kind: String, host: String?) {\n    switch source {\n    case .codexLocal:\n      return (\"codexLocal\", nil)\n    case .claudeLocal:\n      return (\"claudeLocal\", nil)\n    case .geminiLocal:\n      return (\"geminiLocal\", nil)\n    case .codexRemote(let host):\n      return (\"codexRemote\", host)\n    case .claudeRemote(let host):\n      return (\"claudeRemote\", host)\n    case .geminiRemote(let host):\n      return (\"geminiRemote\", host)\n    }\n  }\n\n  private func decodeKind(_ value: String) -> SessionSource.Kind? {\n    switch value {\n    case \"codexLocal\", \"codexRemote\":\n      return .codex\n    case \"claudeLocal\", \"claudeRemote\":\n      return .claude\n    case \"geminiLocal\", \"geminiRemote\":\n      return .gemini\n    default:\n      return nil\n    }\n  }\n\n  private func distinctSources() throws -> [SessionSource.Kind] {\n    let sql = \"SELECT DISTINCT source FROM sessions\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    var kinds: [SessionSource.Kind] = []\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      if let text = columnText(stmt, index: 0), let kind = decodeKind(text) {\n        kinds.append(kind)\n      }\n    }\n    return Array(Set(kinds)).sorted { $0.rawValue < $1.rawValue }\n  }\n\n  private func fetchTotals(scope: OverviewAggregateScope?) throws -> (sessions: Int, tokens: Int, duration: TimeInterval, userMessages: Int, assistantMessages: Int, toolInvocations: Int) {\n    let predicate = predicate(for: scope)\n    let whereClause = predicate.clause.isEmpty ? \"\" : \"WHERE \\(predicate.clause)\"\n    let sql = \"\"\"\n    SELECT\n      COUNT(*) AS c,\n      SUM(COALESCE(tokens_total, 0)) AS tokens,\n      SUM(\n        COALESCE(\n          active_duration,\n          CASE\n            WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at)\n            WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at)\n            ELSE 0\n          END\n        )\n      ) AS duration,\n      SUM(user_message_count) AS user_messages,\n      SUM(assistant_message_count) AS assistant_messages,\n      SUM(tool_invocation_count) AS tool_invocations\n    FROM sessions\n    \\(whereClause)\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    _ = predicate.binder(stmt, 1)\n    guard sqlite3_step(stmt) == SQLITE_ROW else {\n      throw SessionIndexSQLiteStoreError.decodeFailed(\"Failed to read totals\")\n    }\n    let sessions = Int(sqlite3_column_int(stmt, 0))\n    let tokens = Int(sqlite3_column_int64(stmt, 1))\n    let duration = sqlite3_column_double(stmt, 2)\n    let userMessages = Int(sqlite3_column_int64(stmt, 3))\n    let assistantMessages = Int(sqlite3_column_int64(stmt, 4))\n    let toolInvocations = Int(sqlite3_column_int64(stmt, 5))\n    return (sessions, tokens, duration, userMessages, assistantMessages, toolInvocations)\n  }\n\n  private func fetchSourceAggregates(scope: OverviewAggregateScope?) throws -> [OverviewSourceAggregate] {\n    let predicate = predicate(for: scope)\n    let whereClause = predicate.clause.isEmpty ? \"\" : \"WHERE \\(predicate.clause)\"\n    let sql = \"\"\"\n    SELECT\n      source,\n      COUNT(*) AS c,\n      SUM(COALESCE(tokens_total, 0)) AS tokens,\n      SUM(\n        COALESCE(\n          active_duration,\n          CASE\n            WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at)\n            WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at)\n            ELSE 0\n          END\n        )\n      ) AS duration,\n      SUM(user_message_count) AS user_messages,\n      SUM(assistant_message_count) AS assistant_messages,\n      SUM(tool_invocation_count) AS tool_invocations\n    FROM sessions\n    \\(whereClause)\n    GROUP BY source\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    _ = predicate.binder(stmt, 1)\n    var results: [OverviewSourceAggregate] = []\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard\n        let kindText = columnText(stmt, index: 0),\n        let kind = decodeKind(kindText)\n      else { continue }\n      let count = Int(sqlite3_column_int64(stmt, 1))\n      let tokens = Int(sqlite3_column_int64(stmt, 2))\n      let duration = sqlite3_column_double(stmt, 3)\n      let userMessages = Int(sqlite3_column_int64(stmt, 4))\n      let assistantMessages = Int(sqlite3_column_int64(stmt, 5))\n      let toolInvocations = Int(sqlite3_column_int64(stmt, 6))\n      results.append(\n        OverviewSourceAggregate(\n          kind: kind,\n          sessionCount: count,\n          totalTokens: tokens,\n          totalDuration: duration,\n          userMessages: userMessages,\n          assistantMessages: assistantMessages,\n          toolInvocations: toolInvocations\n        )\n      )\n    }\n    return results\n  }\n\n  private func fetchDailyAggregates(scope: OverviewAggregateScope?) throws -> [OverviewDailyPoint] {\n    let predicate = predicate(for: scope)\n    let dateColumn = scope?.dateDimension == .updated ? \"COALESCE(last_updated_at, started_at)\" : \"started_at\"\n    let whereClause = predicate.clause.isEmpty ? \"\" : \"WHERE \\(predicate.clause)\"\n    let sql = \"\"\"\n    SELECT\n      strftime('%Y-%m-%d', \\(dateColumn), 'unixepoch', 'localtime') AS day,\n      source,\n      COUNT(*) AS c,\n      SUM(COALESCE(tokens_total, 0)) AS tokens,\n      SUM(\n        COALESCE(\n          active_duration,\n          CASE\n            WHEN ended_at IS NOT NULL THEN MAX(0, ended_at - started_at)\n            WHEN last_updated_at IS NOT NULL THEN MAX(0, last_updated_at - started_at)\n            ELSE 0\n          END\n        )\n      ) AS duration\n    FROM sessions\n    \\(whereClause)\n    GROUP BY day, source\n    ORDER BY day ASC\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    _ = predicate.binder(stmt, 1)\n\n    var results: [OverviewDailyPoint] = []\n    let df = DateFormatter()\n    df.dateFormat = \"yyyy-MM-dd\"\n    df.locale = Locale(identifier: \"en_US_POSIX\")\n\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard\n        let dayText = columnText(stmt, index: 0),\n        let dayDate = df.date(from: dayText),\n        let kindText = columnText(stmt, index: 1),\n        let kind = decodeKind(kindText)\n      else { continue }\n      let count = Int(sqlite3_column_int64(stmt, 2))\n      let tokens = Int(sqlite3_column_int64(stmt, 3))\n      let duration = sqlite3_column_double(stmt, 4)\n      results.append(\n        OverviewDailyPoint(\n          day: dayDate,\n          kind: kind,\n          sessionCount: count,\n          totalTokens: tokens,\n          totalDuration: duration\n        )\n      )\n    }\n    return results\n  }\n\n  private func countSessions() throws -> Int {\n    let sql = \"SELECT COUNT(*) FROM sessions\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    guard sqlite3_step(stmt) == SQLITE_ROW else {\n      throw SessionIndexSQLiteStoreError.decodeFailed(\"Failed to read session count\")\n    }\n    return Int(sqlite3_column_int64(stmt, 0))\n  }\n\n  private func parseLevelRank(_ level: String?) -> Int {\n    switch level {\n    case \"enriched\": return 3\n    case \"full\": return 2\n    case \"metadata\": return 1\n    default: return 0\n    }\n  }\n}\n\n// MARK: - Cached summaries by source\n\nextension SessionIndexSQLiteStore {\n  /// Fetch cached summaries for given source kinds without touching the filesystem.\n  func fetchSummaries(\n    kinds: [SessionSource.Kind],\n    includeRemote: Bool,\n    dateColumn: String?,\n    dateRange: (Date, Date)?,\n    projectIds: Set<String>?\n  ) throws -> [SessionSummary] {\n    try openIfNeeded()\n    let sources = sourceStrings(for: kinds, includeRemote: includeRemote)\n    guard !sources.isEmpty else { return [] }\n    let placeholders = sources.map { _ in \"?\" }.joined(separator: \",\")\n    var whereParts: [String] = [\"source IN (\\(placeholders))\"]\n    if let dateColumn, dateRange != nil {\n      whereParts.append(\"\\(dateColumn) >= ?\")\n      whereParts.append(\"\\(dateColumn) <= ?\")\n    }\n    if let projectIds, !projectIds.isEmpty {\n      let projectPlaceholders = projectIds.map { _ in \"?\" }.joined(separator: \",\")\n      whereParts.append(\"project IN (\\(projectPlaceholders))\")\n    }\n    let whereClause = whereParts.joined(separator: \" AND \")\n    let sql = \"SELECT payload FROM sessions WHERE \\(whereClause)\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    var idx: Int32 = 1\n    for source in sources {\n      sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT)\n      idx += 1\n    }\n    if let dateRange {\n      sqlite3_bind_double(stmt, idx, dateRange.0.timeIntervalSince1970)\n      idx += 1\n      sqlite3_bind_double(stmt, idx, dateRange.1.timeIntervalSince1970)\n      idx += 1\n    }\n    if let projectIds {\n      for pid in projectIds {\n        sqlite3_bind_text(stmt, idx, pid, -1, SQLITE_TRANSIENT)\n        idx += 1\n      }\n    }\n    var result: [SessionSummary] = []\n    let decoder = JSONDecoder()\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard let payload = columnData(stmt, index: 0) else { continue }\n      if let summary = try? decoder.decode(SessionSummary.self, from: payload) {\n        result.append(summary)\n      }\n    }\n    let withLevels = result\n    let kindLabel = kinds.map { \"\\($0)\" }.joined(separator: \",\")\n    logger.info(\"fetchSummaries cache kind=\\(kindLabel, privacy: .public) count=\\(withLevels.count, privacy: .public) includeRemote=\\(includeRemote, privacy: .public)\")\n    return withLevels\n  }\n\n  /// Fetch cached records (payload + metadata) for given source kinds to build scoped caches.\n  func fetchRecords(\n    kinds: [SessionSource.Kind],\n    includeRemote: Bool,\n    dateColumn: String?,\n    dateRange: (Date, Date)?,\n    projectIds: Set<String>?\n  ) throws -> [SessionIndexRecord] {\n    try openIfNeeded()\n    let sources = sourceStrings(for: kinds, includeRemote: includeRemote)\n    guard !sources.isEmpty else { return [] }\n    let placeholders = sources.map { _ in \"?\" }.joined(separator: \",\")\n    var whereParts: [String] = [\"source IN (\\(placeholders))\"]\n    if let dateColumn, dateRange != nil {\n      whereParts.append(\"\\(dateColumn) >= ?\")\n      whereParts.append(\"\\(dateColumn) <= ?\")\n    }\n    if let projectIds, !projectIds.isEmpty {\n      let projectPlaceholders = projectIds.map { _ in \"?\" }.joined(separator: \",\")\n      whereParts.append(\"project IN (\\(projectPlaceholders))\")\n    }\n    let whereClause = whereParts.joined(separator: \" AND \")\n    let sql = \"\"\"\n    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\n    FROM sessions\n    WHERE \\(whereClause)\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    var idx: Int32 = 1\n    for source in sources {\n      sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT)\n      idx += 1\n    }\n    if let dateRange {\n      sqlite3_bind_double(stmt, idx, dateRange.0.timeIntervalSince1970)\n      idx += 1\n      sqlite3_bind_double(stmt, idx, dateRange.1.timeIntervalSince1970)\n      idx += 1\n    }\n    if let projectIds {\n      for pid in projectIds {\n        sqlite3_bind_text(stmt, idx, pid, -1, SQLITE_TRANSIENT)\n        idx += 1\n      }\n    }\n\n    var records: [SessionIndexRecord] = []\n    let decoder = JSONDecoder()\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard let payload = columnData(stmt, index: 0) else { continue }\n      guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue }\n      let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path\n      let fileMtime = columnDate(stmt, index: 2)\n      let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) }\n      let project = columnText(stmt, index: 4)\n      let schemaVersion = Int(sqlite3_column_int(stmt, 5))\n      let parseError = columnText(stmt, index: 6)\n      let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7)\n      summary = summary.withTokenBreakdownFallback(tokenBreakdown)\n      let parseLevel = columnText(stmt, index: 11)\n      let parsedAt = columnDate(stmt, index: 12)\n      records.append(\n        SessionIndexRecord(\n          summary: summary,\n          filePath: filePath,\n          fileModificationTime: fileMtime,\n          fileSize: fileSize,\n          project: project,\n          schemaVersion: schemaVersion,\n          parseError: parseError,\n          tokenBreakdown: tokenBreakdown,\n          parseLevel: parseLevel,\n          parsedAt: parsedAt\n        )\n      )\n    }\n    return records\n  }\n\n  /// Fetch file paths for a specific date without loading full payloads (optimized for single-day queries).\n  /// Returns [(filePath, lastUpdatedAt, fileModificationTime, fileSize)].\n  func fetchFilePathsForDate(\n    kinds: [SessionSource.Kind],\n    includeRemote: Bool,\n    dateColumn: String,\n    targetDate: Date\n  ) throws -> [(filePath: String, lastUpdatedAt: Date?, fileMtime: Date?, fileSize: UInt64?)] {\n    try openIfNeeded()\n    let sources = sourceStrings(for: kinds, includeRemote: includeRemote)\n    guard !sources.isEmpty else { return [] }\n    let placeholders = sources.map { _ in \"?\" }.joined(separator: \",\")\n\n    // Use SQLite date() function to filter by calendar day in UTC\n    let sql = \"\"\"\n    SELECT file_path, last_updated_at, file_mtime, file_size\n    FROM sessions\n    WHERE source IN (\\(placeholders))\n      AND date(\\(dateColumn), 'unixepoch') = date(?1, 'unixepoch')\n    \"\"\"\n\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    var idx: Int32 = 1\n    for source in sources {\n      sqlite3_bind_text(stmt, idx, source, -1, SQLITE_TRANSIENT)\n      idx += 1\n    }\n    sqlite3_bind_double(stmt, idx, targetDate.timeIntervalSince1970)\n\n    var result: [(String, Date?, Date?, UInt64?)] = []\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      let filePath = columnText(stmt, index: 0) ?? \"\"\n      let lastUpdated = columnDate(stmt, index: 1)\n      let fileMtime = columnDate(stmt, index: 2)\n      let fileSize = columnInt64(stmt, index: 3).map { UInt64($0) }\n      result.append((filePath, lastUpdated, fileMtime, fileSize))\n    }\n    return result\n  }\n\n  /// Fetch cached records for a specific set of session IDs.\n  func fetchRecords(sessionIds: Set<String>) throws -> [SessionIndexRecord] {\n    try openIfNeeded()\n    guard !sessionIds.isEmpty else { return [] }\n    let placeholders = sessionIds.map { _ in \"?\" }.joined(separator: \",\")\n    let sql = \"\"\"\n    SELECT payload, file_path, file_mtime, file_size, project, schema_version, parse_error, tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation\n    FROM sessions\n    WHERE session_id IN (\\(placeholders))\n    \"\"\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n    var idx: Int32 = 1\n    for id in sessionIds {\n      sqlite3_bind_text(stmt, idx, id, -1, SQLITE_TRANSIENT)\n      idx += 1\n    }\n\n    var records: [SessionIndexRecord] = []\n    let decoder = JSONDecoder()\n    while sqlite3_step(stmt) == SQLITE_ROW {\n      guard let payload = columnData(stmt, index: 0) else { continue }\n      guard var summary = try? decoder.decode(SessionSummary.self, from: payload) else { continue }\n      let filePath = columnText(stmt, index: 1) ?? summary.fileURL.path\n      let fileMtime = columnDate(stmt, index: 2)\n      let fileSize = columnInt64(stmt, index: 3).flatMap { UInt64($0) }\n      let project = columnText(stmt, index: 4)\n      let schemaVersion = Int(sqlite3_column_int(stmt, 5))\n      let parseError = columnText(stmt, index: 6)\n      let tokenBreakdown = tokenBreakdownFromColumns(stmt, startIndex: 7)\n      summary = summary.withTokenBreakdownFallback(tokenBreakdown)\n      let parseLevel = columnText(stmt, index: 11)\n      let parsedAt = columnDate(stmt, index: 12)\n      records.append(\n        SessionIndexRecord(\n          summary: summary,\n          filePath: filePath,\n          fileModificationTime: fileMtime,\n          fileSize: fileSize,\n          project: project,\n          schemaVersion: schemaVersion,\n          parseError: parseError,\n          tokenBreakdown: tokenBreakdown,\n          parseLevel: parseLevel,\n          parsedAt: parsedAt\n        )\n      )\n    }\n    return records\n  }\n\n  private func sourceStrings(for kinds: [SessionSource.Kind], includeRemote: Bool) -> [String] {\n    var sources: [String] = []\n    for kind in kinds {\n      switch kind {\n      case .codex:\n        sources.append(\"codexLocal\")\n        if includeRemote { sources.append(\"codexRemote\") }\n      case .claude:\n        sources.append(\"claudeLocal\")\n        if includeRemote { sources.append(\"claudeRemote\") }\n      case .gemini:\n        sources.append(\"geminiLocal\")\n        if includeRemote { sources.append(\"geminiRemote\") }\n      }\n    }\n    return sources\n  }\n\n  // MARK: - Timeline Previews\n\n  /// Fetch timeline previews for a session. Returns nil if cache is invalid (mtime mismatch).\n  func fetchTimelinePreviews(\n    sessionId: String,\n    fileModificationTime: Date?,\n    fileSize: UInt64?\n  ) throws -> [ConversationTurnPreview]? {\n    try openIfNeeded()\n\n    // First check if we have any previews for this session\n    let countSQL = \"SELECT COUNT(*), MIN(file_mtime) FROM timeline_previews WHERE session_id = ?1\"\n    var countStmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, countSQL, -1, &countStmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(countStmt) }\n\n    sqlite3_bind_text(countStmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n\n    guard sqlite3_step(countStmt) == SQLITE_ROW else {\n      return nil\n    }\n\n    let count = Int(sqlite3_column_int(countStmt, 0))\n    if count == 0 {\n      return nil  // No previews cached\n    }\n\n    // Check mtime validity\n    if let fileModificationTime {\n      let cachedMtime = sqlite3_column_double(countStmt, 1)\n      let mtimeInterval = fileModificationTime.timeIntervalSince1970\n      if abs(cachedMtime - mtimeInterval) > 1.0 {\n        // Cache is stale, return nil to trigger re-caching\n        return nil\n      }\n    }\n\n    // Fetch all previews for this session\n    let fetchSQL = \"\"\"\n      SELECT turn_id, turn_index, timestamp, user_preview, outputs_preview,\n             output_count, has_tool_calls, has_thinking\n      FROM timeline_previews\n      WHERE session_id = ?1\n      ORDER BY turn_index ASC\n    \"\"\"\n\n    var fetchStmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, fetchSQL, -1, &fetchStmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(fetchStmt) }\n\n    sqlite3_bind_text(fetchStmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n\n    var previews: [ConversationTurnPreview] = []\n    while sqlite3_step(fetchStmt) == SQLITE_ROW {\n      guard let turnId = columnText(fetchStmt, index: 0) else { continue }\n\n      let turnIndex = Int(sqlite3_column_int(fetchStmt, 1))\n      let timestamp = Date(timeIntervalSince1970: sqlite3_column_double(fetchStmt, 2))\n      let userPreview = columnText(fetchStmt, index: 3)\n      let outputsPreview = columnText(fetchStmt, index: 4)\n      let outputCount = Int(sqlite3_column_int(fetchStmt, 5))\n      let hasToolCalls = sqlite3_column_int(fetchStmt, 6) != 0\n      let hasThinking = sqlite3_column_int(fetchStmt, 7) != 0\n\n      let preview = ConversationTurnPreview(\n        id: turnId,\n        sessionId: sessionId,\n        turnIndex: turnIndex,\n        timestamp: timestamp,\n        userPreview: userPreview,\n        outputsPreview: outputsPreview,\n        outputCount: outputCount,\n        hasToolCalls: hasToolCalls,\n        hasThinking: hasThinking\n      )\n      previews.append(preview)\n    }\n\n    return previews\n  }\n\n  /// Upsert timeline previews for a session. Replaces all existing previews for the session.\n  func upsertTimelinePreviews(\n    _ previews: [ConversationTurnPreview],\n    sessionId: String,\n    fileModificationTime: Date,\n    fileSize: UInt64?\n  ) throws {\n    try openIfNeeded()\n\n    // Delete existing previews for this session\n    let deleteSQL = \"DELETE FROM timeline_previews WHERE session_id = ?1\"\n    var deleteStmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, deleteSQL, -1, &deleteStmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(deleteStmt) }\n\n    sqlite3_bind_text(deleteStmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n    guard sqlite3_step(deleteStmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n\n    // Insert new previews\n    let insertSQL = \"\"\"\n      INSERT INTO timeline_previews (\n        session_id, turn_id, turn_index, timestamp, user_preview, outputs_preview,\n        output_count, has_tool_calls, has_thinking, file_mtime, file_size\n      ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)\n    \"\"\"\n\n    var insertStmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, insertSQL, -1, &insertStmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(insertStmt) }\n\n    for preview in previews {\n      sqlite3_bind_text(insertStmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n      sqlite3_bind_text(insertStmt, 2, preview.id, -1, SQLITE_TRANSIENT)\n      bindInt(insertStmt, index: 3, value: preview.turnIndex)\n      bindDate(insertStmt, index: 4, value: preview.timestamp)\n      bindText(insertStmt, index: 5, value: preview.userPreview)\n      bindText(insertStmt, index: 6, value: preview.outputsPreview)\n      bindInt(insertStmt, index: 7, value: preview.outputCount)\n      sqlite3_bind_int(insertStmt, 8, preview.hasToolCalls ? 1 : 0)\n      sqlite3_bind_int(insertStmt, 9, preview.hasThinking ? 1 : 0)\n      bindDate(insertStmt, index: 10, value: fileModificationTime)\n      bindInt64(insertStmt, index: 11, value: fileSize.map { Int64($0) })\n\n      guard sqlite3_step(insertStmt) == SQLITE_DONE else {\n        throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n      }\n\n      sqlite3_reset(insertStmt)\n    }\n  }\n\n  /// Delete timeline previews for a session (e.g., when file is deleted or modified)\n  func deleteTimelinePreviews(sessionId: String) throws {\n    try openIfNeeded()\n\n    let sql = \"DELETE FROM timeline_previews WHERE session_id = ?1\"\n    var stmt: OpaquePointer?\n    guard sqlite3_prepare_v2(db, sql, -1, &stmt, nil) == SQLITE_OK else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n    defer { sqlite3_finalize(stmt) }\n\n    sqlite3_bind_text(stmt, 1, sessionId, -1, SQLITE_TRANSIENT)\n    guard sqlite3_step(stmt) == SQLITE_DONE else {\n      throw SessionIndexSQLiteStoreError.stepFailed(errorMessage)\n    }\n  }\n}\n"
  },
  {
    "path": "services/SessionIndexer.swift",
    "content": "import Foundation\nimport OSLog\n\nactor SessionIndexer {\n  private let fileManager: FileManager\n  private let decoder: JSONDecoder\n  private let cache = NSCache<NSURL, CacheEntry>()\n  private let sqliteStore: SessionIndexSQLiteStore\n  private let logger = Logger(subsystem: \"io.umate.codmate\", category: \"SessionIndexer\")\n  /// Prevent concurrent refresh loops (scope-level gate)\n  private var isRefreshing = false\n  /// Tracks files whose token total is confirmed zero for a given mtime to avoid repeated rescans.\n  private var zeroTokenStable: [String: TimeInterval?] = [:]\n  /// Avoid global mutable, non-Sendable formatter; create locally when needed\n  nonisolated private static func makeTailTimestampFormatter() -> ISO8601DateFormatter {\n    let f = ISO8601DateFormatter()\n    f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n    return f\n  }\n\n  private final class CacheEntry {\n    let modificationDate: Date?\n    let summary: SessionSummary\n\n    init(modificationDate: Date?, summary: SessionSummary) {\n      self.modificationDate = modificationDate\n      self.summary = summary\n    }\n  }\n\n  init(\n    fileManager: FileManager = .default,\n    sqliteStore: SessionIndexSQLiteStore = SessionIndexSQLiteStore()\n  ) {\n    self.fileManager = fileManager\n    self.sqliteStore = sqliteStore\n    decoder = FlexibleDecoders.iso8601Flexible()\n  }\n\n  private func ensureCacheAvailable() async throws -> SessionIndexMeta {\n    return try await sqliteStore.fetchMeta()\n  }\n\n  func refreshSessions(\n    root: URL,\n    scope: SessionLoadScope,\n    dateRange: (Date, Date)? = nil,\n    projectIds: Set<String>? = nil,\n    projectDirectories: [String]? = nil,\n    dateDimension: DateDimension = .updated,\n    forceFilesystemScan: Bool = false,\n    ignoredPaths: [String] = []\n  ) async throws -> [SessionSummary] {\n    let meta = try await ensureCacheAvailable()\n    let preferFullInitialParse = meta.sessionCount == 0\n    // First, try cached meta fast path so repeated .all refreshes don't re-enumerate\n    if !forceFilesystemScan, case .all = scope, let cached = try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths) {\n      return cached\n    }\n\n    guard !isRefreshing else {\n      logger.debug(\"Refresh skipped: already in progress for scope=\\(String(describing: scope), privacy: .public)\")\n      guard !forceFilesystemScan else { return [] }\n      // When a refresh is already running, still try to surface cached data for scope\n      if case .all = scope, let cached = try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths) {\n        return cached\n      }\n      let fallbackRange: (Date, Date)? = {\n        if let dateRange { return dateRange }\n        let cal = Calendar.current\n        switch scope {\n        case .all:\n          return nil\n        case .today:\n          let start = cal.startOfDay(for: Date())\n          guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n          return (start, end)\n        case .day(let day):\n          let start = cal.startOfDay(for: day)\n          guard let end = cal.date(byAdding: .day, value: 1, to: start)?.addingTimeInterval(-1) else { return nil }\n          return (start, end)\n        case .month(let date):\n          guard\n            let start = cal.date(from: cal.dateComponents([.year, .month], from: date)),\n            let end = cal.date(byAdding: DateComponents(month: 1, second: -1), to: start)\n          else { return nil }\n          return (start, end)\n        }\n      }()\n      let dateColumn = dateDimension == .updated ? \"COALESCE(last_updated_at, started_at)\" : \"started_at\"\n      if var cached = try? await sqliteStore.fetchSummaries(\n        kinds: [.codex],\n        includeRemote: false,\n        dateColumn: dateColumn,\n        dateRange: fallbackRange,\n        projectIds: projectIds\n      ), !cached.isEmpty {\n        // Apply ignore rules to cached summaries\n        if !ignoredPaths.isEmpty {\n          cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: ignoredPaths) }\n        }\n        return cached\n      }\n      return []\n    }\n    isRefreshing = true\n    defer { isRefreshing = false }\n\n    // Layer 0.5: Single-day fast path using SQLite date query (optimized for Updated dimension)\n    // Directly query files by date without loading all cached records into memory\n    let dateColumn = dateDimension == .updated ? \"COALESCE(last_updated_at, started_at)\" : \"started_at\"\n    var cachedRecords: [String: SessionIndexRecord] = [:]\n\n    if dateDimension == .updated, case .day(let targetDate) = scope {\n      // Use optimized single-day query to get candidate file paths directly from SQLite\n      if let dateCandidates = try? await sqliteStore.fetchFilePathsForDate(\n        kinds: [.codex],\n        includeRemote: false,\n        dateColumn: dateColumn,\n        targetDate: targetDate\n      ), !dateCandidates.isEmpty {\n        logger.info(\"Single-day fast path: found \\(dateCandidates.count, privacy: .public) candidates for date\")\n        // Build minimal cachedRecords from file paths for change detection\n        for _ in dateCandidates {\n          // Fetch full record only if file still exists and needs processing\n          if let fullRecords = try? await sqliteStore.fetchRecords(\n            kinds: [.codex],\n            includeRemote: false,\n            dateColumn: dateColumn,\n            dateRange: (targetDate.addingTimeInterval(-86400), targetDate.addingTimeInterval(86400)),\n            projectIds: projectIds\n          ) {\n            cachedRecords = Dictionary(uniqueKeysWithValues: fullRecords.map { ($0.filePath, $0) })\n            break\n          }\n        }\n      }\n    }\n\n    // Layer 1: scoped cached records to skip SQLite fetch per file (fallback or non-single-day queries)\n    if cachedRecords.isEmpty {\n      let initialRecords = try? await sqliteStore.fetchRecords(\n        kinds: [.codex],\n        includeRemote: false,\n        dateColumn: dateRange != nil ? dateColumn : nil,\n        dateRange: dateRange,\n        projectIds: projectIds\n      )\n      if let initialRecords, !initialRecords.isEmpty {\n        cachedRecords = Dictionary(uniqueKeysWithValues: initialRecords.map { ($0.filePath, $0) })\n      } else if cachedRecords.isEmpty {\n        // Layer 1 baseline: fallback to all cached records for change detection to avoid reparsing unchanged files.\n        if let allRecords = try? await sqliteStore.fetchRecords(\n          kinds: [.codex],\n          includeRemote: false,\n          dateColumn: nil,\n          dateRange: nil,\n          projectIds: nil\n        ) {\n          cachedRecords = Dictionary(uniqueKeysWithValues: allRecords.map { ($0.filePath, $0) })\n        }\n      }\n    }\n\n    let scopedDirectories = scopedDirectoriesForRefresh(\n      root: root,\n      scope: scope,\n      dateRange: dateRange,\n      dateDimension: dateDimension,\n      cachedRecords: cachedRecords,\n      projectIds: projectIds,\n      projectDirectories: projectDirectories\n    )\n    let sessionFiles = try sessionFileURLs(\n      at: root,\n      scope: scope,\n      dateRange: dateRange,\n      dateDimension: dateDimension,\n      directories: scopedDirectories,\n      cachedRecords: cachedRecords,\n      ignoredPaths: ignoredPaths\n    )\n    logger.info(\n      \"Refreshing sessions under \\(root.path, privacy: .public) scope=\\(String(describing: scope), privacy: .public) count=\\(sessionFiles.count)\"\n    )\n    guard !sessionFiles.isEmpty else { return [] }\n\n    // Fast path: if all files have up-to-date summaries in cache/SQLite, return immediately\n    var summaries: [SessionSummary] = []\n    summaries.reserveCapacity(sessionFiles.count)\n    // URL, modificationDate, fileSize, previousParseLevel\n    var pending: [(url: URL, modificationDate: Date?, fileSize: Int?, previousParseLevel: String?)] = []\n\n    // Layer 1.5: if all files are covered by cachedRecords with matching mtime/size, short-circuit.\n    if !cachedRecords.isEmpty {\n      var allCovered = true\n      for url in sessionFiles {\n        let values = try url.resourceValues(\n          forKeys: Set<URLResourceKey>([.contentModificationDateKey, .fileSizeKey, .isRegularFileKey])\n        )\n        guard values.isRegularFile == true else { continue }\n        let mdate = values.contentModificationDate\n        let fsize = values.fileSize\n        guard let record = cachedRecords[url.path],\n              let m = record.fileModificationTime,\n              mdate == m,\n              (record.fileSize == nil || fsize == nil || record.fileSize == fsize.map { UInt64($0) })\n        else {\n          allCovered = false\n          break\n        }\n      }\n      if allCovered {\n        let fastSummaries = Array(cachedRecords.values.map { $0.summary.withParseLevel(fromString: $0.parseLevel) })\n        logger.info(\"SessionIndexer fast path: all files covered by scoped cache count=\\(fastSummaries.count, privacy: .public)\")\n        if case .all = scope {\n          try? await sqliteStore.setMeta(lastFullIndexAt: Date(), sessionCount: sessionFiles.count)\n        }\n        return fastSummaries\n      }\n    }\n\n    for url in sessionFiles {\n      let values = try url.resourceValues(\n        forKeys: Set<URLResourceKey>([.contentModificationDateKey, .fileSizeKey, .isRegularFileKey])\n      )\n      guard values.isRegularFile == true else { continue }\n      let mdate = values.contentModificationDate\n      let fsize = values.fileSize\n\n      if let record = cachedRecords[url.path] {\n         if let m = record.fileModificationTime,\n            mdate == m,\n            (record.fileSize == nil || fsize == nil || record.fileSize == fsize.map { UInt64($0) })\n         {\n           let summary = record.summary.withParseLevel(fromString: record.parseLevel)\n           // Check ignore rules against cwd\n           // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries.\n           // This allows sessions to reappear if ignore rules are removed later.\n           if shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths) {\n             continue\n           }\n           store(summary: summary, for: url as NSURL, modificationDate: mdate)\n           summaries.append(summary)\n           continue\n         }\n         // File changed, but remember previous parse level\n         pending.append((url, mdate, fsize, record.parseLevel))\n         continue\n      }\n\n      if let cached = cachedSummary(for: url as NSURL, modificationDate: mdate) {\n        // Check ignore rules against cwd\n        // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries.\n        // This allows sessions to reappear if ignore rules are removed later.\n        if shouldIgnoreSummary(cached, ignoredPaths: ignoredPaths) {\n          continue\n        }\n        if shouldRecomputeTokens(for: url.path, modificationDate: mdate, summary: cached) {\n          pending.append((url, mdate, fsize, cached.parseLevel?.rawValue))\n        } else {\n          summaries.append(cached)\n        }\n        continue\n      }\n      if cachedRecords.isEmpty {\n        // Try to get previous parse level from DB even if file changed or not in memory cache\n        let diskRecord = try? await sqliteStore.fetch(sessionId: url.deletingPathExtension().lastPathComponent)\n        let prevLevel = diskRecord?.parseLevel\n\n        if\n          let disk = try? await sqliteStore.fetch(\n            path: url.path,\n            modificationDate: mdate,\n            fileSize: fsize.flatMap { UInt64($0) })\n        {\n          // Check ignore rules against cwd\n          // Note: Cache is preserved - we filter out ignored sessions but don't delete cache entries.\n          // This allows sessions to reappear if ignore rules are removed later.\n          if shouldIgnoreSummary(disk, ignoredPaths: ignoredPaths) {\n            continue\n          }\n          if shouldRecomputeTokens(for: url.path, modificationDate: mdate, summary: disk) {\n            pending.append((url, mdate, fsize, prevLevel ?? disk.parseLevel?.rawValue))\n          } else {\n            store(summary: disk, for: url as NSURL, modificationDate: mdate)\n            summaries.append(disk)\n          }\n          continue\n        }\n        // Not in cache (or changed), but might have previous level\n        pending.append((url, mdate, fsize, prevLevel))\n        continue\n      }\n      pending.append((url, mdate, fsize, nil))\n    }\n\n    // If everything hit cache, short-circuit\n    if pending.isEmpty {\n      if case .all = scope {\n        do {\n          try await sqliteStore.setMeta(lastFullIndexAt: Date(), sessionCount: sessionFiles.count)\n          logger.info(\"SessionIndexer meta updated from cache-only path count=\\(sessionFiles.count, privacy: .public)\")\n        } catch {\n          logger.error(\"Failed to set meta: \\(error.localizedDescription, privacy: .public)\")\n        }\n      }\n      return summaries\n    }\n\n    logger.info(\n      \"Change detection: pending=\\(pending.count, privacy: .public) cacheHits=\\(summaries.count, privacy: .public) total=\\(sessionFiles.count, privacy: .public)\"\n    )\n\n    let cpuCount = ProcessInfo.processInfo.processorCount\n    let workerCount = max(2, cpuCount / 2)\n    var firstError: Error?\n    summaries.reserveCapacity(sessionFiles.count)\n\n    await withTaskGroup(of: Result<SessionSummary?, Error>.self) { group in\n      var iterator = pending.makeIterator()\n\n      func addNextTasks(_ n: Int) {\n        for _ in 0..<n {\n              guard let item = iterator.next() else { return }\n          group.addTask { [weak self] in\n            guard let self else { return .success(nil) }\n            do {\n              let (url, modificationDate, fileSize, prevLevel) = item\n\n              var builder = SessionSummaryBuilder()\n              if let size = fileSize { builder.setFileSize(UInt64(size)) }\n              \n              // If previously full/enriched, force full parse to capture new content correctly\n              // Otherwise use fast parse for speed\n              let useFullParse = preferFullInitialParse || prevLevel == \"full\" || prevLevel == \"enriched\"\n              \n              // Seed updatedAt by fs metadata to avoid full scan for recency\n              if let lastUpdated = self.lastUpdatedTimestamp(\n                for: url, modificationDate: modificationDate)\n              {\n                builder.seedLastUpdated(lastUpdated)\n              }\n              \n              let summary: SessionSummary?\n              if useFullParse {\n                 // Full parse logic\n                 summary = try await self.buildSummaryFull(for: url, builder: &builder)\n              } else {\n                 // Fast parse logic\n                 summary = try await self.buildSummaryFast(for: url, builder: &builder)\n              }\n              \n              guard let result = summary else { return .success(nil) }\n              // Check ignore rules against cwd\n              // Note: If ignored, we skip caching this newly parsed session.\n              // Existing cache entries remain untouched - they will be filtered out when reading,\n              // but will reappear if ignore rules are removed later (cache preservation strategy).\n              if self.shouldIgnoreSummary(result, ignoredPaths: ignoredPaths) {\n                return .success(nil)\n              }\n              let (cachedSummary, normalizedFullInstructions) = self.prepareSummaryForCache(result)\n              \n              // Track zero-token stability to avoid re-scans next time if still zero\n              await self.updateZeroTokenStable(\n                path: url.path, modificationDate: modificationDate, tokens: cachedSummary.actualTotalTokens)\n              // Persist to SQLite (best-effort)\n              do {\n                try await self.sqliteStore.upsert(\n                  summary: cachedSummary,\n                  project: nil,\n                  fileModificationTime: modificationDate,\n                  fileSize: fileSize.flatMap { UInt64($0) },\n                  tokenBreakdown: cachedSummary.tokenBreakdown,\n                  fullInstructions: normalizedFullInstructions,\n                  parseError: nil,\n                  parseLevel: cachedSummary.parseLevel?.rawValue ?? \"metadata\")\n              } catch {\n                self.logger.error(\n                  \"Failed to persist session summary: \\(error.localizedDescription, privacy: .public) path=\\(url.path, privacy: .public)\"\n                )\n              }\n              await self.store(\n                summary: cachedSummary, for: url as NSURL,\n                modificationDate: modificationDate)\n              return .success(cachedSummary)\n            } catch {\n              return .failure(error)\n            }\n          }\n        }\n      }\n\n      addNextTasks(workerCount)\n\n      while let result = await group.next() {\n        switch result {\n        case .success(let maybe):\n          if let s = maybe { summaries.append(s) }\n        case .failure(let error):\n          if firstError == nil { firstError = error }\n          self.logger.error(\n            \"Failed to build session summary: \\(error.localizedDescription, privacy: .public)\"\n          )\n        }\n        addNextTasks(1)\n      }\n    }\n\n    // Layer 0: meta update only when full scope refreshed\n    if case .all = scope {\n      do {\n        try await sqliteStore.setMeta(lastFullIndexAt: Date(), sessionCount: sessionFiles.count)\n        logger.info(\n          \"SessionIndexer refresh complete. summaries=\\(summaries.count, privacy: .public) files=\\(sessionFiles.count, privacy: .public)\"\n        )\n      } catch {\n        logger.error(\"Failed to set meta after refresh: \\(error.localizedDescription, privacy: .public)\")\n      }\n    } else {\n      logger.info(\n        \"SessionIndexer refresh complete (partial scope). summaries=\\(summaries.count, privacy: .public) pending=0\"\n      )\n    }\n\n    // Remove SQLite entries no longer present in scoped refresh\n    if let cached = try? await sqliteStore.fetchRecords(\n      kinds: [.codex],\n      includeRemote: false,\n      dateColumn: dateColumn,\n      dateRange: dateRange,\n      projectIds: projectIds\n    ) {\n      let alivePaths = Set(sessionFiles.map { $0.path })\n      let staleIds = cached.filter { !alivePaths.contains($0.filePath) }.map { $0.summary.id }\n      for id in staleIds {\n        do { try await sqliteStore.delete(sessionId: id) }\n        catch {\n          logger.error(\"Failed to delete stale cache id=\\(id, privacy: .public) error=\\(error.localizedDescription, privacy: .public)\")\n        }\n      }\n    }\n\n    if summaries.isEmpty, let error = firstError {\n      throw error\n    }\n    return summaries\n  }\n\n  func invalidate(url: URL) {\n    cache.removeObject(forKey: url as NSURL)\n  }\n\n  func invalidateAll() {\n    cache.removeAllObjects()\n  }\n\n  /// Remove cached entries (memory + SQLite) for deleted sessions.\n  func deleteSessions(ids: [String]) async {\n    guard !ids.isEmpty else { return }\n    for id in ids {\n      cache.removeAllObjects()\n      do {\n        try await sqliteStore.delete(sessionId: id)\n      } catch {\n        logger.error(\"Failed to delete session from cache id=\\(id, privacy: .public) error=\\(error.localizedDescription, privacy: .public)\")\n      }\n    }\n  }\n\n  /// Clear both in-memory and on-disk session index caches.\n  func resetAllCaches() async {\n    cache.removeAllObjects()\n    try? await sqliteStore.reset()\n  }\n\n  /// Fetch aggregated overview metrics from SQLite cache (all sources).\n  func fetchOverviewAggregate() async -> OverviewAggregate? {\n    return try? await sqliteStore.fetchOverviewAggregate()\n  }\n\n  /// Fetch scoped aggregated overview metrics (project/date).\n  func fetchOverviewAggregate(scope: OverviewAggregateScope?) async -> OverviewAggregate? {\n    return try? await sqliteStore.fetchOverviewAggregate(scope: scope)\n  }\n\n  func cachedInstructions(forKeys keys: [String]) async -> String? {\n    guard !keys.isEmpty else { return nil }\n    return try? await sqliteStore.fetchProjectInstructions(keys: keys)\n  }\n\n  /// Current cache coverage (sources present + meta).\n  func currentCoverage() async -> SessionIndexCoverage? {\n    return try? await sqliteStore.fetchCoverage()\n  }\n\n  /// Cache externally provided session summaries (e.g., Claude/Gemini providers) into SQLite.\n  func cacheExternalSummaries(_ summaries: [SessionSummary]) async {\n    guard !summaries.isEmpty else { return }\n    for summary in summaries {\n      let (cachedSummary, normalizedFullInstructions) = prepareSummaryForCache(summary)\n      do {\n        try await sqliteStore.upsert(\n          summary: cachedSummary,\n          project: nil,\n          fileModificationTime: nil,\n          fileSize: cachedSummary.fileSizeBytes,\n          tokenBreakdown: cachedSummary.tokenBreakdown,\n          fullInstructions: normalizedFullInstructions,\n          parseError: nil\n        )\n      } catch {\n        logger.error(\"Failed to cache external summary \\(cachedSummary.id, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n      }\n    }\n    logger.info(\"Cached external summaries count=\\(summaries.count, privacy: .public)\")\n  }\n\n  // MARK: - Private\n\n  nonisolated private func prepareSummaryForCache(_ summary: SessionSummary) -> (SessionSummary, String?) {\n    let normalizedFull = SessionIndexSQLiteStore.normalizeInstructions(summary.instructions)\n    let preview = SessionIndexSQLiteStore.instructionsPreview(from: normalizedFull)\n    let cached = summary.withInstructionPreview(preview)\n    return (cached, normalizedFull)\n  }\n\n  private func cachedSummary(for key: NSURL, modificationDate: Date?) -> SessionSummary? {\n    guard let entry = cache.object(forKey: key) else {\n      return nil\n    }\n    if entry.modificationDate == modificationDate {\n      return entry.summary\n    }\n    return nil\n  }\n\n  private func shouldRecomputeTokens(\n    for path: String, modificationDate: Date?, summary: SessionSummary\n  ) -> Bool {\n    if summary.actualTotalTokens > 0 { return false }\n    switch summary.source.baseKind {\n    case .codex, .gemini:\n      let key = path\n      let mt = modificationDate?.timeIntervalSince1970\n      if let stable = zeroTokenStable[key], stable == mt {\n        return false\n      }\n      return true\n    case .claude:\n      return false\n    }\n  }\n\n  private func updateZeroTokenStable(path: String, modificationDate: Date?, tokens: Int) {\n    if tokens > 0 {\n      zeroTokenStable.removeValue(forKey: path)\n    } else {\n      zeroTokenStable[path] = modificationDate?.timeIntervalSince1970\n    }\n  }\n\n  private func store(summary: SessionSummary, for key: NSURL, modificationDate: Date?) {\n    let entry = CacheEntry(modificationDate: modificationDate, summary: summary)\n    cache.setObject(entry, forKey: key)\n  }\n\n  nonisolated private func lastUpdatedTimestamp(for url: URL, modificationDate: Date?) -> Date? {\n    // Updated timestamp is derived from JSONL content only; ignore file\n    // modification times to avoid treating non-session edits as activity.\n    return readTailTimestamp(url: url)\n  }\n\n  /// Cached fast path: return all summaries from SQLite meta without touching the filesystem.\n  private func cachedAllSummariesFromMeta(ignoredPaths: [String] = []) async throws -> [SessionSummary]? {\n    let meta = try await sqliteStore.fetchMeta()\n    guard meta.sessionCount > 0 else { return nil }\n    let records = try await sqliteStore.fetchAll()\n    if records.isEmpty {\n      return nil\n    }\n    logger.info(\"SessionIndexer meta hit: sessions=\\(records.count, privacy: .public)\")\n    let summaries = records.map { $0.summary.withParseLevel(fromString: $0.parseLevel) }\n    // Apply ignore rules to cached summaries\n    if !ignoredPaths.isEmpty {\n      return summaries.filter { !shouldIgnoreSummary($0, ignoredPaths: ignoredPaths) }\n    }\n    return summaries\n  }\n\n  /// Fast tail scan to retrieve latest token_count for Codex/Gemini sessions.\n  private func sessionFileURLs(\n    at root: URL,\n    scope: SessionLoadScope,\n    dateRange: (Date, Date)?,\n    dateDimension: DateDimension,\n    directories: [URL]? = nil,\n    cachedRecords: [String: SessionIndexRecord]? = nil,\n    ignoredPaths: [String] = []\n  ) throws -> [URL] {\n    var urls: [URL] = []\n    let targets: [URL]\n    if let directories, !directories.isEmpty {\n      targets = directories\n    } else if let base = scopeBaseURL(root: root, scope: scope) {\n      targets = [base]\n    } else {\n      logger.warning(\n        \"No enumerator URL for scope=\\(String(describing: scope), privacy: .public) root=\\(root.path, privacy: .public)\"\n      )\n      return []\n    }\n\n    // Updated single-day fast path: use cached records for cross-day directories, but still\n    // enumerate the created-day directory to discover brand-new sessions not in SQLite yet.\n    if let cachedRecords,\n       let dateRange,\n       dateDimension == .updated,\n       Calendar.current.isDate(dateRange.0, inSameDayAs: dateRange.1)\n    {\n      let start = dateRange.0\n      let end = dateRange.1\n      var candidates: [URL] = []\n      var seenPaths: Set<String> = []\n      for record in cachedRecords.values {\n        let updated = record.summary.lastUpdatedAt ?? record.summary.endedAt ?? record.summary.startedAt\n        if updated < start || updated > end { continue }\n        let url = URL(fileURLWithPath: record.filePath)\n        if fileManager.fileExists(atPath: url.path) {\n          seenPaths.insert(url.path)\n          candidates.append(url)\n        }\n      }\n\n      if let dayDir = dayDirectory(root: root, date: start),\n         let enumerator = fileManager.enumerator(\n          at: dayDir,\n          includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey] as [URLResourceKey],\n          options: [.skipsHiddenFiles, .skipsPackageDescendants]\n         )\n      {\n        while let obj = enumerator.nextObject() {\n          guard let fileURL = obj as? URL else { continue }\n          guard fileURL.pathExtension.lowercased() == \"jsonl\" else { continue }\n          // Apply ignore rules - check both file path and cwd\n          if shouldIgnorePath(fileURL.path, ignoredPaths: ignoredPaths) {\n            continue\n          }\n          // Quick check cwd if ignore rules are present\n          if !ignoredPaths.isEmpty {\n            if let cwd = fastExtractCWD(url: fileURL), shouldIgnorePath(cwd, ignoredPaths: ignoredPaths) {\n              continue\n            }\n          }\n          let values = try? fileURL.resourceValues(\n            forKeys: Set<URLResourceKey>([.isRegularFileKey, .contentModificationDateKey]))\n          guard values?.isRegularFile == true else { continue }\n          if let mdate = values?.contentModificationDate, (mdate < start || mdate > end) {\n            continue\n          }\n          if seenPaths.insert(fileURL.path).inserted {\n            candidates.append(fileURL)\n          }\n        }\n      }\n\n      if !candidates.isEmpty {\n        logger.info(\"Updated-day fast path: returning \\(candidates.count, privacy: .public) files (cache + today dir)\")\n        return candidates\n      }\n    }\n\n    var seen = Set<URL>()\n\n    for enumeratorURL in targets {\n      guard\n        let enumerator = fileManager.enumerator(\n          at: enumeratorURL,\n          includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey] as [URLResourceKey],\n          options: [.skipsHiddenFiles, .skipsPackageDescendants]\n        )\n      else {\n        logger.warning(\"Enumerator could not open \\(enumeratorURL.path, privacy: .public)\")\n        continue\n      }\n\n      while let obj = enumerator.nextObject() {\n        guard let fileURL = obj as? URL else { continue }\n        if fileURL.pathExtension.lowercased() == \"jsonl\" {\n          // Apply ignore rules - check both file path and cwd\n          if shouldIgnorePath(fileURL.path, ignoredPaths: ignoredPaths) {\n            continue\n          }\n          // Quick check cwd if ignore rules are present\n          if !ignoredPaths.isEmpty {\n            if let cwd = fastExtractCWD(url: fileURL), shouldIgnorePath(cwd, ignoredPaths: ignoredPaths) {\n              continue\n            }\n          }\n          if let dateRange, dateDimension == .updated {\n            let values = try? fileURL.resourceValues(forKeys: Set<URLResourceKey>([.contentModificationDateKey]))\n            if let mdate = values?.contentModificationDate {\n              if mdate < dateRange.0 || mdate > dateRange.1 { continue }\n            }\n          }\n          if seen.insert(fileURL).inserted {\n            urls.append(fileURL)\n          }\n        }\n      }\n    }\n    logger.info(\"Enumerated \\(urls.count) files under scoped targets count=\\(targets.count, privacy: .public)\")\n    return urls\n  }\n\n  /// Build a narrowed set of directories for enumeration when a project or date range is active.\n  private func scopedDirectoriesForRefresh(\n    root: URL,\n    scope: SessionLoadScope,\n    dateRange: (Date, Date)? = nil,\n    dateDimension: DateDimension,\n    cachedRecords: [String: SessionIndexRecord],\n    projectIds: Set<String>?,\n    projectDirectories: [String]?\n  ) -> [URL]? {\n    var directories: Set<URL> = []\n    // Use cached records to re-scan only directories that previously contained matching sessions\n    if let projectIds, !projectIds.isEmpty {\n      for record in cachedRecords.values {\n        if let project = record.project, projectIds.contains(project) {\n          let url = URL(fileURLWithPath: record.filePath)\n            .deletingLastPathComponent()\n          directories.insert(url)\n        }\n      }\n    }\n\n    if !cachedRecords.isEmpty {\n      for record in cachedRecords.values {\n        let url = URL(fileURLWithPath: record.filePath)\n          .deletingLastPathComponent()\n        directories.insert(url)\n      }\n    }\n\n    if let projectDirectories, !projectDirectories.isEmpty {\n      for path in projectDirectories {\n        let url = URL(fileURLWithPath: path, isDirectory: true)\n        if let dir = directoryIfExists(url) {\n          directories.insert(dir)\n        }\n      }\n    }\n\n    // If created dimension with explicit date range, add day directories to cover new files\n    if let dateRange, dateDimension == .created {\n      let cal = Calendar.current\n      var cursor = cal.startOfDay(for: dateRange.0)\n      let end = cal.startOfDay(for: dateRange.1)\n      while cursor <= end {\n        if let dayDir = dayDirectory(root: root, date: cursor) {\n          directories.insert(dayDir)\n        }\n        guard let next = cal.date(byAdding: .day, value: 1, to: cursor) else { break }\n        cursor = next\n      }\n    }\n\n    // When no narrowing information exists, fall back to default scope directory\n    if directories.isEmpty { return nil }\n    return Array(directories)\n  }\n\n  private func mappedDataIfAvailable(at url: URL) throws -> Data? {\n    do {\n      return try Data(contentsOf: url, options: [.mappedIfSafe])\n    } catch let error as NSError {\n      if error.domain == NSCocoaErrorDomain &&\n        (error.code == NSFileReadNoSuchFileError || error.code == NSFileNoSuchFileError)\n      {\n        logger.debug(\"File disappeared before reading \\(url.path, privacy: .public); skipping.\")\n        return nil\n      }\n      throw error\n    }\n  }\n\n  // Sidebar: month daily counts without parsing content (fast)\n  func computeCalendarCounts(root: URL, monthStart: Date, dimension: DateDimension) async -> [Int:\n    Int]\n  {\n    var counts: [Int: Int] = [:]\n    let cal = Calendar.current\n    let comps = cal.dateComponents([.year, .month], from: monthStart)\n    guard let year = comps.year, let month = comps.month else { return [:] }\n\n    // For the Updated dimension we must scan all files, since cross-month updates can land in any month folder\n    let scanURL: URL\n    if dimension == .updated {\n      scanURL = root\n    } else {\n      guard let monthURL = monthDirectory(root: root, year: year, month: month) else {\n        return [:]\n      }\n      scanURL = monthURL\n    }\n\n    guard\n      let enumerator = fileManager.enumerator(\n        at: scanURL,\n        includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],\n        options: [.skipsHiddenFiles, .skipsPackageDescendants])\n    else { return [:] }\n\n    // Collect URLs synchronously first to avoid Swift 6 async/iterator issues\n    let urls = enumerator.compactMap { $0 as? URL }\n\n    for url in urls {\n      guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n      switch dimension {\n      case .created:\n        if let day = Int(url.deletingLastPathComponent().lastPathComponent) {\n          counts[day, default: 0] += 1\n        }\n      case .updated:\n        let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])\n        if let date = lastUpdatedTimestamp(\n          for: url, modificationDate: values?.contentModificationDate),\n          cal.isDate(date, equalTo: monthStart, toGranularity: .month)\n        {\n          let day = cal.component(.day, from: date)\n          counts[day, default: 0] += 1\n        }\n      }\n    }\n    return counts\n  }\n\n  // MARK: - Updated dimension index\n\n  /// Fast index: record the last update timestamp per file to avoid repeated scans\n  private var updatedDateIndex: [String: Date] = [:]\n  /// Tail token scan when tokens are zero (avoids full reparse)\n  private func readTailTokenSnapshot(url: URL) -> SessionTokenSnapshot? {\n    let chunkSize = 128 * 1024\n    guard\n      let handle = try? FileHandle(forReadingFrom: url)\n    else { return nil }\n    defer { try? handle.close() }\n\n    do {\n      let fileSize = try handle.seekToEnd()\n      let offset = fileSize > chunkSize ? fileSize - UInt64(chunkSize) : 0\n      try handle.seek(toOffset: offset)\n      guard let data = try handle.readToEnd(), !data.isEmpty else { return nil }\n      let newline: UInt8 = 0x0A\n      let carriageReturn: UInt8 = 0x0D\n      for var slice in data.split(separator: newline, omittingEmptySubsequences: true).reversed() {\n        if slice.last == carriageReturn { slice = slice.dropLast() }\n        guard !slice.isEmpty else { continue }\n        if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) {\n          if case let .eventMessage(payload) = row.kind, payload.type == \"token_count\" {\n            var snapshot = SessionTokenSnapshot()\n            var handled = false\n            if let infoSnapshot = SessionTokenSnapshot.from(info: payload.info) {\n              snapshot.merge(infoSnapshot)\n              handled = true\n            }\n            if let messageSnapshot = SessionTokenSnapshot.from(message: payload.message ?? payload.text) {\n              snapshot.merge(messageSnapshot)\n              handled = true\n            }\n            if handled {\n              return snapshot\n            }\n          }\n        }\n      }\n    } catch {\n      return nil\n    }\n    return nil\n  }\n\n  /// Build the date index for the Updated dimension (async in the background)\n  func buildUpdatedIndex(root: URL) async -> [String: Date] {\n    var index: [String: Date] = [:]\n    guard\n      let enumerator = fileManager.enumerator(\n        at: root,\n        includingPropertiesForKeys: [.isRegularFileKey],\n        options: [.skipsHiddenFiles, .skipsPackageDescendants]\n      )\n    else { return [:] }\n\n    let urls = enumerator.compactMap { $0 as? URL }\n\n    await withTaskGroup(of: (String, Date)?.self) { group in\n      for url in urls {\n        guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n        group.addTask { [weak self] in\n          guard let self else { return nil }\n          // Try disk cache first\n          let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])\n          if\n            let cached = try? await self.sqliteStore.fetch(\n              path: url.path,\n              modificationDate: values?.contentModificationDate,\n              fileSize: nil),\n            let updated = cached.lastUpdatedAt\n          {\n            return (url.path, updated)\n          }\n          // Otherwise read tail timestamp quickly\n          if let tailDate = self.readTailTimestamp(url: url) {\n            return (url.path, tailDate)\n          }\n          return nil\n        }\n      }\n      for await item in group {\n        if let (path, date) = item {\n          index[path] = date\n        }\n      }\n    }\n    return index\n  }\n\n  /// Quickly filter files to load based on the Updated index\n  func sessionFileURLsForUpdatedDay(root: URL, day: Date, index: [String: Date]) -> [URL] {\n    let cal = Calendar.current\n    let dayStart = cal.startOfDay(for: day)\n\n    var urls: [URL] = []\n    for (path, updatedDate) in index {\n      if cal.isDate(updatedDate, inSameDayAs: dayStart) {\n        urls.append(URL(fileURLWithPath: path))\n      }\n    }\n    return urls\n  }\n\n  private func scopeBaseURL(root: URL, scope: SessionLoadScope) -> URL? {\n    switch scope {\n    case .today:\n      return dayDirectory(root: root, date: Date())\n    case .day(let date):\n      return dayDirectory(root: root, date: date)\n    case .month(let date):\n      return monthDirectory(root: root, date: date)\n    case .all:\n      return directoryIfExists(root)\n    }\n  }\n\n  private func monthDirectory(root: URL, date: Date) -> URL? {\n    let cal = Calendar.current\n    let components = cal.dateComponents([.year, .month], from: date)\n    guard let year = components.year, let month = components.month else { return nil }\n    return monthDirectory(root: root, year: year, month: month)\n  }\n\n  private func dayDirectory(root: URL, date: Date) -> URL? {\n    let cal = Calendar.current\n    let components = cal.dateComponents([.year, .month, .day], from: cal.startOfDay(for: date))\n    guard let year = components.year,\n      let month = components.month,\n      let day = components.day\n    else { return nil }\n    return dayDirectory(root: root, year: year, month: month, day: day)\n  }\n\n  private func monthDirectory(root: URL, year: Int, month: Int) -> URL? {\n    guard\n      let yearURL = directoryIfExists(\n        root.appendingPathComponent(\"\\(year)\", isDirectory: true))\n    else { return nil }\n    return numberedDirectory(base: yearURL, value: month)\n  }\n\n  private func dayDirectory(root: URL, year: Int, month: Int, day: Int) -> URL? {\n    guard let monthURL = monthDirectory(root: root, year: year, month: month) else {\n      return nil\n    }\n    return numberedDirectory(base: monthURL, value: day)\n  }\n\n  private func numberedDirectory(base: URL, value: Int) -> URL? {\n    let candidates = [String(format: \"%02d\", value), \"\\(value)\"]\n    for name in candidates {\n      let url = base.appendingPathComponent(name, isDirectory: true)\n      if let existing = directoryIfExists(url) { return existing }\n    }\n    return nil\n  }\n\n  private func directoryIfExists(_ url: URL) -> URL? {\n    var isDir: ObjCBool = false\n    if fileManager.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {\n      return url\n    }\n    return nil\n  }\n\n  // Sidebar: collect cwd counts using disk cache or quick head-scan\n  func collectCWDCounts(root: URL) async -> [String: Int] {\n    var result: [String: Int] = [:]\n    guard\n      let enumerator = fileManager.enumerator(\n        at: root,\n        includingPropertiesForKeys: [.isRegularFileKey, .contentModificationDateKey],\n        options: [.skipsHiddenFiles, .skipsPackageDescendants])\n    else { return [:] }\n\n    // Collect URLs synchronously first to avoid Swift 6 async/iterator issues\n    let urls = enumerator.compactMap { $0 as? URL }\n\n    await withTaskGroup(of: (String, Int)?.self) { group in\n      for url in urls {\n        guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n        group.addTask { [weak self] in\n          guard let self else { return nil }\n          let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])\n          let m = values?.contentModificationDate\n          if\n            let cached = try? await self.sqliteStore.fetch(\n              path: url.path, modificationDate: m, fileSize: nil),\n            !cached.cwd.isEmpty\n          {\n            return (cached.cwd, 1)\n          }\n          if let cwd = self.fastExtractCWD(url: url) { return (cwd, 1) }\n          return nil\n        }\n      }\n      for await item in group {\n        if let (cwd, inc) = item { result[cwd, default: 0] += inc }\n      }\n    }\n    return result\n  }\n\n  nonisolated private func fastExtractCWD(url: URL) -> String? {\n    guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else {\n      return nil\n    }\n    let newline: UInt8 = 0x0A\n    let carriageReturn: UInt8 = 0x0D\n    for var slice in data.split(separator: newline, omittingEmptySubsequences: true).prefix(200) {\n      if slice.last == carriageReturn { slice = slice.dropLast() }\n      if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) {\n        switch row.kind {\n        case .sessionMeta(let p): return p.cwd\n        case .turnContext(let p): if let c = p.cwd { return c }\n        default: break\n        }\n      }\n    }\n    return nil\n  }\n\n  private func buildSummaryFast(for url: URL, builder: inout SessionSummaryBuilder) throws\n    -> SessionSummary?\n  {\n    // Memory-map file (fast and low memory overhead)\n    guard let data = try mappedDataIfAvailable(at: url) else { return nil }\n    guard !data.isEmpty else { return nil }\n\n    let newline: UInt8 = 0x0A\n    let carriageReturn: UInt8 = 0x0D\n    let fastLineLimit = 64\n    var lineCount = 0\n    for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n      if slice.last == carriageReturn { slice = slice.dropLast() }\n      guard !slice.isEmpty else { continue }\n      if lineCount >= fastLineLimit, builder.hasEssentialMetadata {\n        break\n      }\n      do {\n        let row = try decoder.decode(SessionRow.self, from: Data(slice))\n        builder.observe(row)\n      } catch {\n        // Silently ignore parse errors for individual lines\n      }\n      lineCount += 1\n    }\n    // Ensure lastUpdatedAt reflects last JSON line timestamp\n    if let tailDate = readTailTimestamp(url: url) {\n      if builder.lastUpdatedAt == nil || (builder.lastUpdatedAt ?? .distantPast) < tailDate {\n        builder.seedLastUpdated(tailDate)\n      }\n    }\n    // Lightweight token fallback for sources emitting token_count events.\n    if builder.totalTokens == 0,\n      shouldUseTokenFallback(for: url),\n      let snapshot = SessionTimelineLoader().loadLatestTokenUsageWithFallback(url: url),\n      let fallbackTokens = snapshot.totalTokens\n    {\n      builder.seedTotalTokens(fallbackTokens)\n    }\n    // Tail token_count scan: always read the last token_count from the file tail\n    // because it contains the cumulative total (not just the first 64 lines)\n    if shouldUseTokenFallback(for: url), let tailSnapshot = readTailTokenSnapshot(url: url) {\n      if let total = tailSnapshot.total {\n        builder.seedTotalTokens(total)\n      }\n      builder.seedTokenSnapshot(\n        input: tailSnapshot.input,\n        output: tailSnapshot.output,\n        cacheRead: tailSnapshot.cacheRead,\n        cacheCreation: tailSnapshot.cacheCreation)\n    }\n\n    builder.parseLevel = .metadata\n    if let result = builder.build(for: url) { return result }\n    return try buildSummaryFull(for: url, builder: &builder)\n  }\n\n  private func shouldUseTokenFallback(for url: URL) -> Bool {\n    // Apply to all sources; if no token_count exists the scan is cheap and falls through.\n    return true\n  }\n\n  private func buildSummaryFull(for url: URL, builder: inout SessionSummaryBuilder) throws\n    -> SessionSummary?\n  {\n    guard let data = try mappedDataIfAvailable(at: url) else { return nil }\n    guard !data.isEmpty else { return nil }\n    let newline: UInt8 = 0x0A\n    let carriageReturn: UInt8 = 0x0D\n    var lastError: Error?\n    for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n      if slice.last == carriageReturn { slice = slice.dropLast() }\n      guard !slice.isEmpty else { continue }\n      do {\n        let row = try decoder.decode(SessionRow.self, from: Data(slice))\n        builder.observe(row)\n      } catch {\n        lastError = error\n      }\n    }\n    builder.parseLevel = .full\n    if let result = builder.build(for: url) { return result }\n    if let error = lastError { throw error }\n    return nil\n  }\n\n  // Public API for background enrichment\n  func enrich(url: URL) async throws -> SessionSummary? {\n    let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])\n    var builder = SessionSummaryBuilder()\n    if let size = values.fileSize { builder.setFileSize(UInt64(size)) }\n    if let tailDate = readTailTimestamp(url: url) { builder.seedLastUpdated(tailDate) }\n    guard let base = try buildSummaryFull(for: url, builder: &builder) else { return nil }\n\n    // Compute accurate active duration from grouped turns\n    let active = computeActiveDuration(url: url)\n    var enriched = SessionSummary(\n      id: base.id,\n      fileURL: base.fileURL,\n      fileSizeBytes: base.fileSizeBytes,\n      startedAt: base.startedAt,\n      endedAt: base.endedAt,\n      activeDuration: active,\n      cliVersion: base.cliVersion,\n      cwd: base.cwd,\n      originator: base.originator,\n      instructions: base.instructions,\n      model: base.model,\n      approvalPolicy: base.approvalPolicy,\n      userMessageCount: base.userMessageCount,\n      assistantMessageCount: base.assistantMessageCount,\n      toolInvocationCount: base.toolInvocationCount,\n      responseCounts: base.responseCounts,\n      turnContextCount: base.turnContextCount,\n      messageTypeCounts: base.messageTypeCounts,\n      totalTokens: base.totalTokens,\n      tokenBreakdown: base.tokenBreakdown,\n      eventCount: base.eventCount,\n      lineCount: base.lineCount,\n      lastUpdatedAt: base.lastUpdatedAt,\n      source: base.source,\n      remotePath: base.remotePath,\n      userTitle: base.userTitle,\n      userComment: base.userComment\n    )\n    enriched.parseLevel = .enriched\n\n    let (cachedEnriched, normalizedFullInstructions) = prepareSummaryForCache(enriched)\n\n    // Persist to in-memory and disk caches keyed by mtime\n    store(summary: cachedEnriched, for: url as NSURL, modificationDate: values.contentModificationDate)\n    do {\n      try await sqliteStore.upsert(\n        summary: cachedEnriched,\n        project: nil,\n        fileModificationTime: values.contentModificationDate,\n        fileSize: values.fileSize.flatMap { UInt64($0) },\n        tokenBreakdown: cachedEnriched.tokenBreakdown,\n        fullInstructions: normalizedFullInstructions,\n        parseError: nil,\n        parseLevel: \"enriched\")  // Full parse + activeDuration computation\n    } catch {\n      logger.error(\n        \"Failed to persist enriched summary: \\(error.localizedDescription, privacy: .public) path=\\(url.path, privacy: .public)\"\n      )\n    }\n    return cachedEnriched\n  }\n\n  // Compute sum of turn durations: for each turn, duration = (last output timestamp - user message timestamp).\n  // If a turn has no user message, start from first output. If no outputs exist, contributes 0.\n  nonisolated private func computeActiveDuration(url: URL) -> TimeInterval? {\n    let loader = SessionTimelineLoader()\n    guard let turns = try? loader.load(url: url) else { return nil }\n    let filtered = turns.removingEnvironmentContext()\n    var total: TimeInterval = 0\n    for turn in filtered {\n      let start: Date?\n      if let u = turn.userMessage?.timestamp {\n        start = u\n      } else {\n        start = turn.outputs.first?.timestamp\n      }\n      guard let s = start, let end = turn.outputs.last?.timestamp else { continue }\n      let dt = end.timeIntervalSince(s)\n      if dt > 0 { total += dt }\n      if Task.isCancelled { return total }\n    }\n    return total\n  }\n\n  // MARK: - Fulltext scanning\n  func fileContains(url: URL, term: String) async -> Bool {\n    guard let handle = try? FileHandle(forReadingFrom: url) else { return false }\n    defer { try? handle.close() }\n    let needle = term\n    let chunkSize = 128 * 1024\n    var carry = Data()\n    while let chunk = try? handle.read(upToCount: chunkSize), !chunk.isEmpty {\n      var combined = carry\n      combined.append(chunk)\n      if let s = String(data: combined, encoding: .utf8),\n        s.range(of: needle, options: .caseInsensitive) != nil\n      {\n        return true\n      }\n      // keep tail to catch matches across boundaries\n      let keep = min(needle.utf8.count - 1, combined.count)\n      carry = combined.suffix(keep)\n      if Task.isCancelled { return false }\n    }\n    if !carry.isEmpty, let s = String(data: carry, encoding: .utf8),\n      s.range(of: needle, options: .caseInsensitive) != nil\n    {\n      return true\n    }\n    return false\n  }\n\n  // MARK: - Tail timestamp helper\n  nonisolated private func readTailTimestamp(url: URL) -> Date? {\n    guard let handle = try? FileHandle(forReadingFrom: url) else { return nil }\n    defer { try? handle.close() }\n\n    let attributes = try? FileManager.default.attributesOfItem(atPath: url.path)\n    let fileSize = (attributes?[.size] as? NSNumber)?.uint64Value ?? 0\n\n    // Start with a reasonable chunk size, will expand if needed\n    let chunkSize: UInt64 = 4096\n    let maxChunkSize: UInt64 = 1024 * 1024  // 1MB max to avoid excessive memory usage\n    let maxAttempts = 3\n\n    let newline: UInt8 = 0x0A\n    let carriageReturn: UInt8 = 0x0D\n\n    for attempt in 0..<maxAttempts {\n      let currentChunkSize = min(chunkSize * UInt64(1 << attempt), maxChunkSize, fileSize)\n      let offset = fileSize > currentChunkSize ? fileSize - currentChunkSize : 0\n\n      do { try handle.seek(toOffset: offset) } catch { return nil }\n      guard let buffer = try? handle.readToEnd(), !buffer.isEmpty else { return nil }\n\n      let lines = buffer.split(separator: newline, omittingEmptySubsequences: true)\n      guard var slice = lines.last else { continue }\n\n      if slice.last == carriageReturn { slice = slice.dropLast() }\n      guard !slice.isEmpty else { continue }\n\n      // Check if this looks like a complete line by looking for opening brace\n      // (all session log lines are JSON objects starting with {)\n      let hasOpeningBrace = slice.first == 0x7B  // '{'\n\n      if !hasOpeningBrace && attempt < maxAttempts - 1 {\n        // Line appears truncated, try with larger chunk\n        continue\n      }\n\n      // Try to extract timestamp from first 100 bytes for performance\n      let limitedSlice = slice.prefix(100)\n      if let text = String(data: Data(limitedSlice), encoding: .utf8)\n        ?? String(bytes: limitedSlice, encoding: .utf8),\n        let timestamp = extractTimestamp(from: text)\n      {\n        return timestamp\n      }\n\n      // Fallback: try full line\n      if let fullText = String(data: Data(slice), encoding: .utf8),\n        let timestamp = extractTimestamp(from: fullText)\n      {\n        return timestamp\n      }\n\n      // If we've tried full line and still failed, no point in retrying with larger chunk\n      break\n    }\n\n    return nil\n  }\n\n  nonisolated private func extractTimestamp(from text: String) -> Date? {\n    let pattern = #\"\"timestamp\"\\s*:\\s*\"([^\"]+)\"\"#\n    guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {\n      return nil\n    }\n    let range = NSRange(location: 0, length: (text as NSString).length)\n    guard let match = regex.firstMatch(in: text, options: [], range: range),\n      match.numberOfRanges >= 2\n    else { return nil }\n    let nsText = text as NSString\n    let isoString = nsText.substring(with: match.range(at: 1))\n    return SessionIndexer.makeTailTimestampFormatter().date(from: isoString)\n  }\n\n  // Global count for sidebar label\n  func countAllSessions(root: URL) async -> Int {\n    var total = 0\n    guard\n      let enumerator = fileManager.enumerator(\n        at: root, includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],\n        options: [.skipsHiddenFiles, .skipsPackageDescendants])\n    else { return 0 }\n\n    while let obj = enumerator.nextObject() {\n      guard let url = obj as? URL else { continue }\n      guard url.pathExtension.lowercased() == \"jsonl\" else { continue }\n      let name = url.deletingPathExtension().lastPathComponent\n      if name.hasPrefix(\"agent-\") { continue }\n      let values = try? url.resourceValues(forKeys: [.fileSizeKey])\n      if let size = values?.fileSize, size == 0 { continue }\n      total += 1\n    }\n    return total\n  }\n\n  /// Expose current meta for UI/diagnostics (non-mutating).\n  func currentMeta() async -> SessionIndexMeta? {\n    return try? await sqliteStore.fetchMeta()\n  }\n\n  /// Persist project assignments into the shared SQLite cache for scoped aggregates.\n  func updateProjects(\n    for sessions: [SessionSummary],\n    resolver: @escaping @Sendable (SessionSummary) -> String?\n  ) async {\n    guard !sessions.isEmpty else { return }\n    for session in sessions {\n      let project = resolver(session)\n      do {\n        try await sqliteStore.updateProject(sessionId: session.id, project: project)\n      } catch {\n        logger.error(\"Failed to update project for \\(session.id, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n      }\n    }\n  }\n\n  /// Persist user-provided title/comment into the SQLite cache for consistency.\n  func updateUserMetadata(sessionId: String, title: String?, comment: String?) async {\n    do {\n      try await sqliteStore.updateUserMetadata(sessionId: sessionId, title: title, comment: comment)\n    } catch {\n      logger.error(\"Failed to update user metadata for \\(sessionId, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n    }\n  }\n\n  /// Returns cached summaries when a full index already exists.\n  func cachedAllSummaries(ignoredPaths: [String] = []) async throws -> [SessionSummary]? {\n    return try await cachedAllSummariesFromMeta(ignoredPaths: ignoredPaths)\n  }\n}\n\n// MARK: - SessionProvider\n\nextension SessionIndexer: SessionProvider {\n  nonisolated var kind: SessionSource.Kind { .codex }\n  nonisolated var identifier: String { \"codex-local\" }\n  nonisolated var label: String { \"Codex (local)\" }\n\n  func load(context: SessionProviderContext) async throws -> SessionProviderResult {\n    guard let root = context.sessionsRoot else {\n      return SessionProviderResult(summaries: [], coverage: nil, cacheHit: true)\n    }\n    _ = try await ensureCacheAvailable()\n    switch context.cachePolicy {\n    case .cacheOnly:\n      // Try scoped cached summaries first\n      var cached = try await sqliteStore.fetchSummaries(\n        kinds: [.codex],\n        includeRemote: false,\n        dateColumn: dateColumn(for: context.dateDimension),\n        dateRange: context.dateRange,\n        projectIds: context.projectIds\n      )\n      // Apply ignore rules to cached summaries\n      if !context.ignoredPaths.isEmpty {\n        cached = cached.filter { !shouldIgnoreSummary($0, ignoredPaths: context.ignoredPaths) }\n      }\n      if !cached.isEmpty {\n        let coverage = await currentCoverage()\n        return SessionProviderResult(summaries: cached, coverage: coverage, cacheHit: true)\n      }\n      if context.scope == .all, let cached = try await cachedAllSummaries(ignoredPaths: context.ignoredPaths) {\n        let coverage = await currentCoverage()\n        return SessionProviderResult(\n          summaries: cached,\n          coverage: coverage,\n          cacheHit: true\n        )\n      }\n      return SessionProviderResult(summaries: [], coverage: await currentCoverage(), cacheHit: false)\n    case .refresh:\n      let summaries = try await refreshSessions(\n        root: root,\n        scope: context.scope,\n        dateRange: context.dateRange,\n        projectIds: context.projectIds,\n        projectDirectories: context.projectDirectories,\n        dateDimension: context.dateDimension,\n        forceFilesystemScan: context.forceFilesystemScan,\n        ignoredPaths: context.ignoredPaths\n      )\n      let coverage = await currentCoverage()\n      return SessionProviderResult(\n        summaries: summaries,\n        coverage: coverage,\n        cacheHit: false\n      )\n    }\n  }\n\n  private func dateColumn(for dimension: DateDimension) -> String {\n    switch dimension {\n    case .created: return \"started_at\"\n    case .updated: return \"COALESCE(last_updated_at, started_at)\"\n    }\n  }\n\n  // MARK: - Single File Reindexing\n\n  /// Reindex specific files and update cache. Used for incremental refresh of selected sessions.\n  /// Returns updated SessionSummary objects for successfully reindexed files.\n  func reindexFiles(_ urls: [URL]) async throws -> [SessionSummary] {\n    guard !urls.isEmpty else { return [] }\n\n    logger.info(\"reindexFiles: processing \\(urls.count, privacy: .public) files\")\n    let startTime = Date()\n\n    var results: [SessionSummary] = []\n    var failedCount = 0\n\n    for url in urls {\n      do {\n        // Get file attributes for mtime and size\n        let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])\n        let fileSize = values.fileSize.flatMap { UInt64($0) }\n        let mtime = values.contentModificationDate\n\n        // Build summary using full parsing\n        var builder = SessionSummaryBuilder()\n        if let size = fileSize {\n          builder.setFileSize(size)\n        }\n        if let tailDate = readTailTimestamp(url: url) {\n          builder.seedLastUpdated(tailDate)\n        }\n\n        guard let summary = try buildSummaryFull(for: url, builder: &builder) else {\n          logger.warning(\"reindexFiles: failed to build summary for \\(url.path, privacy: .public)\")\n          failedCount += 1\n          continue\n        }\n        let (cachedSummary, normalizedFullInstructions) = prepareSummaryForCache(summary)\n\n        // Update SQLite cache\n        do {\n          try await sqliteStore.upsert(\n            summary: cachedSummary,\n            project: nil,\n            fileModificationTime: mtime,\n            fileSize: fileSize,\n            tokenBreakdown: cachedSummary.tokenBreakdown,\n            fullInstructions: normalizedFullInstructions,\n            parseError: nil,\n            parseLevel: cachedSummary.parseLevel?.rawValue ?? \"full\"\n          )\n        } catch {\n          logger.error(\"reindexFiles: failed to update cache for \\(cachedSummary.id, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n        }\n\n        // Update in-memory cache\n        if let mtime {\n          cache.setObject(CacheEntry(modificationDate: mtime, summary: cachedSummary), forKey: url as NSURL)\n        }\n\n        results.append(cachedSummary)\n\n        logger.debug(\"reindexFiles: successfully reindexed \\(cachedSummary.id, privacy: .public) (\\(cachedSummary.fileURL.lastPathComponent, privacy: .public))\")\n      } catch {\n        logger.error(\"reindexFiles: error processing \\(url.path, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n        failedCount += 1\n      }\n    }\n\n    let elapsed = Date().timeIntervalSince(startTime)\n    logger.info(\"reindexFiles: completed in \\(elapsed, format: .fixed(precision: 3))s, success=\\(results.count, privacy: .public), failed=\\(failedCount, privacy: .public)\")\n\n    return results\n  }\n\n  // MARK: - Timeline Preview Cache\n\n  /// Fetch cached timeline previews for a session\n  func fetchTimelinePreviews(\n    sessionId: String,\n    fileModificationTime: Date?,\n    fileSize: UInt64?\n  ) async throws -> [ConversationTurnPreview]? {\n    try await sqliteStore.fetchTimelinePreviews(\n      sessionId: sessionId,\n      fileModificationTime: fileModificationTime,\n      fileSize: fileSize\n    )\n  }\n\n  /// Update timeline preview cache for a session\n  func upsertTimelinePreviews(\n    _ previews: [ConversationTurnPreview],\n    sessionId: String,\n    fileModificationTime: Date,\n    fileSize: UInt64?\n  ) async throws {\n    try await sqliteStore.upsertTimelinePreviews(\n      previews,\n      sessionId: sessionId,\n      fileModificationTime: fileModificationTime,\n      fileSize: fileSize\n    )\n  }\n\n  /// Delete timeline preview cache for a session\n  func deleteTimelinePreviews(sessionId: String) async throws {\n    try await sqliteStore.deleteTimelinePreviews(sessionId: sessionId)\n  }\n\n  /// Fetch cached records for given session IDs (including mtime/size) without touching the filesystem.\n  func fetchRecords(sessionIds: Set<String>) async -> [SessionIndexRecord] {\n    guard !sessionIds.isEmpty else { return [] }\n    do {\n      return try await sqliteStore.fetchRecords(sessionIds: sessionIds)\n    } catch {\n      logger.error(\"fetchRecords(sessionIds:) failed: \\(error.localizedDescription, privacy: .public)\")\n      return []\n    }\n  }\n  \n  // MARK: - Ignore Rules\n  \n  nonisolated private func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnorePath(absolutePath, ignoredPaths: ignoredPaths)\n  }\n  \n  nonisolated private func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool {\n    SessionPathFilter.shouldIgnoreSummary(summary, ignoredPaths: ignoredPaths)\n  }\n}\n"
  },
  {
    "path": "services/SessionNotesStore.swift",
    "content": "import Foundation\n\nstruct SessionNote: Codable, Hashable, Sendable {\n    let id: String\n    var title: String?\n    var comment: String?\n    var projectId: String?\n    var profileId: String?\n    var timelineVisibleKinds: [String]? = nil\n    var updatedAt: Date\n}\n\n// Stores notes as individual JSON files under a notes directory that sits\n// alongside the sessions directory. Provides migration from the legacy\n// Application Support JSON file when the notes directory is empty.\nactor SessionNotesStore {\n    private let fm: FileManager\n    private var notesRoot: URL\n    private let legacyURL: URL\n\n    init(notesRoot: URL? = nil, fileManager: FileManager = .default) {\n        self.fm = fileManager\n        // Default to ~/.codmate/notes (centralized CodMate data root)\n        let home = fileManager.homeDirectoryForCurrentUser\n        let defaultRoot = home.appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"notes\", isDirectory: true)\n        self.notesRoot = notesRoot ?? defaultRoot\n\n        // Legacy single-file JSON in Application Support (existing path in project)\n        let legacyDir = fileManager.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!\n            .appendingPathComponent(\"ai.umate.codmate\", isDirectory: true)\n        self.legacyURL = legacyDir.appendingPathComponent(\"session-notes.json\")\n\n        // First migrate from legacy ~/.codex/notes directory if present\n        Self.migrateLegacyNotesDirectoryIfNeeded(fm: fm, newNotesRoot: self.notesRoot)\n        try? fm.createDirectory(at: self.notesRoot, withIntermediateDirectories: true)\n        // During init, actor isolation isn't available; use static helper for old single-file JSON\n        Self.performMigration(fm: fm, notesRoot: self.notesRoot, legacyURL: self.legacyURL)\n        // Normalize stored timeline visibility settings to current schema.\n        Self.normalizeTimelineVisibilityIfNeeded(fm: fm, notesRoot: self.notesRoot)\n    }\n\n    // Compute default notes directory from sessions root\n    static func defaultNotesRoot(for sessionsRoot: URL) -> URL {\n        // Kept for compatibility, but now always prefers centralized ~/.codmate/notes\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        return home.appendingPathComponent(\".codmate\", isDirectory: true)\n            .appendingPathComponent(\"notes\", isDirectory: true)\n    }\n\n    // Update notes root (e.g., when sessions root changes)\n    func updateRoot(to newRoot: URL) {\n        if newRoot == notesRoot { return }\n        notesRoot = newRoot\n        try? fm.createDirectory(at: notesRoot, withIntermediateDirectories: true)\n        migrateFromLegacyIfNeeded()\n    }\n\n    // MARK: - Public API\n    func note(for id: String) -> SessionNote? {\n        let url = fileURL(for: id)\n        guard let data = try? Data(contentsOf: url) else { return nil }\n        return try? JSONDecoder().decode(SessionNote.self, from: data)\n    }\n\n    func upsert(id: String, title: String?, comment: String?) {\n        var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date()))\n        note.title = title\n        note.comment = comment\n        note.updatedAt = Date()\n        if let data = try? JSONEncoder().encode(note) {\n            let url = fileURL(for: id)\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    func assignProject(id: String, projectId: String?, profileId: String? = nil) {\n        var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date()))\n        note.projectId = projectId\n        if let profileId { note.profileId = profileId }\n        note.updatedAt = Date()\n        if let data = try? JSONEncoder().encode(note) {\n            let url = fileURL(for: id)\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    func remove(id: String) {\n        let url = fileURL(for: id)\n        // Move to Trash rather than hard delete to allow recovery\n        var resulting: NSURL?\n        if fm.fileExists(atPath: url.path) {\n            try? fm.trashItem(at: url, resultingItemURL: &resulting)\n        }\n    }\n\n    func updateTimelineVisibleKinds(id: String, kinds: [String]?) {\n        var note = (note(for: id) ?? SessionNote(id: id, title: nil, comment: nil, projectId: nil, profileId: nil, updatedAt: Date()))\n        note.timelineVisibleKinds = kinds\n        note.updatedAt = Date()\n        if let data = try? JSONEncoder().encode(note) {\n            let url = fileURL(for: id)\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    func all() -> [String: SessionNote] {\n        var result: [String: SessionNote] = [:]\n        guard let en = fm.enumerator(at: notesRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else {\n            return [:]\n        }\n        for case let url as URL in en {\n            if url.pathExtension.lowercased() != \"json\" { continue }\n            if let data = try? Data(contentsOf: url), let n = try? JSONDecoder().decode(SessionNote.self, from: data) {\n                result[n.id] = n\n            }\n        }\n        return result\n    }\n\n    // MARK: - Helpers\n    private func migrateFromLegacyIfNeeded() {\n        Self.performMigration(fm: fm, notesRoot: notesRoot, legacyURL: legacyURL)\n    }\n\n    private static func performMigration(fm: FileManager, notesRoot: URL, legacyURL: URL) {\n        // Only migrate when notes directory is empty and legacy file exists\n        let existing = (try? fm.contentsOfDirectory(at: notesRoot, includingPropertiesForKeys: nil)) ?? []\n        guard existing.first(where: { $0.pathExtension.lowercased() == \"json\" }) == nil else { return }\n        guard fm.fileExists(atPath: legacyURL.path),\n              let data = try? Data(contentsOf: legacyURL),\n              let decoded = try? JSONDecoder().decode([String: SessionNote].self, from: data) else { return }\n        for (id, note) in decoded {\n            if let d = try? JSONEncoder().encode(note) {\n                let safe = safeFileNameStatic(for: id)\n                let url = notesRoot.appendingPathComponent(safe + \".json\")\n                try? d.write(to: url, options: .atomic)\n            }\n        }\n        // Keep legacy file as-is; do not delete to avoid destructive surprises\n    }\n\n    private static func normalizeTimelineVisibilityIfNeeded(fm: FileManager, notesRoot: URL) {\n        guard let en = fm.enumerator(at: notesRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) else {\n            return\n        }\n        for case let url as URL in en {\n            if url.pathExtension.lowercased() != \"json\" { continue }\n            guard let data = try? Data(contentsOf: url),\n                  var note = try? JSONDecoder().decode(SessionNote.self, from: data)\n            else { continue }\n            guard var kinds = note.timelineVisibleKinds else { continue }\n            let before = kinds\n            kinds = Array(Set(kinds))\n            kinds.removeAll {\n                $0 == \"environmentContext\"\n                || $0 == \"turnContext\"\n                || $0 == \"ghostSnapshot\"\n                || $0 == \"compaction\"\n                || $0 == \"turnAborted\"\n                || $0 == \"sessionMeta\"\n                || $0 == \"taskInstructions\"\n            }\n            if kinds.contains(\"tool\"), !kinds.contains(\"codeEdit\") {\n                kinds.append(\"codeEdit\")\n            }\n            if kinds != before {\n                note.timelineVisibleKinds = kinds\n                note.updatedAt = Date()\n                if let updated = try? JSONEncoder().encode(note) {\n                    try? updated.write(to: url, options: .atomic)\n                }\n            }\n        }\n    }\n\n    /// Migrate notes directory from old `~/.codex/notes` to new `~/.codmate/notes`.\n    /// Prefer moving the entire directory if the destination is missing or empty; otherwise copy only missing files.\n    private static func migrateLegacyNotesDirectoryIfNeeded(fm: FileManager, newNotesRoot: URL) {\n        let home = fm.homeDirectoryForCurrentUser\n        let oldRoot = home.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"notes\", isDirectory: true)\n        var isDir: ObjCBool = false\n        guard fm.fileExists(atPath: oldRoot.path, isDirectory: &isDir), isDir.boolValue else { return }\n\n        // Determine if new root exists and is empty\n        var newIsDir: ObjCBool = false\n        let newExists = fm.fileExists(atPath: newNotesRoot.path, isDirectory: &newIsDir) && newIsDir.boolValue\n        let newIsEmpty: Bool = {\n            guard newExists else { return true }\n            do { return try fm.contentsOfDirectory(atPath: newNotesRoot.path).isEmpty } catch { return true }\n        }()\n\n        if !newExists || newIsEmpty {\n            do {\n                if newExists && newIsEmpty { try? fm.removeItem(at: newNotesRoot) }\n                try fm.moveItem(at: oldRoot, to: newNotesRoot)\n                return\n            } catch {\n                // Fallback to copy flow\n            }\n        }\n        // Ensure destination exists for copy\n        try? fm.createDirectory(at: newNotesRoot, withIntermediateDirectories: true)\n        if let en = fm.enumerator(at: oldRoot, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) {\n            for case let url as URL in en {\n                if url.pathExtension.lowercased() != \"json\" { continue }\n                let dest = newNotesRoot.appendingPathComponent(url.lastPathComponent)\n                if !fm.fileExists(atPath: dest.path) {\n                    try? fm.copyItem(at: url, to: dest)\n                }\n            }\n        }\n    }\n\n    private func fileURL(for id: String) -> URL {\n        let safe = safeFileName(for: id) + \".json\"\n        return notesRoot.appendingPathComponent(safe, isDirectory: false)\n    }\n\n    private func safeFileName(for id: String) -> String {\n        // Sanitize and add stable short hash suffix to avoid collisions\n        let allowed = CharacterSet(charactersIn: \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._\")\n        let sanitized = id.unicodeScalars.map { allowed.contains($0) ? Character($0) : \"_\" }.reduce(into: String(), { $0.append($1) })\n        let hash = fnv1a32(id)\n        return sanitized + \"-\" + String(format: \"%08x\", hash)\n    }\n\n    private static func safeFileNameStatic(for id: String) -> String {\n        let allowed = CharacterSet(charactersIn: \"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._\")\n        let sanitized = id.unicodeScalars.map { allowed.contains($0) ? Character($0) : \"_\" }.reduce(into: String(), { $0.append($1) })\n        let hash = fnv1a32Static(id)\n        return sanitized + \"-\" + String(format: \"%08x\", hash)\n    }\n\n    private func fnv1a32(_ s: String) -> UInt32 {\n        var h: UInt32 = 2166136261\n        for b in s.utf8 { h ^= UInt32(b); h = h &* 16777619 }\n        return h\n    }\n\n    private static func fnv1a32Static(_ s: String) -> UInt32 {\n        var h: UInt32 = 2166136261\n        for b in s.utf8 { h ^= UInt32(b); h = h &* 16777619 }\n        return h\n    }\n}\n"
  },
  {
    "path": "services/SessionPreferencesStore.swift",
    "content": "import CoreGraphics\nimport Foundation\n\n#if canImport(Darwin)\n  import Darwin\n#endif\n\n@MainActor\nfinal class SessionPreferencesStore: ObservableObject {\n  @Published var sessionsRoot: URL {\n    didSet { persist() }\n  }\n\n  @Published var notesRoot: URL {\n    didSet { persist() }\n  }\n\n  // New: Projects data directory (metadata + memberships)\n  @Published var projectsRoot: URL {\n    didSet { persist() }\n  }\n\n  @Published var codexCommandPath: String {\n    didSet { persistCLIPaths() }\n  }\n\n  @Published var claudeCommandPath: String {\n    didSet { persistCLIPaths() }\n  }\n\n  @Published var geminiCommandPath: String {\n    didSet { persistCLIPaths() }\n  }\n\n  @Published var cliCodexEnabled: Bool {\n    didSet { persistCLIEnablement() }\n  }\n\n  @Published var cliClaudeEnabled: Bool {\n    didSet { persistCLIEnablement() }\n  }\n\n  @Published var cliGeminiEnabled: Bool {\n    didSet { persistCLIEnablement() }\n  }\n\n  @Published var wizardPreferredProvider: String {\n    didSet { defaults.set(wizardPreferredProvider, forKey: Keys.wizardPreferredProvider) }\n  }\n\n  @Published var sessionPathConfigs: [SessionPathConfig] {\n    didSet { persistSessionPaths() }\n  }\n\n  private let defaults: UserDefaults\n  private let fileManager: FileManager\n  private struct Keys {\n    static let sessionsRootPath = \"codex.sessions.rootPath\"\n    static let notesRootPath = \"codex.notes.rootPath\"\n    static let projectsRootPath = \"codmate.projects.rootPath\"\n    static let codexCommandPath = \"codmate.command.codex\"\n    static let claudeCommandPath = \"codmate.command.claude\"\n    static let geminiCommandPath = \"codmate.command.gemini\"\n    static let cliCodexEnabled = \"codmate.cli.codex.enabled\"\n    static let cliClaudeEnabled = \"codmate.cli.claude.enabled\"\n    static let cliGeminiEnabled = \"codmate.cli.gemini.enabled\"\n    static let wizardPreferredProvider = \"codmate.wizard.preferredProvider\"\n    static let resumeUseEmbedded = \"codex.resume.useEmbedded\"\n    static let resumeCopyClipboard = \"codex.resume.copyClipboard\"\n    static let resumeExternalApp = \"codex.resume.externalApp\"\n    static let resumeSandboxMode = \"codex.resume.sandboxMode\"\n    static let resumeApprovalPolicy = \"codex.resume.approvalPolicy\"\n    static let resumeFullAuto = \"codex.resume.fullAuto\"\n    static let resumeDangerBypass = \"codex.resume.dangerBypass\"\n    static let autoAssignNewToSameProject = \"codex.projects.autoAssignNewToSame\"\n    static let timelineVisibleKinds = \"codex.timeline.visibleKinds\"\n    static let markdownVisibleKinds = \"codex.markdown.visibleKinds\"\n    static let enabledRemoteHosts = \"codex.remote.enabledHosts\"\n    static let searchPanelStyle = \"codmate.search.panelStyle\"\n    static let systemMenuVisibility = \"codmate.systemMenu.visibility\"\n    static let statusBarVisibility = \"codmate.statusbar.visibility\"\n    static let confirmBeforeQuit = \"codmate.app.confirmBeforeQuit\"\n    static let launchAtLogin = \"codmate.app.launchAtLogin\"\n    static let notifyCommitMessage = \"codmate.notifications.commitMessage\"\n    static let notifyTitleComment = \"codmate.notifications.titleComment\"\n    static let notifyCommandCopy = \"codmate.notifications.commandCopy\"\n    // Claude advanced\n    static let claudeDebug = \"claude.debug\"\n    static let claudeDebugFilter = \"claude.debug.filter\"\n    static let claudeVerbose = \"claude.verbose\"\n    static let claudePermissionMode = \"claude.permission.mode\"\n    static let claudeAllowedTools = \"claude.allowedTools\"\n    static let claudeDisallowedTools = \"claude.disallowedTools\"\n    static let claudeAddDirs = \"claude.addDirs\"\n    static let claudeIDE = \"claude.ide\"\n    static let claudeStrictMCP = \"claude.strictMCP\"\n    static let claudeFallbackModel = \"claude.fallbackModel\"\n    static let claudeSkipPermissions = \"claude.skipPermissions\"\n    static let claudeAllowSkipPermissions = \"claude.allowSkipPermissions\"\n    static let claudeAllowUnsandboxedCommands = \"claude.allowUnsandboxedCommands\"\n    // Default editor for quick file opens\n    static let defaultFileEditor = \"codmate.editor.default\"\n    // Git Review\n    static let gitShowLineNumbers = \"git.review.showLineNumbers\"\n    static let gitWrapText = \"git.review.wrapText\"\n    static let commitPromptTemplate = \"git.review.commitPromptTemplate\"\n    static let commitProviderId = \"git.review.commitProviderId\"  // provider id or nil for auto\n    static let commitModelId = \"git.review.commitModelId\"  // optional model id tied to provider\n    // Unified provider selections (CLIProxy-backed pickers)\n    static let codexProxyProviderId = \"codmate.codex.proxyProviderId\"\n    static let codexProxyModelId = \"codmate.codex.proxyModelId\"\n    static let claudeProxyProviderId = \"codmate.claude.proxyProviderId\"\n    static let claudeProxyModelId = \"codmate.claude.proxyModelId\"\n    static let geminiProxyProviderId = \"codmate.gemini.proxyProviderId\"\n    static let geminiProxyModelId = \"codmate.gemini.proxyModelId\"\n    static let claudeProxyModelAliases = \"codmate.claude.proxyModelAliases\"\n    // Terminal mode (DEV): use CLI console instead of shell\n    static let terminalUseCLIConsole = \"terminal.useCliConsole\"\n    static let terminalFontName = \"terminal.fontName\"\n    static let terminalFontSize = \"terminal.fontSize\"\n    static let terminalCursorStyle = \"terminal.cursorStyle\"\n    static let terminalThemeName = \"terminalThemeName\"\n    static let terminalThemeNameLight = \"terminalThemeNameLight\"\n    static let terminalUsePerAppearanceTheme = \"terminalUsePerAppearanceTheme\"\n    static let warpPromptEnabled = \"codmate.warp.promptTitle\"\n    // Local AI Server (formerly CLI Proxy)\n    static let localServerEnabled = \"codmate.localserver.enabled\"         // Public server switch\n    static let localServerReroute = \"codmate.localserver.reroute\"         // ReRoute built-ins\n    static let localServerReroute3P = \"codmate.localserver.reroute3p\"     // ReRoute 3P providers\n    static let localServerAutoStart = \"codmate.localserver.autostart\"     // On-demand/Auto logic\n    static let localServerPort = \"codmate.localserver.port\"\n    static let oauthProvidersEnabled = \"codmate.providers.oauth.enabled\"\n    static let oauthAccountsEnabled = \"codmate.providers.oauth.accounts.enabled\"\n    static let apiKeyProvidersEnabled = \"codmate.providers.apikey.enabled\"\n    // Legacy keys for migration\n    static let legacyUseCLIProxy = \"codmate.cliproxy.useForInternal\"\n    static let legacyCLIProxyPort = \"codmate.cliproxy.port\"\n    // Session path configurations\n    static let sessionPathConfigs = \"codmate.sessions.pathConfigs\"\n  }\n\n  init(\n    defaults: UserDefaults = .standard,\n    fileManager: FileManager = .default\n  ) {\n    self.defaults = defaults\n    self.fileManager = fileManager\n    // Get the real user home directory (not sandbox container)\n    let homeURL = SessionPreferencesStore.getRealUserHomeURL()\n\n    // Resolve sessions root without touching self (still used internally; no longer user-configurable)\n    let resolvedSessionsRoot: URL = {\n      if let storedRoot = defaults.string(forKey: Keys.sessionsRootPath) {\n        let url = URL(fileURLWithPath: storedRoot, isDirectory: true)\n        if fileManager.fileExists(atPath: url.path) {\n          return url\n        } else {\n          defaults.removeObject(forKey: Keys.sessionsRootPath)\n        }\n      }\n      return SessionPreferencesStore.defaultSessionsRoot(for: homeURL)\n    }()\n\n    // Resolve notes root (prefer stored path; else centralized ~/.codmate/notes)\n    let resolvedNotesRoot: URL = {\n      if let storedNotes = defaults.string(forKey: Keys.notesRootPath) {\n        let url = URL(fileURLWithPath: storedNotes, isDirectory: true)\n        if fileManager.fileExists(atPath: url.path) {\n          return url\n        } else {\n          defaults.removeObject(forKey: Keys.notesRootPath)\n        }\n      }\n      return SessionPreferencesStore.defaultNotesRoot(for: resolvedSessionsRoot)\n    }()\n\n    // Resolve projects root (prefer stored path; else ~/.codmate/projects)\n    let resolvedProjectsRoot: URL = {\n      if let stored = defaults.string(forKey: Keys.projectsRootPath) {\n        let url = URL(fileURLWithPath: stored, isDirectory: true)\n        if fileManager.fileExists(atPath: url.path) { return url }\n        defaults.removeObject(forKey: Keys.projectsRootPath)\n      }\n      return SessionPreferencesStore.defaultProjectsRoot(for: homeURL)\n    }()\n\n    let storedCodexCommandPath = defaults.string(forKey: Keys.codexCommandPath) ?? \"\"\n    let storedClaudeCommandPath = defaults.string(forKey: Keys.claudeCommandPath) ?? \"\"\n    let storedGeminiCommandPath = defaults.string(forKey: Keys.geminiCommandPath) ?? \"\"\n    let storedWizardProvider = defaults.string(forKey: Keys.wizardPreferredProvider) ?? \"\"\n\n    var storedCodexEnabled = defaults.object(forKey: Keys.cliCodexEnabled) as? Bool ?? true\n    let storedClaudeEnabled = defaults.object(forKey: Keys.cliClaudeEnabled) as? Bool ?? true\n    let storedGeminiEnabled = defaults.object(forKey: Keys.cliGeminiEnabled) as? Bool ?? true\n    if !storedCodexEnabled && !storedClaudeEnabled && !storedGeminiEnabled {\n      storedCodexEnabled = true\n      defaults.set(true, forKey: Keys.cliCodexEnabled)\n    }\n\n    // Assign after all are computed to avoid using self before init completes\n    self.sessionsRoot = resolvedSessionsRoot\n    self.notesRoot = resolvedNotesRoot\n    self.projectsRoot = resolvedProjectsRoot\n    self.codexCommandPath = storedCodexCommandPath\n    self.claudeCommandPath = storedClaudeCommandPath\n    self.geminiCommandPath = storedGeminiCommandPath\n    self.cliCodexEnabled = storedCodexEnabled\n    self.cliClaudeEnabled = storedClaudeEnabled\n    self.cliGeminiEnabled = storedGeminiEnabled\n    self.wizardPreferredProvider = storedWizardProvider\n    \n    // Load session path configs (with migration)\n    let loadedConfigs = Self.loadSessionPathConfigs(\n      defaults: defaults,\n      fileManager: fileManager,\n      homeURL: homeURL,\n      currentSessionsRoot: resolvedSessionsRoot\n    )\n    self.sessionPathConfigs = loadedConfigs\n    \n    // Resume defaults (defer assigning to self until value is finalized)\n    let resumeEmbedded: Bool\n    #if APPSTORE\n      if defaults.object(forKey: Keys.resumeUseEmbedded) as? Bool != false {\n        defaults.set(false, forKey: Keys.resumeUseEmbedded)\n      }\n      resumeEmbedded = false\n    #else\n      var embedded = defaults.object(forKey: Keys.resumeUseEmbedded) as? Bool ?? true\n      if AppSandbox.isEnabled && embedded {\n        embedded = false\n        defaults.set(false, forKey: Keys.resumeUseEmbedded)\n      }\n      resumeEmbedded = embedded\n    #endif\n    self.defaultResumeUseEmbeddedTerminal = resumeEmbedded\n    self.defaultResumeCopyToClipboard =\n      defaults.object(forKey: Keys.resumeCopyClipboard) as? Bool ?? true\n    ExternalTerminalProfileStore.shared.seedUserFileIfNeeded()\n    let appRaw = defaults.string(forKey: Keys.resumeExternalApp) ?? \"terminal\"\n    let resolvedExternalId = ExternalTerminalProfileStore.shared.resolvePreferredId(id: appRaw)\n    self.defaultResumeExternalAppId = resolvedExternalId\n\n    let statusBarRaw = defaults.string(forKey: Keys.statusBarVisibility) ?? StatusBarVisibility.hidden.rawValue\n    self.statusBarVisibility = StatusBarVisibility(rawValue: statusBarRaw) ?? .hidden\n\n    // Default editor for quick open (files)\n    let editorRaw = defaults.string(forKey: Keys.defaultFileEditor) ?? EditorApp.vscode.rawValue\n    var editor = EditorApp(rawValue: editorRaw) ?? .vscode\n    // If the stored editor is no longer installed, fall back to the first installed option when available.\n    let installedEditors = EditorApp.installedEditors\n    if !installedEditors.isEmpty, !installedEditors.contains(editor) {\n      editor = installedEditors[0]\n    }\n    self.defaultFileEditor = editor\n\n    // Git Review defaults\n    self.gitShowLineNumbers = defaults.object(forKey: Keys.gitShowLineNumbers) as? Bool ?? true\n    self.gitWrapText = defaults.object(forKey: Keys.gitWrapText) as? Bool ?? false\n    self.commitPromptTemplate = defaults.string(forKey: Keys.commitPromptTemplate) ?? \"\"\n    self.commitProviderId = defaults.string(forKey: Keys.commitProviderId)\n    self.commitModelId = defaults.string(forKey: Keys.commitModelId)\n    self.codexProxyProviderId = defaults.string(forKey: Keys.codexProxyProviderId)\n    self.codexProxyModelId = defaults.string(forKey: Keys.codexProxyModelId)\n    self.claudeProxyProviderId = defaults.string(forKey: Keys.claudeProxyProviderId)\n    self.claudeProxyModelId = defaults.string(forKey: Keys.claudeProxyModelId)\n    self.geminiProxyProviderId = defaults.string(forKey: Keys.geminiProxyProviderId)\n    self.geminiProxyModelId = defaults.string(forKey: Keys.geminiProxyModelId)\n    self.claudeProxyModelAliases =\n      SessionPreferencesStore.decodeJSON([String: [String: String]].self, defaults: defaults, key: Keys.claudeProxyModelAliases) ?? [:]\n\n    // Terminal mode (DEV) – compute locally first\n    let cliConsole: Bool\n    #if APPSTORE\n      if defaults.object(forKey: Keys.terminalUseCLIConsole) as? Bool != false {\n        defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n      }\n      cliConsole = false\n    #else\n      var console = defaults.object(forKey: Keys.terminalUseCLIConsole) as? Bool ?? false\n      if !AppSandbox.isEnabled && console {\n        console = false\n        defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n      }\n      if AppSandbox.isEnabled && console {\n        console = false\n        defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n      }\n      cliConsole = console\n    #endif\n    self.useEmbeddedCLIConsole = cliConsole\n    self.terminalFontName = defaults.string(forKey: Keys.terminalFontName) ?? \"\"\n    let storedFontSize = defaults.object(forKey: Keys.terminalFontSize) as? Double ?? 12.0\n    self.terminalFontSize = SessionPreferencesStore.clampFontSize(storedFontSize)\n    let storedCursor =\n      defaults.string(forKey: Keys.terminalCursorStyle)\n      ?? TerminalCursorStyleOption.blinkBlock.rawValue\n    self.terminalCursorStyleRaw = storedCursor\n    self.terminalThemeName = defaults.string(forKey: Keys.terminalThemeName) ?? \"Xcode Dark\"\n    self.terminalThemeNameLight = defaults.string(forKey: Keys.terminalThemeNameLight) ?? \"Xcode Light\"\n    self.terminalUsePerAppearanceTheme = defaults.object(forKey: Keys.terminalUsePerAppearanceTheme) as? Bool ?? true\n\n    // CLI policy defaults (with legacy value coercion)\n    let resolvedSandbox: SandboxMode = {\n      if let s = defaults.string(forKey: Keys.resumeSandboxMode),\n        let val = SessionPreferencesStore.coerceSandboxMode(s)\n      {\n        if val.rawValue != s { defaults.set(val.rawValue, forKey: Keys.resumeSandboxMode) }\n        return val\n      }\n      return .workspaceWrite\n    }()\n    let resolvedApproval: ApprovalPolicy = {\n      if let a = defaults.string(forKey: Keys.resumeApprovalPolicy),\n        let val = SessionPreferencesStore.coerceApprovalPolicy(a)\n      {\n        if val.rawValue != a { defaults.set(val.rawValue, forKey: Keys.resumeApprovalPolicy) }\n        return val\n      }\n      return .onRequest\n    }()\n\n    // Prefer Codex config.toml defaults when present (keeps CodMate in sync with Codex settings)\n    let codexSandbox = SessionPreferencesStore.readCodexTopLevelConfigString(\"sandbox_mode\")\n      .flatMap { SandboxMode(rawValue: $0) }\n    let codexApproval = SessionPreferencesStore.readCodexTopLevelConfigString(\"approval_policy\")\n      .flatMap { ApprovalPolicy(rawValue: $0) }\n\n    let finalSandbox = codexSandbox ?? resolvedSandbox\n    let finalApproval = codexApproval ?? resolvedApproval\n\n    self.defaultResumeSandboxMode = finalSandbox\n    self.defaultResumeApprovalPolicy = finalApproval\n    defaults.set(finalSandbox.rawValue, forKey: Keys.resumeSandboxMode)\n    defaults.set(finalApproval.rawValue, forKey: Keys.resumeApprovalPolicy)\n    self.defaultResumeFullAuto = defaults.object(forKey: Keys.resumeFullAuto) as? Bool ?? false\n    self.defaultResumeDangerBypass =\n      defaults.object(forKey: Keys.resumeDangerBypass) as? Bool ?? false\n    // Projects behaviors\n    self.autoAssignNewToSameProject =\n      defaults.object(forKey: Keys.autoAssignNewToSameProject) as? Bool ?? true\n\n    // Message visibility defaults\n    var resolvedTimelineKinds: Set<MessageVisibilityKind>\n    if let storedTimeline = defaults.array(forKey: Keys.timelineVisibleKinds) as? [String] {\n      resolvedTimelineKinds = Set(\n        storedTimeline.compactMap { MessageVisibilityKind.coerced(from: $0) })\n    } else {\n      resolvedTimelineKinds = MessageVisibilityKind.timelineDefault\n    }\n    resolvedTimelineKinds.remove(.turnContext)\n    resolvedTimelineKinds.remove(.environmentContext)\n    if resolvedTimelineKinds.contains(.tool) {\n      resolvedTimelineKinds.insert(.codeEdit)\n    }\n\n    var resolvedMarkdownKinds: Set<MessageVisibilityKind>\n    if let storedMarkdown = defaults.array(forKey: Keys.markdownVisibleKinds) as? [String] {\n      resolvedMarkdownKinds = Set(\n        storedMarkdown.compactMap { MessageVisibilityKind.coerced(from: $0) })\n    } else {\n      resolvedMarkdownKinds = MessageVisibilityKind.markdownDefault\n    }\n    resolvedMarkdownKinds.remove(.turnContext)\n    resolvedMarkdownKinds.remove(.environmentContext)\n    if resolvedMarkdownKinds.contains(.tool) {\n      resolvedMarkdownKinds.insert(.codeEdit)\n    }\n\n    self.timelineVisibleKinds = resolvedTimelineKinds\n    self.markdownVisibleKinds = resolvedMarkdownKinds\n    // Global search panel style: load stored preference when available, default to floating.\n    if let rawStyle = defaults.string(forKey: Keys.searchPanelStyle),\n       let style = GlobalSearchPanelStyle(rawValue: rawStyle) {\n      self.searchPanelStyle = style\n    } else {\n      self.searchPanelStyle = .floating\n    }\n    if let rawMenu = defaults.string(forKey: Keys.systemMenuVisibility),\n       let visibility = SystemMenuVisibility(rawValue: rawMenu) {\n      self.systemMenuVisibility = visibility\n    } else {\n      self.systemMenuVisibility = .visible\n    }\n    // App behavior defaults\n    self.confirmBeforeQuit = defaults.object(forKey: Keys.confirmBeforeQuit) as? Bool ?? true\n    self.launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false\n    // Notifications defaults\n    self.commitMessageNotificationsEnabled =\n      defaults.object(forKey: Keys.notifyCommitMessage) as? Bool ?? true\n    self.titleCommentNotificationsEnabled =\n      defaults.object(forKey: Keys.notifyTitleComment) as? Bool ?? true\n    self.commandCopyNotificationsEnabled =\n      defaults.object(forKey: Keys.notifyCommandCopy) as? Bool ?? true\n    // Claude advanced defaults\n    self.claudeDebug = defaults.object(forKey: Keys.claudeDebug) as? Bool ?? false\n    self.claudeDebugFilter = defaults.string(forKey: Keys.claudeDebugFilter) ?? \"\"\n    self.claudeVerbose = defaults.object(forKey: Keys.claudeVerbose) as? Bool ?? false\n    if let pm = defaults.string(forKey: Keys.claudePermissionMode) {\n      self.claudePermissionMode = ClaudePermissionMode(rawValue: pm) ?? .default\n    } else {\n      self.claudePermissionMode = .default\n    }\n    self.claudeAllowedTools = defaults.string(forKey: Keys.claudeAllowedTools) ?? \"\"\n    self.claudeDisallowedTools = defaults.string(forKey: Keys.claudeDisallowedTools) ?? \"\"\n    self.claudeAddDirs = defaults.string(forKey: Keys.claudeAddDirs) ?? \"\"\n    self.claudeIDE = defaults.object(forKey: Keys.claudeIDE) as? Bool ?? false\n    self.claudeStrictMCP = defaults.object(forKey: Keys.claudeStrictMCP) as? Bool ?? false\n    self.claudeFallbackModel = defaults.string(forKey: Keys.claudeFallbackModel) ?? \"\"\n    self.claudeSkipPermissions = defaults.object(forKey: Keys.claudeSkipPermissions) as? Bool ?? false\n    self.claudeAllowSkipPermissions = defaults.object(forKey: Keys.claudeAllowSkipPermissions) as? Bool ?? false\n    self.claudeAllowUnsandboxedCommands = defaults.object(forKey: Keys.claudeAllowUnsandboxedCommands) as? Bool ?? false\n\n    // Remote hosts\n    let storedHosts = defaults.array(forKey: Keys.enabledRemoteHosts) as? [String] ?? []\n    self.enabledRemoteHosts = Set(storedHosts)\n\n    self.promptForWarpTitle = defaults.object(forKey: Keys.warpPromptEnabled) as? Bool ?? false\n\n    // Local Server Defaults & Migration\n    let legacyPort = defaults.object(forKey: Keys.legacyCLIProxyPort) as? Int\n    let legacyUse = defaults.object(forKey: Keys.legacyUseCLIProxy) as? Bool ?? false\n\n    self.localServerPort = defaults.object(forKey: Keys.localServerPort) as? Int ?? legacyPort ?? Int(CLIProxyService.defaultPort)\n    self.localServerEnabled = defaults.object(forKey: Keys.localServerEnabled) as? Bool ?? false\n    self.localServerReroute = defaults.object(forKey: Keys.localServerReroute) as? Bool ?? legacyUse\n    // Temporarily disable rerouting API key providers until finalized.\n    self.localServerReroute3P = false\n    defaults.set(false, forKey: Keys.localServerReroute3P)\n    // Default auto-start to true if public server is enabled, or if reroute is on (on-demand implied)\n    self.localServerAutoStart = defaults.object(forKey: Keys.localServerAutoStart) as? Bool ?? true\n\n    let oauthEnabled = defaults.array(forKey: Keys.oauthProvidersEnabled) as? [String] ?? []\n    self.oauthProvidersEnabled = Set(oauthEnabled)\n    let oauthAccountsEnabled = defaults.array(forKey: Keys.oauthAccountsEnabled) as? [String] ?? []\n    self.oauthAccountsEnabled = Set(oauthAccountsEnabled)\n    let apiKeyEnabled = defaults.array(forKey: Keys.apiKeyProvidersEnabled) as? [String] ?? []\n    self.apiKeyProvidersEnabled = Set(apiKeyEnabled)\n\n    Task { @MainActor [weak self] in\n      await self?.normalizeProviderSelectionsIfNeeded()\n    }\n\n    // Now that all properties are initialized, ensure directories exist\n    ensureDirectoryExists(sessionsRoot)\n    ensureDirectoryExists(notesRoot)\n    }\n\n  private func normalizeProviderSelectionsIfNeeded() async {\n    let registry = ProvidersRegistryService()\n    let providers = await registry.listProviders()\n    let normalize: (String?) -> String? = { UnifiedProviderID.normalize($0, registryProviders: providers) }\n\n    let nextCommit = normalize(commitProviderId)\n    if nextCommit != commitProviderId { commitProviderId = nextCommit }\n\n    let nextCodex = normalize(codexProxyProviderId)\n    if nextCodex != codexProxyProviderId { codexProxyProviderId = nextCodex }\n\n    let nextClaude = normalize(claudeProxyProviderId)\n    if nextClaude != claudeProxyProviderId { claudeProxyProviderId = nextClaude }\n\n    let nextGemini = normalize(geminiProxyProviderId)\n    if nextGemini != geminiProxyProviderId { geminiProxyProviderId = nextGemini }\n  }\n\n  private func persist() {\n    defaults.set(sessionsRoot.path, forKey: Keys.sessionsRootPath)\n    defaults.set(notesRoot.path, forKey: Keys.notesRootPath)\n    defaults.set(projectsRoot.path, forKey: Keys.projectsRootPath)\n  }\n  \n  private func persistSessionPaths() {\n    persistJSON(sessionPathConfigs, key: Keys.sessionPathConfigs)\n  }\n\n  private func persistCLIEnablement() {\n    defaults.set(cliCodexEnabled, forKey: Keys.cliCodexEnabled)\n    defaults.set(cliClaudeEnabled, forKey: Keys.cliClaudeEnabled)\n    defaults.set(cliGeminiEnabled, forKey: Keys.cliGeminiEnabled)\n  }\n\n  private static func decodeJSON<T: Decodable>(_ type: T.Type, defaults: UserDefaults, key: String) -> T? {\n    guard let data = defaults.data(forKey: key) else { return nil }\n    return try? JSONDecoder().decode(T.self, from: data)\n  }\n\n  private func persistJSON<T: Encodable>(_ value: T, key: String) {\n    guard let data = try? JSONEncoder().encode(value) else { return }\n    defaults.set(data, forKey: key)\n  }\n\n  private func persistCLIPaths() {\n    setOptionalPath(codexCommandPath, key: Keys.codexCommandPath)\n    setOptionalPath(claudeCommandPath, key: Keys.claudeCommandPath)\n    setOptionalPath(geminiCommandPath, key: Keys.geminiCommandPath)\n  }\n\n  private func setOptionalPath(_ value: String, key: String) {\n    let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n    if trimmed.isEmpty {\n      defaults.removeObject(forKey: key)\n    } else {\n      defaults.set(trimmed, forKey: key)\n    }\n  }\n\n    private func ensureDirectoryExists(_ url: URL) {\n        var isDir: ObjCBool = false\n        if fileManager.fileExists(atPath: url.path, isDirectory: &isDir) {\n            if isDir.boolValue { return }\n            // Remove non-directory item occupying the expected path\n            try? fileManager.removeItem(at: url)\n        }\n        try? fileManager.createDirectory(at: url, withIntermediateDirectories: true)\n  }\n\n  convenience init(defaults: UserDefaults = .standard) {\n    self.init(defaults: defaults, fileManager: .default)\n  }\n\n  private static func clampFontSize(_ value: Double) -> Double {\n    return min(max(value, 8.0), 32.0)\n  }\n\n  static func defaultSessionsRoot(for homeDirectory: URL) -> URL {\n    homeDirectory\n      .appendingPathComponent(\".codex\", isDirectory: true)\n      .appendingPathComponent(\"sessions\", isDirectory: true)\n  }\n\n  static func defaultNotesRoot(for sessionsRoot: URL) -> URL {\n    // Use real home directory, not sandbox container\n    let home = getRealUserHomeURL()\n    return home.appendingPathComponent(\".codmate\", isDirectory: true)\n      .appendingPathComponent(\"notes\", isDirectory: true)\n  }\n\n  static func defaultProjectsRoot(for homeDirectory: URL) -> URL {\n    homeDirectory\n      .appendingPathComponent(\".codmate\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n  }\n\n  static func isCommitMessageNotificationEnabled(defaults: UserDefaults = .standard) -> Bool {\n    defaults.object(forKey: Keys.notifyCommitMessage) as? Bool ?? true\n  }\n\n  static func isTitleCommentNotificationEnabled(defaults: UserDefaults = .standard) -> Bool {\n    defaults.object(forKey: Keys.notifyTitleComment) as? Bool ?? true\n  }\n\n  static func isCommandCopyNotificationEnabled(defaults: UserDefaults = .standard) -> Bool {\n    defaults.object(forKey: Keys.notifyCommandCopy) as? Bool ?? true\n  }\n\n  func resolvedCommandOverrideURL(for kind: SessionSource.Kind) -> URL? {\n    let raw: String\n    switch kind {\n    case .codex: raw = codexCommandPath\n    case .claude: raw = claudeCommandPath\n    case .gemini: raw = geminiCommandPath\n    }\n    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return nil }\n    let expanded = expandHomePath(trimmed)\n    guard expanded.contains(\"/\") else { return nil }\n    let url = URL(fileURLWithPath: expanded)\n    return fileManager.isExecutableFile(atPath: url.path) ? url : nil\n  }\n\n  func preferredExecutablePath(for kind: SessionSource.Kind) -> String {\n    if let override = resolvedCommandOverrideURL(for: kind) {\n      return override.path\n    }\n    return kind.cliExecutableName\n  }\n\n  /// Get the real user home directory (not sandbox container)\n  nonisolated static func getRealUserHomeURL() -> URL {\n    #if canImport(Darwin)\n      if let homeDir = getpwuid(getuid())?.pointee.pw_dir {\n        let path = String(cString: homeDir)\n        return URL(fileURLWithPath: path, isDirectory: true)\n      }\n    #endif\n    if let home = ProcessInfo.processInfo.environment[\"HOME\"] {\n      return URL(fileURLWithPath: home, isDirectory: true)\n    }\n    return FileManager.default.homeDirectoryForCurrentUser\n  }\n\n  private func expandHomePath(_ path: String) -> String {\n    if path.hasPrefix(\"~\") {\n      return (path as NSString).expandingTildeInPath\n    }\n    if path.contains(\"$HOME\") {\n      return path.replacingOccurrences(of: \"$HOME\", with: NSHomeDirectory())\n    }\n    return path\n  }\n\n  // Removed: default executable URLs – resolution uses PATH\n\n  // MARK: - Legacy coercion helpers\n  private static func coerceSandboxMode(_ raw: String) -> SandboxMode? {\n    let v = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    if let exact = SandboxMode(rawValue: v) { return exact }\n    switch v {\n    case \"full\": return SandboxMode.dangerFullAccess\n    case \"rw\", \"write\": return SandboxMode.workspaceWrite\n    case \"ro\", \"read\": return SandboxMode.readOnly\n    default: return nil\n    }\n  }\n\n  private static func coerceApprovalPolicy(_ raw: String) -> ApprovalPolicy? {\n    let v = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n    if let exact = ApprovalPolicy(rawValue: v) { return exact }\n    switch v {\n    case \"auto\": return ApprovalPolicy.onRequest\n    case \"fail\", \"onfail\": return ApprovalPolicy.onFailure\n    default: return nil\n    }\n  }\n\n  private static func readCodexTopLevelConfigString(_ key: String) -> String? {\n    let url = getRealUserHomeURL()\n      .appendingPathComponent(\".codex\", isDirectory: true)\n      .appendingPathComponent(\"config.toml\", isDirectory: false)\n    guard let text = try? String(contentsOf: url, encoding: .utf8) else { return nil }\n    for raw in text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init) {\n      let trimmed = raw.trimmingCharacters(in: CharacterSet.whitespaces)\n      guard trimmed.hasPrefix(key + \" \") || trimmed.hasPrefix(key + \"=\") else { continue }\n      guard let eq = trimmed.firstIndex(of: \"=\") else { continue }\n      var value = String(trimmed[trimmed.index(after: eq)...])\n        .trimmingCharacters(in: CharacterSet.whitespaces)\n      if value.hasPrefix(\"\\\"\") && value.hasSuffix(\"\\\"\") {\n        value.removeFirst()\n        value.removeLast()\n      }\n      let finalValue = value.trimmingCharacters(in: .whitespacesAndNewlines)\n      if !finalValue.isEmpty { return finalValue }\n    }\n    return nil\n  }\n\n  // MARK: - Resume Preferences\n  @Published var defaultResumeUseEmbeddedTerminal: Bool {\n    didSet {\n      #if APPSTORE\n        if defaultResumeUseEmbeddedTerminal {\n          defaultResumeUseEmbeddedTerminal = false\n          defaults.set(false, forKey: Keys.resumeUseEmbedded)\n          return\n        }\n      #endif\n      if AppSandbox.isEnabled, defaultResumeUseEmbeddedTerminal {\n        defaultResumeUseEmbeddedTerminal = false\n        defaults.set(false, forKey: Keys.resumeUseEmbedded)\n        return\n      }\n      defaults.set(defaultResumeUseEmbeddedTerminal, forKey: Keys.resumeUseEmbedded)\n    }\n  }\n  @Published var defaultResumeCopyToClipboard: Bool {\n    didSet { defaults.set(defaultResumeCopyToClipboard, forKey: Keys.resumeCopyClipboard) }\n  }\n  @Published var defaultResumeExternalAppId: String {\n    didSet { defaults.set(defaultResumeExternalAppId, forKey: Keys.resumeExternalApp) }\n  }\n  @Published var promptForWarpTitle: Bool {\n    didSet { defaults.set(promptForWarpTitle, forKey: Keys.warpPromptEnabled) }\n  }\n\n  // MARK: - Local AI Server\n  @Published var localServerEnabled: Bool {\n    didSet { defaults.set(localServerEnabled, forKey: Keys.localServerEnabled) }\n  }\n  @Published var localServerReroute: Bool {\n    didSet { defaults.set(localServerReroute, forKey: Keys.localServerReroute) }\n  }\n  @Published var localServerReroute3P: Bool {\n    didSet { defaults.set(localServerReroute3P, forKey: Keys.localServerReroute3P) }\n  }\n  @Published var localServerAutoStart: Bool {\n    didSet { defaults.set(localServerAutoStart, forKey: Keys.localServerAutoStart) }\n  }\n  @Published var localServerPort: Int {\n    didSet { defaults.set(localServerPort, forKey: Keys.localServerPort) }\n  }\n  @Published var oauthProvidersEnabled: Set<String> {\n    didSet { defaults.set(Array(oauthProvidersEnabled), forKey: Keys.oauthProvidersEnabled) }\n  }\n  @Published var oauthAccountsEnabled: Set<String> {\n    didSet { defaults.set(Array(oauthAccountsEnabled), forKey: Keys.oauthAccountsEnabled) }\n  }\n  @Published var apiKeyProvidersEnabled: Set<String> {\n    didSet { defaults.set(Array(apiKeyProvidersEnabled), forKey: Keys.apiKeyProvidersEnabled) }\n  }\n\n  @Published var defaultResumeSandboxMode: SandboxMode {\n    didSet { defaults.set(defaultResumeSandboxMode.rawValue, forKey: Keys.resumeSandboxMode) }\n  }\n  @Published var defaultResumeApprovalPolicy: ApprovalPolicy {\n    didSet { defaults.set(defaultResumeApprovalPolicy.rawValue, forKey: Keys.resumeApprovalPolicy) }\n  }\n  @Published var defaultResumeFullAuto: Bool {\n    didSet { defaults.set(defaultResumeFullAuto, forKey: Keys.resumeFullAuto) }\n  }\n  @Published var defaultResumeDangerBypass: Bool {\n    didSet { defaults.set(defaultResumeDangerBypass, forKey: Keys.resumeDangerBypass) }\n  }\n\n  // Projects: auto-assign new sessions from detail to same project (default ON)\n  @Published var autoAssignNewToSameProject: Bool {\n    didSet { defaults.set(autoAssignNewToSameProject, forKey: Keys.autoAssignNewToSameProject) }\n  }\n\n  // Visibility for timeline and export markdown\n  @Published var timelineVisibleKinds: Set<MessageVisibilityKind> = MessageVisibilityKind\n    .timelineDefault\n  {\n    didSet {\n      defaults.set(\n        Array(timelineVisibleKinds.map { $0.rawValue }), forKey: Keys.timelineVisibleKinds)\n    }\n  }\n  @Published var markdownVisibleKinds: Set<MessageVisibilityKind> = MessageVisibilityKind\n    .markdownDefault\n  {\n    didSet {\n      defaults.set(\n        Array(markdownVisibleKinds.map { $0.rawValue }), forKey: Keys.markdownVisibleKinds)\n    }\n  }\n\n  @Published var searchPanelStyle: GlobalSearchPanelStyle {\n    didSet { defaults.set(searchPanelStyle.rawValue, forKey: Keys.searchPanelStyle) }\n  }\n\n  @Published var statusBarVisibility: StatusBarVisibility {\n    didSet { defaults.set(statusBarVisibility.rawValue, forKey: Keys.statusBarVisibility) }\n  }\n\n  @Published var systemMenuVisibility: SystemMenuVisibility {\n    didSet { defaults.set(systemMenuVisibility.rawValue, forKey: Keys.systemMenuVisibility) }\n  }\n\n  @Published var confirmBeforeQuit: Bool {\n    didSet { defaults.set(confirmBeforeQuit, forKey: Keys.confirmBeforeQuit) }\n  }\n\n  @Published var launchAtLogin: Bool {\n    didSet {\n      defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)\n      LaunchAtLoginService.shared.setLaunchAtLogin(enabled: launchAtLogin)\n    }\n  }\n\n  // MARK: - Notifications (App)\n  @Published var commitMessageNotificationsEnabled: Bool {\n    didSet { defaults.set(commitMessageNotificationsEnabled, forKey: Keys.notifyCommitMessage) }\n  }\n  @Published var titleCommentNotificationsEnabled: Bool {\n    didSet { defaults.set(titleCommentNotificationsEnabled, forKey: Keys.notifyTitleComment) }\n  }\n  @Published var commandCopyNotificationsEnabled: Bool {\n    didSet { defaults.set(commandCopyNotificationsEnabled, forKey: Keys.notifyCommandCopy) }\n  }\n\n  @Published var enabledRemoteHosts: Set<String> = [] {\n    didSet { defaults.set(Array(enabledRemoteHosts), forKey: Keys.enabledRemoteHosts) }\n  }\n\n  var isEmbeddedTerminalEnabled: Bool {\n    !AppSandbox.isEnabled && defaultResumeUseEmbeddedTerminal\n  }\n\n  var resumeOptions: ResumeOptions {\n    var opt = ResumeOptions(\n      sandbox: defaultResumeSandboxMode,\n      approval: defaultResumeApprovalPolicy,\n      fullAuto: defaultResumeFullAuto,\n      dangerouslyBypass: defaultResumeDangerBypass\n    )\n    // Carry Claude advanced flags for launch\n    opt.claudeDebug = claudeDebug\n    opt.claudeDebugFilter = claudeDebugFilter.isEmpty ? nil : claudeDebugFilter\n    opt.claudeVerbose = claudeVerbose\n    opt.claudePermissionMode = claudePermissionMode\n    opt.claudeAllowedTools = claudeAllowedTools.isEmpty ? nil : claudeAllowedTools\n    opt.claudeDisallowedTools = claudeDisallowedTools.isEmpty ? nil : claudeDisallowedTools\n    opt.claudeAddDirs = claudeAddDirs.isEmpty ? nil : claudeAddDirs\n    opt.claudeIDE = claudeIDE\n    opt.claudeStrictMCP = claudeStrictMCP\n    opt.claudeFallbackModel = claudeFallbackModel.isEmpty ? nil : claudeFallbackModel\n    opt.claudeSkipPermissions = claudeSkipPermissions\n    opt.claudeAllowSkipPermissions = claudeAllowSkipPermissions\n    opt.claudeAllowUnsandboxedCommands = claudeAllowUnsandboxedCommands\n    return opt\n  }\n\n  // MARK: - Claude Advanced (Published)\n  @Published var claudeDebug: Bool {\n    didSet { defaults.set(claudeDebug, forKey: Keys.claudeDebug) }\n  }\n  @Published var claudeDebugFilter: String {\n    didSet { defaults.set(claudeDebugFilter, forKey: Keys.claudeDebugFilter) }\n  }\n  @Published var claudeVerbose: Bool {\n    didSet { defaults.set(claudeVerbose, forKey: Keys.claudeVerbose) }\n  }\n  @Published var claudePermissionMode: ClaudePermissionMode {\n    didSet { defaults.set(claudePermissionMode.rawValue, forKey: Keys.claudePermissionMode) }\n  }\n  @Published var claudeAllowedTools: String {\n    didSet { defaults.set(claudeAllowedTools, forKey: Keys.claudeAllowedTools) }\n  }\n  @Published var claudeDisallowedTools: String {\n    didSet { defaults.set(claudeDisallowedTools, forKey: Keys.claudeDisallowedTools) }\n  }\n  @Published var claudeAddDirs: String {\n    didSet { defaults.set(claudeAddDirs, forKey: Keys.claudeAddDirs) }\n  }\n  @Published var claudeIDE: Bool { didSet { defaults.set(claudeIDE, forKey: Keys.claudeIDE) } }\n  @Published var claudeStrictMCP: Bool {\n    didSet { defaults.set(claudeStrictMCP, forKey: Keys.claudeStrictMCP) }\n  }\n  @Published var claudeFallbackModel: String {\n    didSet { defaults.set(claudeFallbackModel, forKey: Keys.claudeFallbackModel) }\n  }\n  @Published var claudeSkipPermissions: Bool {\n    didSet { defaults.set(claudeSkipPermissions, forKey: Keys.claudeSkipPermissions) }\n  }\n  @Published var claudeAllowSkipPermissions: Bool {\n    didSet { defaults.set(claudeAllowSkipPermissions, forKey: Keys.claudeAllowSkipPermissions) }\n  }\n  @Published var claudeAllowUnsandboxedCommands: Bool {\n    didSet {\n      defaults.set(claudeAllowUnsandboxedCommands, forKey: Keys.claudeAllowUnsandboxedCommands)\n    }\n  }\n\n  // MARK: - Editor Preferences\n  @Published var defaultFileEditor: EditorApp {\n    didSet { defaults.set(defaultFileEditor.rawValue, forKey: Keys.defaultFileEditor) }\n  }\n\n  // MARK: - Git Review\n  @Published var gitShowLineNumbers: Bool {\n    didSet { defaults.set(gitShowLineNumbers, forKey: Keys.gitShowLineNumbers) }\n  }\n  @Published var gitWrapText: Bool {\n    didSet { defaults.set(gitWrapText, forKey: Keys.gitWrapText) }\n  }\n  @Published var commitPromptTemplate: String {\n    didSet { defaults.set(commitPromptTemplate, forKey: Keys.commitPromptTemplate) }\n  }\n  @Published var commitProviderId: String? {\n    didSet { defaults.set(commitProviderId, forKey: Keys.commitProviderId) }\n  }\n  @Published var commitModelId: String? {\n    didSet { defaults.set(commitModelId, forKey: Keys.commitModelId) }\n  }\n  @Published var codexProxyProviderId: String? {\n    didSet { defaults.set(codexProxyProviderId, forKey: Keys.codexProxyProviderId) }\n  }\n  @Published var codexProxyModelId: String? {\n    didSet { defaults.set(codexProxyModelId, forKey: Keys.codexProxyModelId) }\n  }\n  @Published var claudeProxyProviderId: String? {\n    didSet { defaults.set(claudeProxyProviderId, forKey: Keys.claudeProxyProviderId) }\n  }\n  @Published var claudeProxyModelId: String? {\n    didSet { defaults.set(claudeProxyModelId, forKey: Keys.claudeProxyModelId) }\n  }\n  @Published var geminiProxyProviderId: String? {\n    didSet { defaults.set(geminiProxyProviderId, forKey: Keys.geminiProxyProviderId) }\n  }\n  @Published var geminiProxyModelId: String? {\n    didSet { defaults.set(geminiProxyModelId, forKey: Keys.geminiProxyModelId) }\n  }\n  @Published var claudeProxyModelAliases: [String: [String: String]] {\n    didSet { persistJSON(claudeProxyModelAliases, key: Keys.claudeProxyModelAliases) }\n  }\n\n  // MARK: - Terminal (DEV)\n  @Published var useEmbeddedCLIConsole: Bool {\n    didSet {\n      #if APPSTORE\n        if useEmbeddedCLIConsole {\n          useEmbeddedCLIConsole = false\n          defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n          return\n        }\n      #endif\n      if !AppSandbox.isEnabled, useEmbeddedCLIConsole {\n        useEmbeddedCLIConsole = false\n        defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n        return\n      }\n      if AppSandbox.isEnabled, useEmbeddedCLIConsole {\n        useEmbeddedCLIConsole = false\n        defaults.set(false, forKey: Keys.terminalUseCLIConsole)\n        return\n      }\n      defaults.set(useEmbeddedCLIConsole, forKey: Keys.terminalUseCLIConsole)\n    }\n  }\n\n  @Published var terminalFontName: String {\n    didSet {\n      defaults.set(terminalFontName, forKey: Keys.terminalFontName)\n    }\n  }\n\n  @Published var terminalFontSize: Double {\n    didSet {\n      let clamped = SessionPreferencesStore.clampFontSize(terminalFontSize)\n      if clamped != terminalFontSize {\n        terminalFontSize = clamped\n        return\n      }\n      defaults.set(terminalFontSize, forKey: Keys.terminalFontSize)\n    }\n  }\n\n  @Published var terminalCursorStyleRaw: String {\n    didSet {\n      defaults.set(terminalCursorStyleRaw, forKey: Keys.terminalCursorStyle)\n    }\n  }\n\n  @Published var terminalThemeName: String {\n    didSet {\n      defaults.set(terminalThemeName, forKey: Keys.terminalThemeName)\n    }\n  }\n\n  @Published var terminalThemeNameLight: String {\n    didSet {\n      defaults.set(terminalThemeNameLight, forKey: Keys.terminalThemeNameLight)\n    }\n  }\n\n  @Published var terminalUsePerAppearanceTheme: Bool {\n    didSet {\n      defaults.set(terminalUsePerAppearanceTheme, forKey: Keys.terminalUsePerAppearanceTheme)\n    }\n  }\n\n  var terminalCursorStyleOption: TerminalCursorStyleOption {\n    get { TerminalCursorStyleOption(rawValue: terminalCursorStyleRaw) ?? .blinkBlock }\n    set { terminalCursorStyleRaw = newValue.rawValue }\n  }\n\n  var clampedTerminalFontSize: CGFloat {\n    CGFloat(SessionPreferencesStore.clampFontSize(terminalFontSize))\n  }\n  \n  // MARK: - Session Path Configs\n  \n  /// Load session path configs with migration from legacy settings\n  private static func loadSessionPathConfigs(\n    defaults: UserDefaults,\n    fileManager: FileManager,\n    homeURL: URL,\n    currentSessionsRoot: URL\n  ) -> [SessionPathConfig] {\n    // Try to load existing configs\n    if let data = defaults.data(forKey: Keys.sessionPathConfigs),\n       let configs = try? JSONDecoder().decode([SessionPathConfig].self, from: data),\n       !configs.isEmpty {\n      return applyInternalWizardIgnore(to: configs, homeURL: homeURL)\n    }\n    \n    // Migration: generate default configs\n    let codexPath = currentSessionsRoot.path\n    let claudePath = homeURL\n      .appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"projects\", isDirectory: true)\n      .path\n    let geminiPath = homeURL\n      .appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"tmp\", isDirectory: true)\n      .path\n    \n    let defaults: [SessionPathConfig] = [\n      SessionPathConfig(\n        kind: .codex,\n        path: codexPath,\n        enabled: true,\n        displayName: \"Codex\"\n      ),\n      SessionPathConfig(\n        kind: .claude,\n        path: claudePath,\n        enabled: true,\n        displayName: \"Claude\"\n      ),\n      SessionPathConfig(\n        kind: .gemini,\n        path: geminiPath,\n        enabled: true,\n        displayName: \"Gemini\"\n      )\n    ]\n    return applyInternalWizardIgnore(to: defaults, homeURL: homeURL)\n  }\n\n  private static func applyInternalWizardIgnore(\n    to configs: [SessionPathConfig],\n    homeURL: URL\n  ) -> [SessionPathConfig] {\n    let ignored = InternalWizardPaths.ignoredSubpaths(home: homeURL)\n    guard !ignored.isEmpty else { return configs }\n    return configs.map { config in\n      var updated = config\n      for path in ignored where !updated.ignoredSubpaths.contains(path) {\n        updated.ignoredSubpaths.append(path)\n      }\n      return updated\n    }\n  }\n  \n  /// Get enabled session paths for a specific kind\n  func enabledSessionPaths(for kind: SessionSource.Kind) -> [URL] {\n    sessionPathConfigs\n      .filter { $0.kind == kind && $0.enabled }\n      .compactMap { URL(fileURLWithPath: $0.path) }\n  }\n  \n  /// Get the primary enabled path for a kind (first enabled, or default if none)\n  func primarySessionPath(for kind: SessionSource.Kind) -> URL? {\n    if let enabled = enabledSessionPaths(for: kind).first {\n      return enabled\n    }\n    // Fallback to default path\n    let home = Self.getRealUserHomeURL()\n    switch kind {\n    case .codex:\n      return sessionsRoot\n    case .claude:\n      return home\n        .appendingPathComponent(\".claude\", isDirectory: true)\n        .appendingPathComponent(\"projects\", isDirectory: true)\n    case .gemini:\n      return home\n        .appendingPathComponent(\".gemini\", isDirectory: true)\n        .appendingPathComponent(\"tmp\", isDirectory: true)\n    }\n  }\n  \n  /// Get the config for a specific kind (default or custom)\n  func config(for kind: SessionSource.Kind) -> SessionPathConfig? {\n    sessionPathConfigs.first { $0.kind == kind && $0.isDefault }\n  }\n  \n  /// Check if a path should be ignored based on config\n  func shouldIgnorePath(_ absolutePath: String, under config: SessionPathConfig) -> Bool {\n    guard config.enabled else { return true }\n    let lowercasedPath = absolutePath.lowercased()\n    for ignored in config.ignoredSubpaths {\n      let needle = ignored.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !needle.isEmpty else { continue }\n      if lowercasedPath.contains(needle.lowercased()) {\n        return true\n      }\n    }\n    return false\n  }\n\n  func isCLIEnabled(_ kind: SessionSource.Kind) -> Bool {\n    switch kind {\n    case .codex: return cliCodexEnabled\n    case .claude: return cliClaudeEnabled\n    case .gemini: return cliGeminiEnabled\n    }\n  }\n\n  func setCLIEnabled(_ kind: SessionSource.Kind, enabled: Bool) -> Bool {\n    if enabled {\n      switch kind {\n      case .codex: cliCodexEnabled = true\n      case .claude: cliClaudeEnabled = true\n      case .gemini: cliGeminiEnabled = true\n      }\n      return true\n    }\n    let enabledCount = [cliCodexEnabled, cliClaudeEnabled, cliGeminiEnabled].filter { $0 }.count\n    if enabledCount <= 1 {\n      return false\n    }\n    switch kind {\n    case .codex: cliCodexEnabled = false\n    case .claude: cliClaudeEnabled = false\n    case .gemini: cliGeminiEnabled = false\n    }\n    return true\n  }\n\n  nonisolated static func isCLIEnabled(_ kind: SessionSource.Kind, defaults: UserDefaults = .standard) -> Bool {\n    let codex = defaults.object(forKey: Keys.cliCodexEnabled) as? Bool ?? true\n    let claude = defaults.object(forKey: Keys.cliClaudeEnabled) as? Bool ?? true\n    let gemini = defaults.object(forKey: Keys.cliGeminiEnabled) as? Bool ?? true\n    if !codex && !claude && !gemini {\n      return kind == .codex\n    }\n    switch kind {\n    case .codex: return codex\n    case .claude: return claude\n    case .gemini: return gemini\n    }\n  }\n}\n"
  },
  {
    "path": "services/SessionProvider.swift",
    "content": "import Foundation\n\nenum SessionProviderCachePolicy: Sendable {\n  case cacheOnly\n  case refresh\n}\n\nstruct SessionProviderContext: Sendable {\n  let scope: SessionLoadScope\n  /// Local sessions root (Codex) if applicable.\n  let sessionsRoot: URL?\n  /// Enabled remote hosts for remote providers.\n  let enabledRemoteHosts: Set<String>\n  /// Optional project directories (canonical paths) to narrow enumeration.\n  let projectDirectories: [String]?\n  /// Current date dimension for date-range filtering (created vs. updated).\n  let dateDimension: DateDimension\n  /// Optional date range filter (start/end, inclusive) derived from UI selection.\n  let dateRange: (Date, Date)?\n  /// Optional project filter (single project preferred).\n  let projectIds: Set<String>?\n  /// When true, bypass cache-only shortcuts and touch the filesystem to discover new sessions.\n  let forceFilesystemScan: Bool\n  let cachePolicy: SessionProviderCachePolicy\n  /// Ignored path substrings for filtering during enumeration.\n  let ignoredPaths: [String]\n}\n\nstruct SessionProviderResult: Sendable {\n  let summaries: [SessionSummary]\n  /// Best-effort coverage info if the provider can surface it (e.g., SQLite meta).\n  let coverage: SessionIndexCoverage?\n  /// True when results came fully from cache without touching the filesystem.\n  let cacheHit: Bool\n}\n\nprotocol SessionProvider: Sendable {\n  var kind: SessionSource.Kind { get }\n  var identifier: String { get }\n  var label: String { get }\n  func load(context: SessionProviderContext) async throws -> SessionProviderResult\n}\n"
  },
  {
    "path": "services/SessionRipgrepStore.swift",
    "content": "import Foundation\nimport OSLog\n\nactor SessionRipgrepStore {\n    struct Diagnostics: Sendable {\n        let cachedCoverageEntries: Int\n        let cachedToolEntries: Int\n        let cachedTokenEntries: Int\n        let lastCoverageScan: Date?\n        let lastToolScan: Date?\n        let lastTokenScan: Date?\n    }\n\n    private struct CoverageCacheKey: Hashable {\n        let path: String\n        let monthKey: String\n    }\n\n    private struct CoverageEntry {\n        let mtime: Date?\n        let days: Set<Int>\n    }\n\n    private struct ToolEntry {\n        let mtime: Date?\n        let count: Int\n    }\n\n    private struct TokenEntry {\n        let mtime: Date?\n        let snapshot: TokenUsageSnapshot?\n    }\n\n    private let logger = Logger(subsystem: \"io.umate.codmate\", category: \"RipgrepStore\")\n    private let verboseLoggingEnabled = ProcessInfo.processInfo.environment[\"CODMATE_TRACE_RIPGREP\"] == \"1\"\n    private let decoder = FlexibleDecoders.iso8601Flexible()\n    private let disk = RipgrepDiskCache()\n    private let isoFormatterWithFractional: ISO8601DateFormatter = {\n        let f = ISO8601DateFormatter()\n        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        return f\n    }()\n    private let isoFormatterPlain: ISO8601DateFormatter = {\n        let f = ISO8601DateFormatter()\n        f.formatOptions = [.withInternetDateTime]\n        return f\n    }()\n    private let monthFormatter: DateFormatter = {\n        let df = DateFormatter()\n        df.dateFormat = \"yyyy-MM\"\n        return df\n    }()\n    private var coverageCache: [CoverageCacheKey: CoverageEntry] = [:]\n    private var toolCache: [String: ToolEntry] = [:]\n    private var tokenCache: [String: TokenEntry] = [:]\n\n    private var lastCoverageScan: Date?\n    private var lastToolScan: Date?\n    private var lastTokenScan: Date?\n\n    func dayCoverage(for monthStart: Date, sessions: [SessionSummary]) async -> [String: Set<Int>] {\n        guard !sessions.isEmpty else { return [:] }\n        let monthKey = Self.monthKeyString(for: monthStart)\n        var result: [String: Set<Int>] = [:]\n\n        // Separate sessions into cached and need-scan groups\n        var needScan: [(SessionSummary, Date)] = []\n        var cacheHits = 0\n\n        for session in sessions {\n            if Task.isCancelled { break }\n            guard let mtime = fileModificationDate(for: session.fileURL) else {\n                continue\n            }\n            let cacheKey = CoverageCacheKey(path: session.fileURL.path, monthKey: monthKey)\n            if let cached = coverageCache[cacheKey], Self.datesEqual(cached.mtime, mtime) {\n                result[session.id] = cached.days\n                cacheHits += 1\n                continue\n            }\n            // Try disk cache\n            if let days = await disk.getCoverage(path: cacheKey.path, monthKey: cacheKey.monthKey, mtime: mtime) {\n                let set = Set(days)\n                coverageCache[cacheKey] = CoverageEntry(mtime: mtime, days: set)\n                result[session.id] = set\n                cacheHits += 1\n                continue\n            }\n            needScan.append((session, mtime))\n        }\n\n        // Log cache performance\n        if verboseLoggingEnabled && !sessions.isEmpty {\n            logger.debug(\"Coverage cache: \\(cacheHits, privacy: .public) hits, \\(needScan.count, privacy: .public) misses for \\(monthKey, privacy: .public)\")\n        }\n\n        // Batch scan all files that need scanning\n        guard !needScan.isEmpty else { return result }\n\n        let batchResult = await scanDaysBatch(\n            sessions: needScan.map { $0.0 },\n            monthKey: monthKey\n        )\n\n        // Update cache and results\n        for (session, mtime) in needScan {\n            guard let days = batchResult[session.id] else { continue }\n            let cacheKey = CoverageCacheKey(path: session.fileURL.path, monthKey: monthKey)\n            coverageCache[cacheKey] = CoverageEntry(mtime: mtime, days: days)\n            result[session.id] = days\n            await disk.setCoverage(path: cacheKey.path, monthKey: cacheKey.monthKey, mtime: mtime, days: days)\n        }\n\n        return result\n    }\n\n    func toolInvocationCounts(for sessions: [SessionSummary]) async -> [String: Int] {\n        guard !sessions.isEmpty else { return [:] }\n        var output: [String: Int] = [:]\n        var needScan: [(SessionSummary, Date)] = []\n\n        // Check cache first\n        for session in sessions {\n            if Task.isCancelled { break }\n            guard let mtime = fileModificationDate(for: session.fileURL) else { continue }\n            let path = session.fileURL.path\n            if let cached = toolCache[path], Self.datesEqual(cached.mtime, mtime) {\n                output[session.id] = cached.count\n                continue\n            }\n            if let persisted = await disk.getToolCount(path: path, mtime: mtime) {\n                toolCache[path] = ToolEntry(mtime: mtime, count: persisted)\n                output[session.id] = persisted\n                continue\n            }\n            needScan.append((session, mtime))\n        }\n\n        // Batch scan uncached files\n        guard !needScan.isEmpty else { return output }\n\n        let batchResult = await countToolInvocationsBatch(sessions: needScan.map { $0.0 })\n\n        // Update cache and results\n        for (session, mtime) in needScan {\n            if let count = batchResult[session.id] {\n                toolCache[session.fileURL.path] = ToolEntry(mtime: mtime, count: count)\n                output[session.id] = count\n                await disk.setToolCount(path: session.fileURL.path, mtime: mtime, count: count)\n            }\n        }\n\n        return output\n    }\n\n    func latestTokenUsage(in sessions: [SessionSummary]) async -> TokenUsageSnapshot? {\n        guard !sessions.isEmpty else { return nil }\n\n        for session in sessions {\n            if Task.isCancelled { break }\n            guard let mtime = fileModificationDate(for: session.fileURL) else { continue }\n            let path = session.fileURL.path\n            if let cached = tokenCache[path], Self.datesEqual(cached.mtime, mtime) {\n                if let snapshot = cached.snapshot { return snapshot }\n                continue\n            }\n            do {\n                let snapshot = try await extractTokenUsage(at: session.fileURL)\n                tokenCache[path] = TokenEntry(mtime: mtime, snapshot: snapshot)\n                if let snapshot { return snapshot }\n            } catch is CancellationError {\n                return nil\n            } catch {\n                logger.error(\"Token usage scan failed for \\(path, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n            }\n        }\n        return nil\n    }\n\n    func diagnostics() async -> Diagnostics {\n        Diagnostics(\n            cachedCoverageEntries: coverageCache.count,\n            cachedToolEntries: toolCache.count,\n            cachedTokenEntries: tokenCache.count,\n            lastCoverageScan: lastCoverageScan,\n            lastToolScan: lastToolScan,\n            lastTokenScan: lastTokenScan\n        )\n    }\n\n    func resetAll() {\n        coverageCache.removeAll()\n        toolCache.removeAll()\n        tokenCache.removeAll()\n        lastCoverageScan = nil\n        lastToolScan = nil\n        lastTokenScan = nil\n    }\n\n    func invalidateCoverage(monthKey: String, projectPath: String? = nil) {\n        if let projectPath = projectPath {\n            // Invalidate only entries matching this project path\n            coverageCache = coverageCache.filter { key, _ in\n                !(key.monthKey == monthKey && key.path.hasPrefix(projectPath))\n            }\n        } else {\n            // Invalidate all entries for this month\n            coverageCache = coverageCache.filter { key, _ in\n                key.monthKey != monthKey\n            }\n        }\n        Task { [monthKey, projectPath] in\n            await disk.invalidateCoverage(monthKey: monthKey, projectPath: projectPath)\n        }\n    }\n\n    /// Invalidate coverage for specific file paths only (more precise than invalidating entire directories)\n    func invalidateCoverageForFiles(_ filePaths: Set<String>, monthKey: String) {\n        coverageCache = coverageCache.filter { key, _ in\n            !(key.monthKey == monthKey && filePaths.contains(key.path))\n        }\n        // Disk invalidation for specific files\n        Task { [filePaths] in\n            for path in filePaths {\n                await disk.invalidateCoverage(path: path)\n            }\n        }\n    }\n\n    /// Invalidate tool counts for specific file paths only\n    func invalidateToolsForFiles(_ filePaths: Set<String>) {\n        for path in filePaths {\n            toolCache.removeValue(forKey: path)\n        }\n        Task { [filePaths] in\n            for path in filePaths {\n                await disk.invalidateTools(path: path)\n            }\n        }\n    }\n\n    func markFileModified(_ filePath: String) {\n        // Remove from all caches to force rescan\n        coverageCache = coverageCache.filter { $0.key.path != filePath }\n        toolCache.removeValue(forKey: filePath)\n        tokenCache.removeValue(forKey: filePath)\n        Task { [filePath] in\n            await disk.invalidateCoverage(path: filePath)\n            await disk.invalidateTools(path: filePath)\n        }\n    }\n\n    // MARK: - Private helpers\n\n    /// Calculate optimal batch size based on average file size\n    private func calculateBatchSize(for sessions: [SessionSummary]) -> Int {\n        guard !sessions.isEmpty else { return 30 }\n\n        // Sample up to 10 files to estimate average size\n        let sampleSize = min(10, sessions.count)\n        let samples = sessions.prefix(sampleSize)\n\n        var totalBytes: UInt64 = 0\n        var validSamples = 0\n\n        for session in samples {\n            if let attrs = try? FileManager.default.attributesOfItem(atPath: session.fileURL.path),\n               let fileSize = attrs[.size] as? UInt64 {\n                totalBytes += fileSize\n                validSamples += 1\n            }\n        }\n\n        guard validSamples > 0 else { return 30 }\n\n        let avgBytes = totalBytes / UInt64(validSamples)\n        let avgKB = avgBytes / 1024\n\n        // Dynamic batch sizing:\n        // - Small files (<100KB): 50 files/batch\n        // - Medium files (100-500KB): 30 files/batch\n        // - Large files (>500KB): 15 files/batch\n        if avgKB < 100 {\n            return 50\n        } else if avgKB < 500 {\n            return 30\n        } else {\n            return 15\n        }\n    }\n\n    private func scanDaysBatch(sessions: [SessionSummary], monthKey: String) async -> [String: Set<Int>] {\n        guard !sessions.isEmpty else { return [:] }\n\n        // Build file path to session ID mapping\n        var pathToSessionID: [String: String] = [:]\n        var filePaths: [String] = []\n        for session in sessions {\n            let path = session.fileURL.path\n            pathToSessionID[path] = session.id\n            filePaths.append(path)\n        }\n\n        // Dynamic batch size based on file sizes\n        let batchSize = calculateBatchSize(for: sessions)\n        let batches = stride(from: 0, to: filePaths.count, by: batchSize).map {\n            Array(filePaths[$0..<min($0 + batchSize, filePaths.count)])\n        }\n\n        let start = Date()\n        var allResults: [String: Set<Int>] = [:]\n\n        for (index, batch) in batches.enumerated() {\n            if Task.isCancelled { break }\n\n            // Add delay between batches to spread CPU load\n            if index > 0 {\n                try? await Task.sleep(nanoseconds: 50_000_000)  // 50ms delay between batches\n            }\n\n            let pattern = #\"\\\"timestamp\\\"\\s*:\\s*\\\"\\#(monthKey)-(?:[0-3][0-9])T[^\\\"]+\\\"\"#\n            let args = [\n                \"--no-heading\",\n                \"--with-filename\",  // Include filename in output\n                \"--no-line-number\",\n                \"--color\", \"never\",\n                \"--pcre2\",\n                \"--only-matching\",\n                pattern\n            ] + batch\n\n            do {\n                let lines = try await RipgrepRunner.run(arguments: args)\n                guard !lines.isEmpty else { continue }\n\n                // Parse batch results: each line is \"filepath:timestamp\"\n                var fileToMatches: [String: [String]] = [:]\n                for line in lines {\n                    guard let colonIndex = line.firstIndex(of: \":\") else { continue }\n                    let filePath = String(line[..<colonIndex])\n                    let match = String(line[line.index(after: colonIndex)...])\n                    fileToMatches[filePath, default: []].append(match)\n                }\n\n                // Convert matches to days per session\n                for (filePath, matches) in fileToMatches {\n                    guard let sessionID = pathToSessionID[filePath] else { continue }\n                    let days = parseDays(from: matches, monthKey: monthKey)\n                    if !days.isEmpty {\n                        allResults[sessionID] = days\n                    }\n                }\n            } catch is CancellationError {\n                return allResults\n            } catch {\n                logger.error(\"Ripgrep batch coverage scan failed for batch: \\(error.localizedDescription, privacy: .public)\")\n                // Continue with next batch\n            }\n        }\n\n        lastCoverageScan = Date()\n        let elapsed = -start.timeIntervalSinceNow\n        if verboseLoggingEnabled {\n            logger.debug(\"Batch scanned \\(sessions.count, privacy: .public) files (\\(batches.count, privacy: .public) batches) for \\(monthKey, privacy: .public) in \\(elapsed, format: .fixed(precision: 3))s\")\n        }\n\n        return allResults\n    }\n\n    private func countToolInvocationsBatch(sessions: [SessionSummary]) async -> [String: Int] {\n        guard !sessions.isEmpty else { return [:] }\n\n        var pathToSessionID: [String: String] = [:]\n        var filePaths: [String] = []\n        for session in sessions {\n            let path = session.fileURL.path\n            pathToSessionID[path] = session.id\n            filePaths.append(path)\n        }\n\n        // Dynamic batch size based on file sizes\n        let batchSize = calculateBatchSize(for: sessions)\n        let batches = stride(from: 0, to: filePaths.count, by: batchSize).map {\n            Array(filePaths[$0..<min($0 + batchSize, filePaths.count)])\n        }\n\n        let start = Date()\n        var allResults: [String: Int] = [:]\n\n        for (index, batch) in batches.enumerated() {\n            if Task.isCancelled { break }\n\n            if index > 0 {\n                try? await Task.sleep(nanoseconds: 50_000_000)\n            }\n\n            let pattern = #\"\\\"type\\\"\\s*:\\s*\\\"(?:function_call|tool_call|tool_output)\"\"#\n            let args = [\n                \"--no-heading\",\n                \"--with-filename\",\n                \"--no-line-number\",\n                \"--color\", \"never\",\n                \"--pcre2\",\n                \"--count\",  // Use --count for efficiency\n                pattern\n            ] + batch\n\n            do {\n                let lines = try await RipgrepRunner.run(arguments: args)\n                for line in lines {\n                    // Parse \"filepath:count\" format\n                    guard let colonIndex = line.firstIndex(of: \":\") else { continue }\n                    let filePath = String(line[..<colonIndex])\n                    let countStr = String(line[line.index(after: colonIndex)...])\n                    guard let count = Int(countStr),\n                          let sessionID = pathToSessionID[filePath] else { continue }\n                    allResults[sessionID] = count\n                }\n            } catch is CancellationError {\n                return allResults\n            } catch {\n                logger.error(\"Ripgrep batch tool scan failed: \\(error.localizedDescription, privacy: .public)\")\n            }\n        }\n\n        lastToolScan = Date()\n        let elapsed = -start.timeIntervalSinceNow\n        if verboseLoggingEnabled {\n            logger.debug(\"Batch scanned \\(sessions.count, privacy: .public) files (\\(batches.count, privacy: .public) batches) for tool invocations in \\(elapsed, format: .fixed(precision: 3))s\")\n        }\n\n        return allResults\n    }\n\n    private func scanDays(for url: URL, monthKey: String) async -> Set<Int>? {\n        let pattern = #\"\\\"timestamp\\\"\\s*:\\s*\\\"\\#(monthKey)-(?:[0-3][0-9])T[^\\\"]+\\\"\"#\n        let args = [\n            \"--no-heading\",\n            \"--no-filename\",\n            \"--no-line-number\",\n            \"--color\", \"never\",\n            \"--pcre2\",\n            \"--only-matching\",\n            pattern,\n            url.path\n        ]\n        let start = Date()\n        do {\n            let lines = try await RipgrepRunner.run(arguments: args)\n            guard !lines.isEmpty else { return nil }\n            lastCoverageScan = Date()\n            logger.debug(\"Scanned \\(url.lastPathComponent, privacy: .public) for \\(monthKey, privacy: .public) in \\(-start.timeIntervalSinceNow, privacy: .public)s\")\n            let days = parseDays(from: lines, monthKey: monthKey)\n            guard !days.isEmpty else { return nil }\n            return days\n        } catch is CancellationError {\n            return nil\n        } catch {\n            logger.error(\"Ripgrep coverage scan failed for \\(url.lastPathComponent, privacy: .public): \\(error.localizedDescription, privacy: .public)\")\n            return nil\n        }\n    }\n\n    private func countToolInvocations(at url: URL) async throws -> Int {\n        let pattern = #\"\\\"type\\\"\\s*:\\s*\\\"(?:function_call|tool_call|tool_output)\"\"#\n        let args = [\n            \"--no-heading\",\n            \"--no-filename\",\n            \"--no-line-number\",\n            \"--color\", \"never\",\n            \"--pcre2\",\n            pattern,\n            url.path\n        ]\n        let lines = try await RipgrepRunner.run(arguments: args)\n        lastToolScan = Date()\n        return lines.count\n    }\n\n    private func extractTokenUsage(at url: URL) async throws -> TokenUsageSnapshot? {\n        let pattern = #\"\\\"type\\\"\\s*:\\s*\\\"token_count\"\"#\n        let args = [\n            \"--no-heading\",\n            \"--no-filename\",\n            \"--color\", \"never\",\n            \"--pcre2\",\n            pattern,\n            url.path\n        ]\n        let lines = try await RipgrepRunner.run(arguments: args)\n        lastTokenScan = Date()\n        guard !lines.isEmpty else { return nil }\n\n        var latest: TokenUsageSnapshot?\n        for line in lines {\n            guard let data = line.data(using: .utf8),\n                  let row = try? decoder.decode(SessionRow.self, from: data)\n            else { continue }\n            guard case let .eventMessage(payload) = row.kind else { continue }\n            if let snapshot = TokenUsageSnapshotBuilder.build(timestamp: row.timestamp, payload: payload) {\n                latest = snapshot\n            }\n        }\n        return latest\n    }\n\n    private func parseDays(from lines: [String], monthKey: String) -> Set<Int> {\n        var days: Set<Int> = []\n        for line in lines {\n            guard let timestamp = extractTimestamp(from: line) else { continue }\n            guard let date = parseISODate(timestamp) else { continue }\n            let monthOfDate = monthFormatter.string(from: date)\n            guard monthOfDate == monthKey else { continue }\n            let day = Calendar.current.component(.day, from: date)\n            days.insert(day)\n        }\n        return days\n    }\n\n    private func extractTimestamp(from line: String) -> String? {\n        let prefix = \"\\\"timestamp\\\":\\\"\"\n        guard let range = line.range(of: prefix) else { return nil }\n        let start = range.upperBound\n        guard let end = line[start...].firstIndex(of: \"\\\"\") else { return nil }\n        return String(line[start..<end])\n    }\n\n    private func parseISODate(_ string: String) -> Date? {\n        if let date = isoFormatterWithFractional.date(from: string) {\n            return date\n        }\n        return isoFormatterPlain.date(from: string)\n    }\n\n    private func fileModificationDate(for url: URL) -> Date? {\n        let values = try? url.resourceValues(forKeys: [.contentModificationDateKey])\n        return values?.contentModificationDate\n    }\n\n    private static func monthKeyString(for date: Date) -> String {\n        let cal = Calendar.current\n        let comps = cal.dateComponents([.year, .month], from: date)\n        let year = comps.year ?? 0\n        let month = comps.month ?? 0\n        return String(format: \"%04d-%02d\", year, month)\n    }\n\n    private static func datesEqual(_ lhs: Date?, _ rhs: Date?) -> Bool {\n        switch (lhs, rhs) {\n        case (.none, .none): return true\n        case let (.some(a), .some(b)): return abs(a.timeIntervalSince(b)) < 0.0001\n        default: return false\n        }\n    }\n}\n"
  },
  {
    "path": "services/SessionTimelineLoader.swift",
    "content": "import Foundation\n\nstruct SessionTimelineLoader {\n    private let decoder: JSONDecoder\n    private let skippedEventTypes: Set<String> = [\n        \"reasoning_output\"\n    ]\n    private let turnBoundaryMetadataKey = \"turn_boundary\"\n\n    init() {\n        decoder = FlexibleDecoders.iso8601Flexible()\n    }\n\n    func load(url: URL) throws -> [ConversationTurn] {\n        let events = try decodeEvents(url: url)\n        return group(events: events)\n    }\n\n    func turns(from rows: [SessionRow]) -> [ConversationTurn] {\n        let events = rows.compactMap { makeEvent(from: $0) }\n        return group(events: events)\n    }\n\n    private func decodeEvents(url: URL) throws -> [TimelineEvent] {\n        let data = try Data(contentsOf: url, options: [.mappedIfSafe])\n        guard !data.isEmpty else { return [] }\n        let newline: UInt8 = 0x0A\n        let carriageReturn: UInt8 = 0x0D\n\n        var events: [TimelineEvent] = []\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue }\n            guard let event = makeEvent(from: row) else { continue }\n            events.append(event)\n        }\n        return events\n    }\n\n    private func makeEvent(from row: SessionRow) -> TimelineEvent? {\n        switch row.kind {\n        case .sessionMeta:\n            return nil\n        case .assistantMessage:\n            // Assistant messages are handled by response_item events; skip here to avoid duplicates\n            return nil\n        case let .turnContext(payload):\n            var parts: [String] = []\n            if let model = payload.model { parts.append(\"model: \\(model)\") }\n            if let ap = payload.approvalPolicy { parts.append(\"policy: \\(ap)\") }\n            if let cwd = payload.cwd { parts.append(\"cwd: \\(cwd)\") }\n            if let summary = payload.summary, !summary.isEmpty { parts.append(summary) }\n            let text = parts.joined(separator: \"\\n\")\n            guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }\n            // Turn Context is already surfaced in the Environment Context section;\n            // skip adding it to the conversation timeline.\n            return nil\n        case let .eventMessage(payload):\n            let type = payload.type.lowercased()\n            if type == \"turn_boundary\" {\n                var metadata: [String: String] = [turnBoundaryMetadataKey: \"1\"]\n                if let kind = payload.kind, !kind.isEmpty {\n                    metadata[\"boundary_kind\"] = kind\n                }\n                if let identifier = payload.message, !identifier.isEmpty {\n                    metadata[\"boundary_message_id\"] = identifier\n                }\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: .info,\n                    title: nil,\n                    text: nil,\n                    metadata: metadata\n                )\n            }\n            if skippedEventTypes.contains(type) { return nil }\n            if type == \"token_count\" {\n                return makeTokenCountEvent(timestamp: row.timestamp, payload: payload)\n            }\n            if type == \"turn_aborted\" || type == \"turn aborted\" || type == \"compaction\" || type == \"compacted\" {\n                return nil\n            }\n            if type == \"agent_reasoning\" {\n                let reasoning = cleanedText(payload.text ?? payload.message ?? \"\")\n                guard !reasoning.isEmpty else { return nil }\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: .info,\n                    title: \"Agent Reasoning\",\n                    text: reasoning,\n                    metadata: nil,\n                    visibilityKind: .reasoning\n                )\n            }\n            if type == \"ghost_snapshot\" || type == \"ghost snapshot\" {\n                return nil\n            }\n            if type == \"environment_context\" {\n                if let env = payload.message ?? payload.text {\n                    return makeEnvironmentContextEvent(text: env, timestamp: row.timestamp)\n                }\n                return nil\n            }\n\n            let message = cleanedAssistantText(payload.message ?? payload.text ?? payload.reason ?? \"\")\n            let attachments = attachments(from: payload)\n            guard !message.isEmpty || !attachments.isEmpty else { return nil }\n            let displayMessage = message.isEmpty ? \"[Image]\" : message\n            let mappedKind = MessageVisibilityKind.mappedKind(\n                rawType: payload.type,\n                title: payload.kind ?? payload.type,\n                metadata: nil\n            )\n            let effectiveKind: MessageVisibilityKind? = {\n                guard mappedKind == .tool else { return mappedKind }\n                if containsCodeEditMarkers(message) || containsStrongEditOutputMarkers(message) {\n                    return .codeEdit\n                }\n                return mappedKind\n            }()\n            switch type {\n            case \"user_message\":\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: .user,\n                    title: nil,\n                    text: displayMessage,\n                    metadata: nil,\n                    repeatCount: repeatCountHint(from: payload.info),\n                    attachments: attachments,\n                    visibilityKind: effectiveKind ?? .user\n                )\n            case \"agent_message\":\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: .assistant,\n                    title: nil,\n                    text: displayMessage,\n                    metadata: nil,\n                    repeatCount: repeatCountHint(from: payload.info),\n                    attachments: attachments,\n                    visibilityKind: effectiveKind ?? .assistant\n                )\n            default:\n                let actor = effectiveKind?.defaultActor ?? .info\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: actor,\n                    title: payload.type,\n                    text: displayMessage,\n                    metadata: nil,\n                    attachments: attachments,\n                    visibilityKind: effectiveKind\n                )\n            }\n        case let .responseItem(payload):\n            let type = payload.type.lowercased()\n            if skippedEventTypes.contains(type) { return nil }\n            if type == \"ghost_snapshot\" || type == \"ghost snapshot\" { return nil }\n            if type == \"reasoning\",\n               let summary = payload.summary,\n               !summary.isEmpty,\n               (payload.content == nil || payload.content?.isEmpty == true) {\n                // Codex emits duplicate reasoning in response_item (summary only) + event_msg.\n                // Keep the event_msg version and skip the summary-only duplicate.\n                return nil\n            }\n\n            if type == \"message\" {\n                let text = cleanedAssistantText(joinedText(from: payload.content ?? []))\n                guard !text.isEmpty else { return nil }\n                if payload.role?.lowercased() == \"user\" {\n                    if let environment = makeEnvironmentContextEvent(text: text, timestamp: row.timestamp) {\n                        return environment\n                    }\n                    // event_msg already covers user content; skip to avoid duplicates\n                    return nil\n                }\n                return TimelineEvent(\n                    id: UUID().uuidString,\n                    timestamp: row.timestamp,\n                    actor: .assistant,\n                    title: nil,\n                    text: text,\n                    metadata: nil,\n                    visibilityKind: .assistant\n                )\n            }\n\n            let contentText = cleanedText(joinedText(from: payload.content ?? []))\n            let summaryText = cleanedText(joinedSummary(from: payload.summary ?? []))\n            let fallbackText = responseFallbackText(payload)\n            let mappedKind = MessageVisibilityKind.mappedKind(\n                rawType: payload.type,\n                title: payload.type,\n                metadata: nil\n            )\n            let detectionText: String = {\n                if !contentText.isEmpty { return contentText }\n                if !summaryText.isEmpty { return summaryText }\n                return fallbackText\n            }()\n            let resolvedKind: MessageVisibilityKind? = {\n                guard mappedKind == .tool else { return mappedKind }\n                if isCodeEdit(payload: payload, fallbackText: detectionText) { return .codeEdit }\n                return mappedKind\n            }()\n            let baseText: String\n            if resolvedKind == .tool || resolvedKind == .codeEdit {\n                if !contentText.isEmpty { baseText = contentText }\n                else if !summaryText.isEmpty { baseText = summaryText }\n                else { baseText = \"\" }\n            } else {\n                if !contentText.isEmpty { baseText = contentText }\n                else if !summaryText.isEmpty { baseText = summaryText }\n                else { baseText = fallbackText }\n            }\n            let bodyText: String\n            if resolvedKind == .tool || resolvedKind == .codeEdit {\n                let toolText = toolDisplayText(payload: payload, fallback: baseText)\n                bodyText = toolText\n            } else {\n                bodyText = baseText\n            }\n            guard !bodyText.isEmpty else { return nil }\n            let actor = resolvedKind?.defaultActor ?? .info\n            return TimelineEvent(\n                id: UUID().uuidString,\n                timestamp: row.timestamp,\n                actor: actor,\n                title: payload.type,\n                text: bodyText,\n                metadata: nil,\n                visibilityKind: resolvedKind,\n                callID: payload.callID\n            )\n        case .unknown:\n            return nil\n        }\n    }\n\n    private func group(events: [TimelineEvent]) -> [ConversationTurn] {\n        var turns: [ConversationTurn] = []\n        var currentUser: TimelineEvent?\n        var pendingOutputs: [TimelineEvent] = []\n\n        // Use a stable, content-agnostic key per turn to preserve UI expansion state\n        // across reloads when outputs are appended (commonly the last turn).\n        var seenTurnKeys: [String: Int] = [:]\n\n        func stableTurnID(anchor timestamp: Date, hasUser: Bool) -> String {\n            let millis = Int(timestamp.timeIntervalSince1970 * 1000)\n            let baseKey = \"\\(millis)-\\(hasUser ? \"u\" : \"o\")\"\n            let seq = (seenTurnKeys[baseKey] ?? 0) + 1\n            seenTurnKeys[baseKey] = seq\n            return \"t-\\(baseKey)-\\(seq)\"\n        }\n\n        func flushTurn() {\n            guard currentUser != nil || !pendingOutputs.isEmpty else { return }\n            let timestamp = currentUser?.timestamp ?? pendingOutputs.first?.timestamp ?? Date()\n            let id = stableTurnID(anchor: timestamp, hasUser: currentUser != nil)\n            let turn = ConversationTurn(\n                id: id,\n                timestamp: timestamp,\n                userMessage: currentUser,\n                outputs: pendingOutputs\n            )\n            turns.append(turn)\n            currentUser = nil\n            pendingOutputs = []\n        }\n\n        let ordered = events.sorted(by: { $0.timestamp < $1.timestamp })\n        let mergedTools = mergeToolInvocations(in: ordered)\n        let deduped = collapseDuplicates(mergeConsecutiveUserMessages(mergedTools))\n\n        for event in deduped {\n            if event.title == TimelineEvent.environmentContextTitle {\n                continue\n            }\n            if event.metadata?[turnBoundaryMetadataKey] == \"1\" {\n                if currentUser == nil {\n                    flushTurn()\n                }\n                continue\n            }\n            if event.actor == .user {\n                flushTurn()\n                currentUser = event\n            } else {\n                pendingOutputs.append(event)\n            }\n        }\n        flushTurn()\n        return turns\n    }\n\n    private func mergeToolInvocations(in events: [TimelineEvent]) -> [TimelineEvent] {\n        var result: [TimelineEvent] = []\n        var pendingByCallID: [String: Int] = [:]\n\n        for event in events {\n            guard isToolLike(event.visibilityKind),\n                  let callID = event.callID,\n                  !callID.isEmpty else {\n                result.append(event)\n                continue\n            }\n\n            if isToolOutputEvent(event), let index = pendingByCallID[callID] {\n                let merged = mergeToolOutput(into: result[index], output: event)\n                result[index] = merged\n                continue\n            }\n\n            pendingByCallID[callID] = result.count\n            result.append(event)\n        }\n\n        return result\n    }\n\n    private func isToolLike(_ kind: MessageVisibilityKind) -> Bool {\n        switch kind {\n        case .tool, .codeEdit:\n            return true\n        default:\n            return false\n        }\n    }\n\n    private func isToolOutputEvent(_ event: TimelineEvent) -> Bool {\n        let type = (event.title ?? \"\").lowercased()\n        if type.isEmpty { return false }\n        if type.contains(\"output\") || type.contains(\"result\") { return true }\n        return false\n    }\n\n    private func mergeToolOutput(into callEvent: TimelineEvent, output: TimelineEvent) -> TimelineEvent {\n        let callText = callEvent.text ?? \"\"\n        let outputText = output.text ?? \"\"\n        let mergedText: String\n        if outputText.isEmpty {\n            mergedText = callText\n        } else if callText.isEmpty {\n            mergedText = outputText\n        } else if callText.contains(outputText) {\n            mergedText = callText\n        } else {\n            mergedText = [callText, outputText].joined(separator: \"\\n\\n\")\n        }\n        return TimelineEvent(\n            id: callEvent.id,\n            timestamp: callEvent.timestamp,\n            actor: callEvent.actor,\n            title: callEvent.title,\n            text: mergedText,\n            metadata: callEvent.metadata,\n            repeatCount: callEvent.repeatCount,\n            attachments: callEvent.attachments,\n            visibilityKind: callEvent.visibilityKind,\n            callID: callEvent.callID\n        )\n    }\n\n    private func cleanedText(_ text: String) -> String {\n        guard !text.isEmpty else { return text }\n        return text\n            .replacingOccurrences(of: \"<user_instructions>\", with: \"\")\n            .replacingOccurrences(of: \"</user_instructions>\", with: \"\")\n            .trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private func cleanedAssistantText(_ text: String) -> String {\n        let base = cleanedText(text)\n        return stripTaggedBlocks(\n            base,\n            tags: [\n                \"permissions_instructions\",\n                \"permissions instructions\",\n                \"collaboration_mode\",\n                \"collaboration mode\"\n            ]\n        )\n            .trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private func stripTaggedBlocks(_ text: String, tags: [String]) -> String {\n        var result = text\n        for tag in tags {\n            result = stripTaggedBlock(result, tag: tag)\n        }\n        return result\n    }\n\n    private func stripTaggedBlock(_ text: String, tag: String) -> String {\n        let lowerTag = tag.lowercased()\n        let openToken = \"<\\(lowerTag)>\"\n        let closeToken = \"</\\(lowerTag)>\"\n        var output = text\n        while let openRange = output.lowercased().range(of: openToken) {\n            if let closeRange = output.lowercased().range(of: closeToken, range: openRange.upperBound..<output.endIndex) {\n                output.removeSubrange(openRange.lowerBound..<closeRange.upperBound)\n            } else {\n                output.removeSubrange(openRange.lowerBound..<output.endIndex)\n                break\n            }\n        }\n        return output\n    }\n\n    private func joinedText(from blocks: [ResponseContentBlock]) -> String {\n        blocks.compactMap { $0.text }.joined(separator: \"\\n\\n\")\n    }\n\n    private func joinedSummary(from items: [ResponseSummaryItem]) -> String {\n        items.compactMap { $0.text }.joined(separator: \"\\n\\n\")\n    }\n\n    private func responseFallbackText(_ payload: ResponseItemPayload) -> String {\n        var lines: [String] = []\n\n        if let name = payload.name, !name.isEmpty {\n            lines.append(\"name: \\(name)\")\n        }\n        if let args = renderValue(payload.arguments), !args.isEmpty {\n            lines.append(formatLabel(\"arguments\", value: args))\n        }\n        if let input = renderValue(payload.input), !input.isEmpty {\n            lines.append(formatLabel(\"input\", value: input))\n        }\n        if let output = renderValue(payload.output), !output.isEmpty {\n            lines.append(formatLabel(\"output\", value: output))\n        }\n        if let ghost = renderValue(payload.ghostCommit), !ghost.isEmpty {\n            lines.append(formatLabel(\"ghost_commit\", value: ghost))\n        }\n\n        if lines.isEmpty, let callID = payload.callID, !callID.isEmpty {\n            lines.append(\"call_id: \\(callID)\")\n        }\n\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func toolDisplayText(payload: ResponseItemPayload, fallback: String) -> String {\n        var lines: [String] = []\n\n        if let name = payload.name, !name.isEmpty {\n            lines.append(\"name: \\(name)\")\n        }\n\n        let argumentValue = payload.arguments ?? payload.input\n        if let args = renderValue(argumentValue), !args.isEmpty {\n            lines.append(formatLabel(\"arguments\", value: args))\n        }\n\n        if let output = renderValue(payload.output), !output.isEmpty {\n            lines.append(formatLabel(\"output\", value: output))\n        }\n\n        if lines.isEmpty { return fallback }\n\n        let composed = lines.joined(separator: \"\\n\")\n        guard !fallback.isEmpty else { return composed }\n        if fallback == composed { return composed }\n        if composed.contains(fallback) { return composed }\n        return [composed, fallback].joined(separator: \"\\n\")\n    }\n\n    private func isCodeEdit(payload: ResponseItemPayload, fallbackText: String) -> Bool {\n        let name = normalizeToolName(payload.name)\n        if codeEditToolNames.contains(name) { return true }\n\n        if containsEditKeys(payload.arguments) || containsEditKeys(payload.input) {\n            return true\n        }\n\n        if name == \"execcommand\" || name == \"bash\" || name == \"runshellcommand\" {\n            let argsText = stringValue(payload.arguments) ?? \"\"\n            if containsCodeEditMarkers(argsText) { return true }\n        }\n\n        if let outputText = stringValue(payload.output),\n           containsStrongEditOutputMarkers(outputText) { return true }\n\n        if containsCodeEditMarkers(fallbackText) { return true }\n\n        return false\n    }\n\n    private var codeEditToolNames: Set<String> {\n        [\n            \"edit\",\n            \"write\",\n            \"replace\",\n            \"applypatch\",\n            \"patch\",\n            \"createfile\",\n            \"writefile\",\n            \"deletefile\",\n            \"fileedit\",\n            \"filewrite\",\n            \"updatefile\",\n            \"insert\",\n            \"append\",\n            \"move\",\n            \"rename\",\n            \"remove\",\n            \"multiedit\"\n        ]\n    }\n\n    private func normalizeToolName(_ name: String?) -> String {\n        let raw = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? \"\"\n        if raw.isEmpty { return \"\" }\n        return raw\n            .replacingOccurrences(of: \"_\", with: \"\")\n            .replacingOccurrences(of: \"-\", with: \"\")\n            .replacingOccurrences(of: \" \", with: \"\")\n    }\n\n    private func containsEditKeys(_ value: JSONValue?) -> Bool {\n        guard let value else { return false }\n        switch value {\n        case .object(let dict):\n            let keys = Set(dict.keys.map { $0.lowercased() })\n            let hasPath = keys.contains(\"file_path\") || keys.contains(\"filepath\") || keys.contains(\"path\")\n            let hasOldNew = keys.contains(\"old_string\") || keys.contains(\"new_string\")\n            let hasPatch = keys.contains(\"patch\") || keys.contains(\"diff\")\n            let hasContent = keys.contains(\"content\") || keys.contains(\"new_content\") || keys.contains(\"text\")\n            if hasOldNew || hasPatch { return true }\n            if hasPath && hasContent { return true }\n            return dict.values.contains { containsEditKeys($0) }\n        case .array(let array):\n            return array.contains { containsEditKeys($0) }\n        default:\n            return false\n        }\n    }\n\n    private func containsCodeEditMarkers(_ text: String) -> Bool {\n        let lowered = text.lowercased()\n        if lowered.contains(\"*** begin patch\") { return true }\n        if lowered.contains(\"*** update file\") { return true }\n        if lowered.contains(\"*** add file\") { return true }\n        if lowered.contains(\"*** delete file\") { return true }\n        if lowered.contains(\"update file:\") { return true }\n        return false\n    }\n\n    private func containsStrongEditOutputMarkers(_ text: String) -> Bool {\n        let lowered = text.lowercased()\n        if lowered.contains(\"updated the following files\") { return true }\n        if lowered.contains(\"success. updated the following files\") { return true }\n        return false\n    }\n\n    private func stringValue(_ value: JSONValue?) -> String? {\n        guard let value else { return nil }\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return String(number)\n        case .bool(let flag):\n            return flag ? \"true\" : \"false\"\n        case .array, .object, .null:\n            return nil\n        }\n    }\n\n    private func formatLabel(_ label: String, value: String) -> String {\n        value.contains(\"\\n\") ? \"\\(label):\\n\\(value)\" : \"\\(label): \\(value)\"\n    }\n\n    private func attachments(from payload: EventMessagePayload) -> [TimelineAttachment] {\n        guard let images = payload.images, !images.isEmpty else { return [] }\n        return images.enumerated().compactMap { index, raw in\n            let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { return nil }\n            let label = \"Image \\(index + 1)\"\n            if let url = URL(string: trimmed), let scheme = url.scheme, scheme != \"data\" {\n                return TimelineAttachment(kind: .image, label: label, url: url)\n            }\n            if trimmed.hasPrefix(\"/\") {\n                return TimelineAttachment(kind: .image, label: label, url: URL(fileURLWithPath: trimmed))\n            }\n            return TimelineAttachment(kind: .image, label: label, dataURL: trimmed)\n        }\n    }\n\n    private func renderValue(_ value: JSONValue?) -> String? {\n        guard let value else { return nil }\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return String(number)\n        case .bool(let flag):\n            return flag ? \"true\" : \"false\"\n        case .null:\n            return nil\n        case .array, .object:\n            let raw = toAny(value)\n            guard JSONSerialization.isValidJSONObject(raw),\n                  let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]),\n                  let text = String(data: data, encoding: .utf8)\n            else { return nil }\n            return text\n        }\n    }\n\n    private func toAny(_ value: JSONValue) -> Any {\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return number\n        case .bool(let flag):\n            return flag\n        case .array(let array):\n            return array.map(toAny)\n        case .object(let dict):\n            return dict.mapValues(toAny)\n        case .null:\n            return NSNull()\n        }\n    }\n\n    /// Merge consecutive user messages with the exact same timestamp\n    /// This handles Claude Code's behavior of splitting a single user input into multiple JSONL records\n    /// with identical timestamps (down to millisecond precision)\n    private func mergeConsecutiveUserMessages(_ events: [TimelineEvent]) -> [TimelineEvent] {\n        guard !events.isEmpty else { return [] }\n        var result: [TimelineEvent] = []\n        var pendingUserMessages: [TimelineEvent] = []\n        var lastUserTimestamp: Date?\n\n        func flushPendingUserMessages() {\n            guard !pendingUserMessages.isEmpty else { return }\n\n            // Filter out auto-generated image description messages\n            // (text-only messages that start with \"[Image:\")\n            let realMessages = pendingUserMessages.filter { event in\n                if event.attachments.isEmpty,\n                   let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines),\n                   text.hasPrefix(\"[Image:\") {\n                    return false  // Skip auto-generated image description\n                }\n                return true\n            }\n\n            if realMessages.count == 1 {\n                // Only one real message, use it as-is\n                result.append(realMessages[0])\n            } else if realMessages.count > 1 {\n                // Multiple messages need to be merged\n                let first = realMessages[0]\n                let mergedText = realMessages.compactMap { $0.text }.filter { !$0.isEmpty }.joined(separator: \"\\n\\n\")\n                var mergedAttachments: [TimelineAttachment] = []\n\n                for event in realMessages {\n                    mergedAttachments.append(contentsOf: event.attachments)\n                }\n\n                // Create merged event\n                let merged = TimelineEvent(\n                    id: first.id,\n                    timestamp: first.timestamp,\n                    actor: first.actor,\n                    title: first.title,\n                    text: mergedText.isEmpty ? nil : mergedText,\n                    metadata: first.metadata,\n                    repeatCount: first.repeatCount,\n                    attachments: mergedAttachments,\n                    visibilityKind: first.visibilityKind,\n                    callID: first.callID\n                )\n                result.append(merged)\n            }\n\n            pendingUserMessages.removeAll()\n            lastUserTimestamp = nil\n        }\n\n        for event in events {\n            if event.actor == .user {\n                // Check if this user message has the exact same timestamp as pending ones\n                if let lastTimestamp = lastUserTimestamp {\n                    // Compare timestamps with exact equality (millisecond precision)\n                    if event.timestamp == lastTimestamp {\n                        pendingUserMessages.append(event)\n                        continue\n                    }\n                }\n\n                // Different timestamp, flush pending and start new batch\n                flushPendingUserMessages()\n                pendingUserMessages.append(event)\n                lastUserTimestamp = event.timestamp\n            } else {\n                // Non-user message, flush pending user messages first\n                flushPendingUserMessages()\n                result.append(event)\n            }\n        }\n\n        // Flush any remaining pending user messages\n        flushPendingUserMessages()\n\n        return result\n    }\n\n    private func collapseDuplicates(_ events: [TimelineEvent]) -> [TimelineEvent] {\n        guard !events.isEmpty else { return [] }\n        var result: [TimelineEvent] = []\n        for event in events {\n            if let last = result.last,\n                last.actor == event.actor,\n                last.title == event.title,\n                (last.text ?? \"\") == (event.text ?? \"\"),\n                normalize(metadata: last.metadata) == normalize(metadata: event.metadata)\n            {\n                result[result.count - 1] = last.incrementingRepeatCount()\n            } else {\n                result.append(event)\n            }\n        }\n        return result\n    }\n\n    private func normalize(metadata: [String: String]?) -> [String: String] {\n        metadata?.filter { !$0.value.isEmpty } ?? [:]\n    }\n\n    private func repeatCountHint(from info: JSONValue?) -> Int {\n        guard let info else { return 1 }\n        if case let .object(dict) = info, let value = dict[\"repeat_count\"] {\n            switch value {\n            case .number(let number):\n                return max(1, Int(number.rounded()))\n            case .string(let string):\n                if let parsed = Double(string) {\n                    return max(1, Int(parsed.rounded()))\n                }\n            case .bool(let flag):\n                return flag ? 1 : 1\n            default:\n                break\n            }\n        }\n        return 1\n    }\n\n    private func makeEnvironmentContextEvent(text: String, timestamp: Date) -> TimelineEvent? {\n        guard let rangeStart = text.range(of: \"<environment_context>\"),\n            let rangeEnd = text.range(of: \"</environment_context>\")\n        else { return nil }\n        let inner = text[rangeStart.upperBound..<rangeEnd.lowerBound]\n        let regex = try? NSRegularExpression(pattern: \"<(\\\\w+)>\\\\s*([^<]+?)\\\\s*</\\\\1>\", options: [])\n        var metadata: [String: String] = [:]\n        if let regex {\n            let nsString = NSString(string: String(inner))\n            let matches = regex.matches(in: String(inner), range: NSRange(location: 0, length: nsString.length))\n            for match in matches where match.numberOfRanges >= 3 {\n                let key = nsString.substring(with: match.range(at: 1))\n                var value = nsString.substring(with: match.range(at: 2))\n                value = value.trimmingCharacters(in: .whitespacesAndNewlines)\n                metadata[key] = value\n            }\n        }\n        let sortedEntries = metadata.sorted(by: { $0.key < $1.key })\n        let textLines = sortedEntries\n            .map { \"\\($0.key): \\($0.value)\" }\n            .joined(separator: \"\\n\")\n        let displayText = textLines.isEmpty ? cleanedText(String(inner)) : textLines\n        return TimelineEvent(\n            id: UUID().uuidString,\n            timestamp: timestamp,\n            actor: .info,\n            title: TimelineEvent.environmentContextTitle,\n            text: displayText.isEmpty ? nil : displayText,\n            metadata: metadata.isEmpty ? nil : metadata,\n            visibilityKind: .environmentContext\n        )\n    }\n\n    private func makeTokenCountEvent(timestamp: Date, payload: EventMessagePayload) -> TimelineEvent? {\n        let infoDict = flatten(json: payload.info)\n        let rateDict = flatten(json: payload.rateLimits, prefix: \"rate_\")\n        let combined = infoDict.merging(rateDict) { current, _ in current }\n        guard !combined.isEmpty else { return nil }\n        return TimelineEvent(\n            id: UUID().uuidString,\n            timestamp: timestamp,\n            actor: .info,\n            title: \"Token Usage\",\n            text: nil,\n            metadata: combined,\n            visibilityKind: .tokenUsage\n        )\n    }\n\n    private func flatten(json: JSONValue?, prefix: String = \"\") -> [String: String] {\n        guard let json else { return [:] }\n        var result: [String: String] = [:]\n        switch json {\n        case .string(let value):\n            result[prefix.isEmpty ? \"value\" : prefix] = value\n        case .number(let value):\n            let key = prefix.isEmpty ? \"value\" : prefix\n            result[key] = String(value)\n        case .bool(let value):\n            let key = prefix.isEmpty ? \"value\" : prefix\n            result[key] = value ? \"true\" : \"false\"\n        case .object(let dict):\n            for (key, value) in dict {\n                let newPrefix = prefix.isEmpty ? key : \"\\(prefix)\\(key.capitalized)\"\n                result.merge(flatten(json: value, prefix: newPrefix)) { current, _ in current }\n            }\n        case .array(let array):\n            for (index, value) in array.enumerated() {\n                let newPrefix = prefix.isEmpty ? \"item\\(index)\" : \"\\(prefix)\\(index)\"\n                result.merge(flatten(json: value, prefix: newPrefix)) { current, _ in current }\n            }\n        case .null:\n            break\n        }\n        return result\n    }\n\n    func loadInstructions(url: URL) throws -> String? {\n        let data = try Data(contentsOf: url, options: [.mappedIfSafe])\n        guard !data.isEmpty else { return nil }\n        let newline: UInt8 = 0x0A\n        let carriageReturn: UInt8 = 0x0D\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            if let row = try? decoder.decode(SessionRow.self, from: Data(slice)) {\n                if case let .sessionMeta(payload) = row.kind, let instructions = payload.instructions {\n                    let cleaned = cleanedText(instructions)\n                    if !cleaned.isEmpty { return cleaned }\n                }\n            }\n        }\n        return nil\n    }\n\n    func loadEnvironmentContext(from rows: [SessionRow]) -> EnvironmentContextInfo? {\n        var latest: TimelineEvent?\n\n        for row in rows {\n            switch row.kind {\n            case let .turnContext(payload):\n                // Extract environment context from turnContext (for Gemini sessions)\n                var metadata: [String: String] = [:]\n                if let model = payload.model { metadata[\"model\"] = model }\n                if let cwd = payload.cwd { metadata[\"cwd\"] = cwd }\n                if let approval = payload.approvalPolicy { metadata[\"approval\"] = approval }\n\n                if !metadata.isEmpty {\n                    var textParts: [String] = []\n                    if let model = metadata[\"model\"] { textParts.append(\"model: \\(model)\") }\n                    if let cwd = metadata[\"cwd\"] { textParts.append(\"cwd: \\(cwd)\") }\n                    if let approval = metadata[\"approval\"] { textParts.append(\"approval: \\(approval)\") }\n\n                    latest = TimelineEvent(\n                        id: UUID().uuidString,\n                        timestamp: row.timestamp,\n                        actor: .info,\n                        title: TimelineEvent.environmentContextTitle,\n                        text: textParts.joined(separator: \"\\n\"),\n                        metadata: metadata\n                    )\n                }\n            case let .eventMessage(payload):\n                let type = payload.type.lowercased()\n                if type == \"environment_context\",\n                   let envText = payload.message ?? payload.text,\n                   let event = makeEnvironmentContextEvent(text: envText, timestamp: row.timestamp)\n                {\n                    latest = event\n                }\n            case let .responseItem(payload):\n                if payload.type.lowercased() == \"message\" {\n                    let text = joinedText(from: payload.content ?? [])\n                    guard text.contains(\"<environment_context\") else { continue }\n                    if let event = makeEnvironmentContextEvent(text: text, timestamp: row.timestamp) {\n                        latest = event\n                    }\n                }\n            default:\n                continue\n            }\n        }\n\n        guard let event = latest else { return nil }\n        let metadataPairs = (event.metadata ?? [:]).sorted(by: { $0.key < $1.key })\n        let entries = metadataPairs.map { EnvironmentContextInfo.Entry(key: $0.key, value: $0.value) }\n        return EnvironmentContextInfo(\n            timestamp: event.timestamp,\n            entries: entries,\n            rawText: event.text\n        )\n    }\n\n    func loadEnvironmentContext(url: URL) throws -> EnvironmentContextInfo? {\n        let data = try Data(contentsOf: url, options: [.mappedIfSafe])\n        guard !data.isEmpty else { return nil }\n        let newline: UInt8 = 0x0A\n        let carriageReturn: UInt8 = 0x0D\n        var latest: TimelineEvent?\n\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue }\n\n            switch row.kind {\n            case let .turnContext(payload):\n                // Extract environment context from turnContext (for Gemini sessions)\n                var metadata: [String: String] = [:]\n                if let model = payload.model { metadata[\"model\"] = model }\n                if let cwd = payload.cwd { metadata[\"cwd\"] = cwd }\n                if let approval = payload.approvalPolicy { metadata[\"approval\"] = approval }\n\n                if !metadata.isEmpty {\n                    var textParts: [String] = []\n                    if let model = metadata[\"model\"] { textParts.append(\"model: \\(model)\") }\n                    if let cwd = metadata[\"cwd\"] { textParts.append(\"cwd: \\(cwd)\") }\n                    if let approval = metadata[\"approval\"] { textParts.append(\"approval: \\(approval)\") }\n\n                    latest = TimelineEvent(\n                        id: UUID().uuidString,\n                        timestamp: row.timestamp,\n                        actor: .info,\n                        title: TimelineEvent.environmentContextTitle,\n                        text: textParts.joined(separator: \"\\n\"),\n                        metadata: metadata\n                    )\n                }\n            case let .eventMessage(payload):\n                let type = payload.type.lowercased()\n                if type == \"environment_context\",\n                   let envText = payload.message ?? payload.text,\n                   let event = makeEnvironmentContextEvent(text: envText, timestamp: row.timestamp)\n                {\n                    latest = event\n                }\n            case let .responseItem(payload):\n                if payload.type.lowercased() == \"message\" {\n                    let text = joinedText(from: payload.content ?? [])\n                    guard text.contains(\"<environment_context\") else { continue }\n                    if let event = makeEnvironmentContextEvent(text: text, timestamp: row.timestamp) {\n                        latest = event\n                    }\n                }\n            default:\n                continue\n            }\n        }\n\n        guard let event = latest else { return nil }\n        let metadataPairs = (event.metadata ?? [:]).sorted(by: { $0.key < $1.key })\n        let entries = metadataPairs.map { EnvironmentContextInfo.Entry(key: $0.key, value: $0.value) }\n        return EnvironmentContextInfo(\n            timestamp: event.timestamp,\n            entries: entries,\n            rawText: event.text\n        )\n    }\n\n    func loadLatestTokenUsage(url: URL) throws -> TokenUsageSnapshot? {\n        let data = try Data(contentsOf: url, options: [.mappedIfSafe])\n        guard !data.isEmpty else { return nil }\n        let newline: UInt8 = 0x0A\n        let carriageReturn: UInt8 = 0x0D\n        var latest: TokenUsageSnapshot?\n\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard !slice.isEmpty else { continue }\n            guard let row = try? decoder.decode(SessionRow.self, from: Data(slice)) else { continue }\n            guard case let .eventMessage(payload) = row.kind else { continue }\n            if payload.type.lowercased() == \"token_count\",\n               let snapshot = TokenUsageSnapshotBuilder.build(timestamp: row.timestamp, payload: payload)\n            {\n                latest = snapshot\n            }\n        }\n\n        return latest\n    }\n\n}\n\nstruct TokenUsageSnapshot: Equatable {\n    let timestamp: Date\n    let totalTokens: Int?\n    let contextWindow: Int?\n    let primaryPercent: Double?\n    let primaryWindowMinutes: Int?\n    let primaryResetAt: Date?\n    let secondaryPercent: Double?\n    let secondaryWindowMinutes: Int?\n    let secondaryResetAt: Date?\n}\n\nstruct TokenUsageSnapshotBuilder {\n    static func build(timestamp: Date, payload: EventMessagePayload) -> TokenUsageSnapshot? {\n        let info = payload.info\n        let totalTokens = info?.value(forKeyPath: [\"last_token_usage\", \"total_tokens\"])?.intValue\n            ?? info?.value(forKeyPath: [\"total_token_usage\", \"total_tokens\"])?.intValue\n        let contextWindow = info?.value(forKeyPath: [\"model_context_window\"])?.intValue\n\n        let primaryRate = RateWindowSnapshot(json: payload.rateLimits, prefix: \"primary\", timestamp: timestamp)\n        let secondaryRate = RateWindowSnapshot(json: payload.rateLimits, prefix: \"secondary\", timestamp: timestamp)\n\n        if totalTokens == nil,\n           contextWindow == nil,\n           primaryRate.isEmpty,\n           secondaryRate.isEmpty\n        {\n            return nil\n        }\n\n        return TokenUsageSnapshot(\n            timestamp: timestamp,\n            totalTokens: totalTokens,\n            contextWindow: contextWindow,\n            primaryPercent: primaryRate.usedPercent,\n            primaryWindowMinutes: primaryRate.windowMinutes,\n            primaryResetAt: primaryRate.resetDate,\n            secondaryPercent: secondaryRate.usedPercent,\n            secondaryWindowMinutes: secondaryRate.windowMinutes,\n            secondaryResetAt: secondaryRate.resetDate\n        )\n    }\n}\n\nfileprivate struct TokenUsageFallbackParser {\n    private let isoFormatter: ISO8601DateFormatter = {\n        let formatter = ISO8601DateFormatter()\n        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        return formatter\n    }()\n\n    func loadLatest(url: URL) -> TokenUsageSnapshot? {\n        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]), !data.isEmpty else { return nil }\n        let newline: UInt8 = 0x0A\n        let carriageReturn: UInt8 = 0x0D\n        var latest: TokenUsageSnapshot?\n\n        for var slice in data.split(separator: newline, omittingEmptySubsequences: true) {\n            if slice.last == carriageReturn { slice = slice.dropLast() }\n            guard let snapshot = parseLine(Data(slice)) else { continue }\n            latest = snapshot\n        }\n\n        return latest\n    }\n\n    private func parseLine(_ data: Data) -> TokenUsageSnapshot? {\n        guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n              let payload = json[\"payload\"] as? [String: Any],\n              let type = (payload[\"type\"] as? String)?.lowercased(),\n              type == \"token_count\",\n              let timestampString = json[\"timestamp\"] as? String,\n              let timestamp = isoFormatter.date(from: timestampString) ?? ISO8601DateFormatter().date(from: timestampString)\n        else {\n            return nil\n        }\n\n        let info = payload[\"info\"] as? [String: Any]\n        let rateLimits = payload[\"rate_limits\"] as? [String: Any]\n        let totalTokens = TokenUsageValueParser.int(TokenUsageValueParser.value(in: info, keyPath: [\"total_token_usage\", \"total_tokens\"]))\n        let contextWindow = TokenUsageValueParser.int(info?[\"model_context_window\"])\n        let primary = RateLimitComponents(json: rateLimits, prefix: \"primary\", timestamp: timestamp)\n        let secondary = RateLimitComponents(json: rateLimits, prefix: \"secondary\", timestamp: timestamp)\n\n        if totalTokens == nil,\n           contextWindow == nil,\n           primary.isEmpty,\n           secondary.isEmpty\n        {\n            return nil\n        }\n\n        return TokenUsageSnapshot(\n            timestamp: timestamp,\n            totalTokens: totalTokens,\n            contextWindow: contextWindow,\n            primaryPercent: primary.usedPercent,\n            primaryWindowMinutes: primary.windowMinutes,\n            primaryResetAt: primary.resetDate,\n            secondaryPercent: secondary.usedPercent,\n            secondaryWindowMinutes: secondary.windowMinutes,\n            secondaryResetAt: secondary.resetDate\n        )\n    }\n}\n\nextension SessionTimelineLoader {\n    func loadLatestTokenUsageWithFallback(url: URL) -> TokenUsageSnapshot? {\n        if let snapshot = try? loadLatestTokenUsage(url: url) {\n            return snapshot\n        }\n        return TokenUsageFallbackParser().loadLatest(url: url)\n    }\n}\n\nprivate struct RateLimitComponents {\n    var usedPercent: Double?\n    var windowMinutes: Int?\n    var resetDate: Date?\n\n    var isEmpty: Bool { usedPercent == nil && windowMinutes == nil && resetDate == nil }\n\n    init(json: [String: Any]?, prefix: String, timestamp: Date) {\n        if let nested = json?[prefix] as? [String: Any] {\n            parse(values: nested, timestamp: timestamp)\n            return\n        }\n\n        guard let json else { return }\n        var extracted: [String: Any] = [:]\n        extracted[\"used_percent\"] = json[\"\\(prefix)_used_percent\"]\n        extracted[\"window_minutes\"] = json[\"\\(prefix)_window_minutes\"]\n        extracted[\"resets_in_seconds\"] = json[\"\\(prefix)_resets_in_seconds\"]\n        extracted[\"resets_at\"] = json[\"\\(prefix)_resets_at\"]\n        parse(values: extracted, timestamp: timestamp)\n    }\n\n    private mutating func parse(values: [String: Any], timestamp: Date) {\n        usedPercent = TokenUsageValueParser.double(values[\"used_percent\"])\n        windowMinutes = TokenUsageValueParser.int(values[\"window_minutes\"])\n        if let resetsAt = TokenUsageValueParser.double(values[\"resets_at\"]) {\n            resetDate = Date(timeIntervalSince1970: resetsAt)\n        } else if let resetsInSeconds = TokenUsageValueParser.double(values[\"resets_in_seconds\"]) {\n            resetDate = timestamp.addingTimeInterval(resetsInSeconds)\n        }\n    }\n}\n\nprivate enum TokenUsageValueParser {\n    static func value(in root: Any?, keyPath: [String]) -> Any? {\n        var current = root\n        for key in keyPath {\n            guard let dict = current as? [String: Any] else { return nil }\n            current = dict[key]\n        }\n        return current\n    }\n\n    static func double(_ value: Any?) -> Double? {\n        switch value {\n        case let number as NSNumber:\n            return number.doubleValue\n        case let string as String:\n            return Double(string)\n        default:\n            return nil\n        }\n    }\n\n    static func int(_ value: Any?) -> Int? {\n        switch value {\n        case let number as NSNumber:\n            return number.intValue\n        case let string as String:\n            return Int(string)\n        default:\n            return nil\n        }\n    }\n}\n\nprivate struct RateWindowSnapshot {\n    var usedPercent: Double?\n    var windowMinutes: Int?\n    var resetsInSeconds: Double?\n    var resetDate: Date? {\n        guard let resetsInSeconds, let referenceTimestamp else { return nil }\n        return referenceTimestamp.addingTimeInterval(resetsInSeconds)\n    }\n\n    private let referenceTimestamp: Date?\n\n    init(json: JSONValue?, prefix: String, timestamp: Date) {\n        referenceTimestamp = timestamp\n        guard let json else { return }\n        guard case let .object(dict) = json else { return }\n\n        if let nested = dict[prefix] {\n            usedPercent = nested.value(forKeyPath: [\"used_percent\"])?.doubleValue\n            windowMinutes = nested.value(forKeyPath: [\"window_minutes\"])?.intValue\n            resetsInSeconds = nested.value(forKeyPath: [\"resets_in_seconds\"])?.doubleValue\n        } else {\n            usedPercent = dict[\"\\(prefix)_used_percent\"]?.doubleValue\n            windowMinutes = dict[\"\\(prefix)_window_minutes\"]?.intValue\n            resetsInSeconds = dict[\"\\(prefix)_resets_in_seconds\"]?.doubleValue\n        }\n    }\n\n    var isEmpty: Bool {\n        usedPercent == nil && windowMinutes == nil && resetsInSeconds == nil\n    }\n}\n\nprivate extension JSONValue {\n    func value(forKeyPath path: [String]) -> JSONValue? {\n        guard !path.isEmpty else { return self }\n        var current: JSONValue = self\n        for key in path {\n            guard case let .object(dict) = current, let next = dict[key] else { return nil }\n            current = next\n        }\n        return current\n    }\n\n    var doubleValue: Double? {\n        switch self {\n        case .number(let value):\n            return value\n        case .string(let string):\n            return Double(string)\n        case .bool(let bool):\n            return bool ? 1 : 0\n        default:\n            return nil\n        }\n    }\n\n}\n"
  },
  {
    "path": "services/SessionsDiagnosticsService.swift",
    "content": "import Foundation\n\nstruct SessionsDiagnostics: Codable, Sendable {\n    struct Probe: Codable, Sendable {\n        var path: String\n        var exists: Bool\n        var isDirectory: Bool\n        var enumeratedCount: Int\n        var sampleFiles: [String]\n        var enumeratorError: String?\n    }\n\n    var timestamp: Date\n    // Sessions (.jsonl)\n    var current: Probe\n    var defaultRoot: Probe\n    // Notes (.json)\n    var notesCurrent: Probe\n    var notesDefault: Probe\n    // Projects (.json)\n    var projectsCurrent: Probe\n    var projectsDefault: Probe\n    // Claude sessions (.jsonl)\n    var claudeCurrent: Probe?\n    var claudeDefault: Probe\n    // Gemini sessions (.json)\n    var geminiCurrent: Probe?\n    var geminiDefault: Probe\n    var suggestions: [String]\n}\n\nactor SessionsDiagnosticsService {\n    private let fm: FileManager\n\n    init(fileManager: FileManager = .default) {\n        self.fm = fileManager\n    }\n\n    func run(\n        currentRoot: URL,\n        defaultRoot: URL,\n        notesCurrentRoot: URL,\n        notesDefaultRoot: URL,\n        projectsCurrentRoot: URL,\n        projectsDefaultRoot: URL,\n        claudeCurrentRoot: URL?,\n        claudeDefaultRoot: URL,\n        geminiCurrentRoot: URL?,\n        geminiDefaultRoot: URL\n    ) async -> SessionsDiagnostics {\n        let currentProbe = await probe(root: currentRoot, fileExtension: \"jsonl\")\n        let defaultProbe = await probe(root: defaultRoot, fileExtension: \"jsonl\")\n        let notesCurrent = await probe(root: notesCurrentRoot, fileExtension: \"json\")\n        let notesDefault = await probe(root: notesDefaultRoot, fileExtension: \"json\")\n        let projectsCurrent = await probe(root: projectsCurrentRoot, fileExtension: \"json\")\n        let projectsDefault = await probe(root: projectsDefaultRoot, fileExtension: \"json\")\n        let claudeCurrent = claudeCurrentRoot != nil ? await probe(root: claudeCurrentRoot!, fileExtension: \"jsonl\") : nil\n        let claudeDefault = await probe(root: claudeDefaultRoot, fileExtension: \"jsonl\")\n        let geminiCurrent = geminiCurrentRoot != nil ? await probe(root: geminiCurrentRoot!, fileExtension: \"json\") : nil\n        let geminiDefault = await probe(root: geminiDefaultRoot, fileExtension: \"json\")\n\n        var suggestions: [String] = []\n        if currentProbe.enumeratedCount == 0, defaultProbe.enumeratedCount > 0,\n            currentProbe.exists\n        {\n            suggestions.append(\"Switch sessions root to default path; it contains sessions.\")\n        }\n        if !currentProbe.exists {\n            suggestions.append(\"Current sessions root does not exist; create or select another directory.\")\n        }\n        if currentProbe.exists, !currentProbe.isDirectory {\n            suggestions.append(\"Current sessions root is not a directory; select a folder.\")\n        }\n        if currentProbe.enumeratedCount == 0,\n            currentProbe.enumeratorError == nil,\n            defaultProbe.enumeratedCount == 0\n        {\n            suggestions.append(\"No .jsonl files found under both roots; ensure Codex CLI is writing sessions.\")\n        }\n\n        // Notes suggestions\n        if !notesCurrent.exists {\n            suggestions.append(\"Notes directory does not exist; it will be created on demand under ~/.codmate/notes by default.\")\n        }\n        if notesCurrent.exists, !notesCurrent.isDirectory {\n            suggestions.append(\"Notes path is not a directory; select a folder.\")\n        }\n        if notesCurrent.enumeratedCount == 0, notesDefault.enumeratedCount > 0 {\n            suggestions.append(\"Notes directory is empty; consider switching to default ~/.codmate/notes or migrating.\")\n        }\n\n        // Projects suggestions\n        if !projectsCurrent.exists {\n            suggestions.append(\"Projects directory does not exist; it will be created under ~/.codmate/projects.\")\n        }\n        if projectsCurrent.exists, !projectsCurrent.isDirectory {\n            suggestions.append(\"Projects path is not a directory; select a folder.\")\n        }\n\n        // Claude suggestions (informational)\n        if let cc = claudeCurrent {\n            if !cc.exists {\n                suggestions.append(\"Claude sessions directory not found; if you use Claude Code CLI, ensure it writes logs under ~/.claude/projects.\")\n            }\n        } else if !claudeDefault.exists {\n            suggestions.append(\"Claude default sessions directory (~/.claude/projects) not found.\")\n        }\n\n        if let gc = geminiCurrent {\n            if !gc.exists {\n                suggestions.append(\"Gemini sessions directory not found; ensure Gemini CLI writes logs under ~/.gemini/tmp.\")\n            }\n        } else if !geminiDefault.exists {\n            suggestions.append(\"Gemini default sessions directory (~/.gemini/tmp) not found.\")\n        }\n\n        return SessionsDiagnostics(\n            timestamp: Date(),\n            current: currentProbe,\n            defaultRoot: defaultProbe,\n            notesCurrent: notesCurrent,\n            notesDefault: notesDefault,\n            projectsCurrent: projectsCurrent,\n            projectsDefault: projectsDefault,\n            claudeCurrent: claudeCurrent,\n            claudeDefault: claudeDefault,\n            geminiCurrent: geminiCurrent,\n            geminiDefault: geminiDefault,\n            suggestions: suggestions\n        )\n    }\n\n    // MARK: - Helpers\n    func probe(root: URL, fileExtension: String) async -> SessionsDiagnostics.Probe {\n        var isDir: ObjCBool = false\n        let exists = fm.fileExists(atPath: root.path, isDirectory: &isDir)\n        var count = 0\n        var samples: [String] = []\n        var enumError: String? = nil\n\n        if exists, isDir.boolValue {\n            if let enumerator = fm.enumerator(\n                at: root,\n                includingPropertiesForKeys: [.isRegularFileKey],\n                options: [.skipsHiddenFiles, .skipsPackageDescendants]\n            ) {\n                // Collect URLs synchronously first to avoid Swift 6 async/iterator issues\n                let urls = enumerator.compactMap { $0 as? URL }\n                \n                for url in urls {\n                    if url.pathExtension.lowercased() == fileExtension.lowercased() {\n                        count += 1\n                        if samples.count < 10 { samples.append(url.path) }\n                    }\n                }\n            } else {\n                enumError = \"Failed to open enumerator for \\(root.path)\"\n            }\n        }\n\n        return .init(\n            path: root.path,\n            exists: exists,\n            isDirectory: isDir.boolValue,\n            enumeratedCount: count,\n            sampleFiles: samples,\n            enumeratorError: enumError\n        )\n    }\n}\n"
  },
  {
    "path": "services/SkillsImportService.swift",
    "content": "import Foundation\n\nenum SkillsImportService {\n  struct SourceDescriptor {\n    let label: String\n    let directory: URL\n  }\n\n  static func scan(scope: ExtensionsImportScope, fileManager: FileManager = .default) async -> [SkillImportCandidate] {\n    let sources: [SourceDescriptor]\n    switch scope {\n    case .home:\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      sources = [\n        SourceDescriptor(\n          label: \"Codex\",\n          directory: home.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n        SourceDescriptor(\n          label: \"Claude\",\n          directory: home.appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n        SourceDescriptor(\n          label: \"Gemini\",\n          directory: home.appendingPathComponent(\".gemini\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n      ]\n    case .project(let directory):\n      sources = [\n        SourceDescriptor(\n          label: \"Codex\",\n          directory: directory.appendingPathComponent(\".codex\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n        SourceDescriptor(\n          label: \"Claude\",\n          directory: directory.appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n        SourceDescriptor(\n          label: \"Gemini\",\n          directory: directory.appendingPathComponent(\".gemini\", isDirectory: true)\n            .appendingPathComponent(\"skills\", isDirectory: true)\n        ),\n      ]\n    }\n    let filtered = sources.filter { source in\n      switch source.label {\n      case \"Codex\": return SessionPreferencesStore.isCLIEnabled(.codex)\n      case \"Claude\": return SessionPreferencesStore.isCLIEnabled(.claude)\n      case \"Gemini\": return SessionPreferencesStore.isCLIEnabled(.gemini)\n      default: return true\n      }\n    }\n    return await scan(sources: filtered, fileManager: fileManager)\n  }\n\n  private static func scan(sources: [SourceDescriptor], fileManager: FileManager) async -> [SkillImportCandidate] {\n    let store = SkillsStore()\n    var merged: [String: SkillImportCandidate] = [:]\n\n    for source in sources {\n      guard fileManager.fileExists(atPath: source.directory.path) else { continue }\n      guard let entries = try? fileManager.contentsOfDirectory(\n        at: source.directory,\n        includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey],\n        options: [.skipsHiddenFiles]\n      ) else { continue }\n\n      for entry in entries {\n        guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true else { continue }\n        let skillFile = entry.appendingPathComponent(\"SKILL.md\", isDirectory: false)\n        guard fileManager.fileExists(atPath: skillFile.path) else { continue }\n        if await store.isCodMateManagedSkill(at: entry) { continue }\n\n        let proposedId = entry.lastPathComponent\n        let metadata = try? await store.parseSkillMetadata(at: entry, sourceLabel: \"import\")\n        let name = metadata?.name.isEmpty == false ? metadata?.name ?? proposedId : proposedId\n        let summary = metadata?.summary ?? (metadata?.description ?? \"\")\n\n        if var existing = merged[proposedId] {\n          if !existing.sources.contains(source.label) {\n            existing.sources.append(source.label)\n          }\n          existing.sourcePaths[source.label] = skillFile.path\n          merged[proposedId] = existing\n        } else {\n          merged[proposedId] = SkillImportCandidate(\n            id: proposedId,\n            name: name,\n            summary: summary,\n            sourcePath: entry.path,\n            sources: [source.label],\n            sourcePaths: [source.label: skillFile.path],\n            isSelected: true,\n            hasConflict: false,\n            conflictDetail: nil,\n            resolution: .overwrite,\n            renameId: proposedId,\n            suggestedId: proposedId\n          )\n        }\n      }\n    }\n\n    return merged.values.sorted { $0.id.localizedCaseInsensitiveCompare($1.id) == .orderedAscending }\n  }\n}\n"
  },
  {
    "path": "services/SkillsStore.swift",
    "content": "import Foundation\n\nenum SkillCreationError: LocalizedError {\n  case invalidName(String)\n  case nameConflict(existing: String, suggested: String)\n\n  var errorDescription: String? {\n    switch self {\n    case .invalidName(let message):\n      return message\n    case .nameConflict(let existing, let suggested):\n      return \"A skill named '\\(existing)' already exists. Suggested name: '\\(suggested)'\"\n    }\n  }\n}\n\nactor SkillsStore {\n  struct Paths {\n    let root: URL\n    let libraryDir: URL\n    let indexURL: URL\n\n    static func `default`(fileManager: FileManager = .default) -> Paths {\n      let home = SessionPreferencesStore.getRealUserHomeURL()\n      let root = home.appendingPathComponent(\".codmate\", isDirectory: true)\n        .appendingPathComponent(\"skills\", isDirectory: true)\n      return Paths(\n        root: root,\n        libraryDir: root.appendingPathComponent(\"library\", isDirectory: true),\n        indexURL: root.appendingPathComponent(\"index.json\", isDirectory: false)\n      )\n    }\n  }\n\n  private let paths: Paths\n  private let fm: FileManager\n\n  init(paths: Paths = .default(), fileManager: FileManager = .default) {\n    self.paths = paths\n    self.fm = fileManager\n  }\n\n  func list() -> [SkillRecord] {\n    load()\n  }\n\n  func record(id: String) -> SkillRecord? {\n    load().first(where: { $0.id == id })\n  }\n\n  func saveAll(_ records: [SkillRecord]) {\n    save(records)\n  }\n\n  func uninstall(id: String) {\n    var records = load()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return }\n    let record = records.remove(at: idx)\n    save(records)\n    let path = record.path.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !path.isEmpty else { return }\n    let url = URL(fileURLWithPath: path, isDirectory: true)\n    if fm.fileExists(atPath: url.path) {\n      if isCodMateManagedSkill(at: url) || url.standardizedFileURL.path.hasPrefix(paths.libraryDir.standardizedFileURL.path) {\n        try? fm.removeItem(at: url)\n      }\n    }\n  }\n\n  func refreshMetadata(id: String) -> SkillRecord? {\n    var records = load()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return nil }\n    let record = records[idx]\n    let path = record.path.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !path.isEmpty else { return nil }\n    let url = URL(fileURLWithPath: path, isDirectory: true)\n    guard fm.fileExists(atPath: url.path) else { return nil }\n    let metadata = (try? parseSkillMetadata(at: url, sourceLabel: record.source)) ?? ParsedMetadata(\n      name: record.name,\n      description: record.description,\n      summary: record.summary,\n      tags: record.tags,\n      source: record.source\n    )\n    records[idx].name = metadata.name\n    records[idx].description = metadata.description\n    records[idx].summary = metadata.summary\n    records[idx].tags = metadata.tags\n    save(records)\n    return records[idx]\n  }\n\n  func update(id: String, mutate: (inout SkillRecord) -> Void) {\n    var records = load()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return }\n    mutate(&records[idx])\n    save(records)\n  }\n\n  func upsert(_ record: SkillRecord) {\n    var records = load()\n    if let idx = records.firstIndex(where: { $0.id == record.id }) {\n      records[idx] = record\n    } else {\n      records.append(record)\n    }\n    save(records)\n  }\n\n  func createFromTemplate(name: String, description: String) async throws -> SkillRecord {\n    let skillId = try validateAndNormalizeSkillName(name)\n\n    try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true)\n    let destination = paths.libraryDir.appendingPathComponent(skillId, isDirectory: true)\n\n    if fm.fileExists(atPath: destination.path) {\n      let suggested = suggestNewId(basedOn: skillId)\n      throw SkillCreationError.nameConflict(existing: skillId, suggested: suggested)\n    }\n\n    try fm.createDirectory(at: destination, withIntermediateDirectories: true)\n\n    let skillMarkdown = generateDefaultSkillMarkdown(name: skillId, description: description)\n    let skillFile = destination.appendingPathComponent(\"SKILL.md\", isDirectory: false)\n    try skillMarkdown.write(to: skillFile, atomically: true, encoding: .utf8)\n\n    try writeMarker(to: destination, id: skillId, sourceType: \"template\")\n\n    let record = SkillRecord(\n      id: skillId,\n      name: skillId,\n      description: description,\n      summary: description,\n      tags: [],\n      source: \"Template\",\n      path: destination.path,\n      isEnabled: true,\n      targets: MCPServerTargets(codex: true, claude: true, gemini: false),\n      installedAt: Date()\n    )\n\n    upsert(record)\n    return record\n  }\n\n  func createFromWizard(draft: SkillWizardDraft, enabled: Bool = false) async throws -> SkillRecord {\n    let proposed = draft.id.isEmpty ? draft.name : draft.id\n    let skillId = try validateAndNormalizeSkillName(proposed)\n\n    try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true)\n    let destination = paths.libraryDir.appendingPathComponent(skillId, isDirectory: true)\n\n    if fm.fileExists(atPath: destination.path) {\n      let suggested = suggestNewId(basedOn: skillId)\n      throw SkillCreationError.nameConflict(existing: skillId, suggested: suggested)\n    }\n\n    try fm.createDirectory(at: destination, withIntermediateDirectories: true)\n\n    let skillMarkdown = generateSkillMarkdownFromDraft(draft, id: skillId)\n    let skillFile = destination.appendingPathComponent(\"SKILL.md\", isDirectory: false)\n    try skillMarkdown.write(to: skillFile, atomically: true, encoding: .utf8)\n\n    try writeMarker(to: destination, id: skillId, sourceType: \"wizard\")\n\n    let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description\n    let record = SkillRecord(\n      id: skillId,\n      name: draft.name,\n      description: draft.description,\n      summary: summary,\n      tags: draft.tags,\n      source: \"Wizard\",\n      path: destination.path,\n      isEnabled: enabled,\n      targets: draft.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false),\n      installedAt: Date()\n    )\n\n    upsert(record)\n    return record\n  }\n\n  private func validateAndNormalizeSkillName(_ name: String) throws -> String {\n    let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else {\n      throw SkillCreationError.invalidName(\"Skill name cannot be empty\")\n    }\n\n    let normalized = trimmed\n      .lowercased()\n      .replacingOccurrences(of: \" \", with: \"-\")\n      .replacingOccurrences(of: \"_\", with: \"-\")\n\n    let allowed = CharacterSet(charactersIn: \"abcdefghijklmnopqrstuvwxyz0123456789-\")\n    let filtered = normalized.unicodeScalars.filter { allowed.contains($0) }\n    let result = String(String.UnicodeScalarView(filtered))\n\n    guard !result.isEmpty else {\n      throw SkillCreationError.invalidName(\"Skill name must contain at least one alphanumeric character\")\n    }\n\n    guard result.count <= 64 else {\n      throw SkillCreationError.invalidName(\"Skill name must be 64 characters or less\")\n    }\n\n    return result\n  }\n\n  private func generateDefaultSkillMarkdown(name: String, description: String) -> String {\n    let displayName = name.split(separator: \"-\")\n      .map { $0.prefix(1).uppercased() + $0.dropFirst() }\n      .joined(separator: \" \")\n\n    return \"\"\"\n---\nname: \\(name)\ndescription: \\(description.isEmpty ? \"Custom skill for specific tasks\" : description)\n---\n\n# \\(displayName)\n\n## Overview\n\nThis is a custom skill created from a template. Describe what this skill does and when Claude or Codex should use it.\n\n## Instructions\n\nProvide clear, step-by-step guidance for the AI assistant:\n\n1. First step or action to take\n2. Second step or action\n3. Additional steps as needed\n\n## Examples\n\nShow concrete usage examples to help the AI understand how to apply this skill:\n\n**Example 1: Basic Usage**\n```\nUser: [Example user request]\nAssistant: [Expected behavior or response]\n```\n\n**Example 2: Advanced Usage**\n```\nUser: [Another example]\nAssistant: [Expected behavior]\n```\n\n## Notes\n\n- Add any special considerations or limitations\n- Document required tools or dependencies\n- Include best practices or tips\n\n\"\"\"\n  }\n\n  nonisolated func generateSkillMarkdownFromDraft(_ draft: SkillWizardDraft, id: String) -> String {\n    let title = draft.name.isEmpty ? id : draft.name\n    let summary = draft.summary?.isEmpty == false ? draft.summary! : draft.description\n    let tagsBlock: String = {\n      if draft.tags.isEmpty { return \"\" }\n      let lines = draft.tags.map { \"  - \\($0)\" }.joined(separator: \"\\n\")\n      return \"tags:\\n\\(lines)\\n\"\n    }()\n\n    let instructions: String = {\n      if draft.instructions.isEmpty { return \"\" }\n      return draft.instructions.enumerated().map { index, step in\n        \"\\(index + 1). \\(step)\"\n      }.joined(separator: \"\\n\")\n    }()\n\n    let examples: String = {\n      if draft.examples.isEmpty { return \"\" }\n      return draft.examples.enumerated().map { index, example in\n        let title = example.title.isEmpty ? \"Example \\(index + 1)\" : example.title\n        return \"\"\"\n**\\(title)**\n```\nUser: \\(example.user)\nAssistant: \\(example.assistant)\n```\n\"\"\"\n      }.joined(separator: \"\\n\\n\")\n    }()\n\n    let notes: String = {\n      if draft.notes.isEmpty { return \"\" }\n      return draft.notes.map { \"- \\($0)\" }.joined(separator: \"\\n\")\n    }()\n\n    return \"\"\"\n---\nname: \\(id)\ndescription: \\(draft.description)\nmetadata:\n  short-description: \\(summary)\n\\(tagsBlock)---\n\n# \\(title)\n\n## Overview\n\n\\(draft.overview)\n\n## Instructions\n\n\\(instructions)\n\n## Examples\n\n\\(examples)\n\n## Notes\n\n\\(notes)\n\n\"\"\"\n  }\n\n  func install(\n    request: SkillInstallRequest,\n    resolution: SkillConflictResolution? = nil\n  ) async -> SkillInstallOutcome {\n    do {\n      let result = try await performInstall(request: request, resolution: resolution)\n      return result\n    } catch {\n      return .skipped\n    }\n  }\n\n  func validate(request: SkillInstallRequest) async -> Bool {\n    do {\n      let tempRoot = fm.temporaryDirectory\n        .appendingPathComponent(\"codmate-skill-validate-\\(UUID().uuidString)\", isDirectory: true)\n      try fm.createDirectory(at: tempRoot, withIntermediateDirectories: true)\n      defer { try? fm.removeItem(at: tempRoot) }\n\n      guard let sourceURL = try await resolveSourceURL(request: request, tempRoot: tempRoot) else {\n        return false\n      }\n      _ = try locateSkillRoot(from: sourceURL, request: request, tempRoot: tempRoot)\n      return true\n    } catch {\n      return false\n    }\n  }\n\n  private func performInstall(\n    request: SkillInstallRequest,\n    resolution: SkillConflictResolution? = nil\n  ) async throws -> SkillInstallOutcome {\n    let tempRoot = fm.temporaryDirectory\n      .appendingPathComponent(\"codmate-skill-install-\\(UUID().uuidString)\", isDirectory: true)\n    try fm.createDirectory(at: tempRoot, withIntermediateDirectories: true)\n    defer { try? fm.removeItem(at: tempRoot) }\n\n    guard let sourceURL = try await resolveSourceURL(request: request, tempRoot: tempRoot) else {\n      return .skipped\n    }\n\n    let skillRoot = try locateSkillRoot(from: sourceURL, request: request, tempRoot: tempRoot)\n    let proposedId = skillRoot.lastPathComponent\n    let targetId: String\n    switch resolution {\n    case .rename(let newId):\n      targetId = newId\n    default:\n      targetId = proposedId\n    }\n\n    try fm.createDirectory(at: paths.libraryDir, withIntermediateDirectories: true)\n    let destination = paths.libraryDir.appendingPathComponent(targetId, isDirectory: true)\n\n    if fm.fileExists(atPath: destination.path) {\n      if resolution == .skip { return .skipped }\n      let managed = isCodMateManagedSkill(at: destination)\n      if managed || resolution == .overwrite {\n        try? fm.removeItem(at: destination)\n      } else {\n        let suggested = suggestNewId(basedOn: targetId)\n        let conflict = SkillInstallConflict(\n          proposedId: targetId,\n          destination: destination,\n          existingIsManaged: managed,\n          suggestedId: suggested\n        )\n        return .conflict(conflict)\n      }\n    }\n\n    try fm.copyItem(at: skillRoot, to: destination)\n    try writeMarker(to: destination, id: targetId)\n\n    let sourceLabel = sourceDescription(request: request, fallback: destination.lastPathComponent)\n    let metadata = try parseSkillMetadata(at: destination, sourceLabel: sourceLabel)\n    let existing = load().first(where: { $0.id == targetId })\n    let record = SkillRecord(\n      id: targetId,\n      name: metadata.name,\n      description: metadata.description,\n      summary: metadata.summary,\n      tags: metadata.tags,\n      source: metadata.source,\n      path: destination.path,\n      isEnabled: existing?.isEnabled ?? true,\n      targets: existing?.targets ?? MCPServerTargets(codex: true, claude: true, gemini: false),\n      installedAt: Date()\n    )\n    upsert(record)\n    return .installed(record)\n  }\n\n  struct ParsedMetadata {\n    var name: String\n    var description: String\n    var summary: String\n    var tags: [String]\n    var source: String\n  }\n\n  func parseSkillMetadata(at root: URL, sourceLabel: String) throws -> ParsedMetadata {\n    let skillFile = root.appendingPathComponent(\"SKILL.md\", isDirectory: false)\n    let text = (try? String(contentsOf: skillFile, encoding: .utf8)) ?? \"\"\n    let front = parseFrontMatter(text)\n    let name = front.name.isEmpty ? root.lastPathComponent : front.name\n    let description = front.description.isEmpty ? name : front.description\n    let summary = front.shortDescription.isEmpty ? description : front.shortDescription\n    let tags = front.tags\n    return ParsedMetadata(\n      name: name,\n      description: description,\n      summary: summary,\n      tags: tags,\n      source: sourceLabel\n    )\n  }\n\n  private func load() -> [SkillRecord] {\n    guard fm.fileExists(atPath: paths.indexURL.path) else { return [] }\n    guard let data = try? Data(contentsOf: paths.indexURL) else { return [] }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    return (try? decoder.decode([SkillRecord].self, from: data)) ?? []\n  }\n\n  private func save(_ records: [SkillRecord]) {\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n    encoder.dateEncodingStrategy = .iso8601\n    guard let data = try? encoder.encode(records) else { return }\n    try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true)\n    try? data.write(to: paths.indexURL, options: .atomic)\n  }\n\n  private func resolveSourceURL(request: SkillInstallRequest, tempRoot: URL) async throws -> URL? {\n    switch request.mode {\n    case .folder:\n      guard let url = request.url else { return nil }\n      return url\n    case .zip:\n      guard let url = request.url else { return nil }\n      return try extractZip(at: url, to: tempRoot)\n    case .url:\n      guard let text = request.text?.trimmingCharacters(in: .whitespacesAndNewlines),\n            let url = URL(string: text)\n      else { return nil }\n      let downloaded = try await downloadURL(url, to: tempRoot)\n      if downloaded.pathExtension.lowercased() == \"zip\" {\n        return try extractZip(at: downloaded, to: tempRoot)\n      }\n      return downloaded\n    }\n  }\n\n  private func locateSkillRoot(from source: URL, request: SkillInstallRequest, tempRoot: URL) throws -> URL {\n    let fm = FileManager.default\n    let isDirectory = (try? source.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false\n    if isDirectory {\n      if hasSkillFile(in: source) { return source }\n      let candidates = try fm.contentsOfDirectory(at: source, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])\n      if let nested = candidates.first(where: { hasSkillFile(in: $0) }) { return nested }\n    } else if hasSkillFile(in: source.deletingLastPathComponent()) {\n      return source.deletingLastPathComponent()\n    }\n\n    let candidates = try fm.contentsOfDirectory(at: tempRoot, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles])\n      .filter { $0.lastPathComponent != \"__MACOSX\" }\n    if candidates.count == 1 {\n      let single = candidates[0]\n      if hasSkillFile(in: single) { return single }\n      let nested = try fm.contentsOfDirectory(at: single, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])\n      if let hit = nested.first(where: { hasSkillFile(in: $0) }) { return hit }\n    }\n    if hasSkillFile(in: tempRoot) { return tempRoot }\n    throw NSError(domain: \"CodMate\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"SKILL.md not found\"])\n  }\n\n  private func hasSkillFile(in dir: URL) -> Bool {\n    fm.fileExists(atPath: dir.appendingPathComponent(\"SKILL.md\", isDirectory: false).path)\n  }\n\n  private func downloadURL(_ url: URL, to tempRoot: URL) async throws -> URL {\n    let (data, _) = try await URLSession.shared.data(from: url)\n    let ext = url.pathExtension.isEmpty ? \"download\" : url.pathExtension\n    let target = tempRoot.appendingPathComponent(\"skill.\\(ext)\", isDirectory: false)\n    try data.write(to: target, options: .atomic)\n    return target\n  }\n\n  private func extractZip(at url: URL, to tempRoot: URL) throws -> URL {\n    let ditto = Process()\n    ditto.executableURL = URL(fileURLWithPath: \"/usr/bin/ditto\")\n    ditto.arguments = [\"-x\", \"-k\", url.path, tempRoot.path]\n    let pipe = Pipe()\n    ditto.standardOutput = pipe\n    ditto.standardError = pipe\n    try ditto.run()\n    ditto.waitUntilExit()\n    if ditto.terminationStatus != 0 {\n      let data = pipe.fileHandleForReading.readDataToEndOfFile()\n      let output = String(data: data, encoding: .utf8) ?? \"\"\n      throw NSError(domain: \"CodMate\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"Failed to extract zip: \\(output)\"])\n    }\n    return tempRoot\n  }\n\n  func suggestNewId(basedOn id: String) -> String {\n    let base = id.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !base.isEmpty else { return \"skill\" }\n    var i = 2\n    var candidate = \"\\(base)-\\(i)\"\n    while fm.fileExists(atPath: paths.libraryDir.appendingPathComponent(candidate).path) {\n      i += 1\n      candidate = \"\\(base)-\\(i)\"\n    }\n    return candidate\n  }\n\n  private func sourceDescription(request: SkillInstallRequest, fallback: String) -> String {\n    switch request.mode {\n    case .folder:\n      return request.url?.path ?? fallback\n    case .zip:\n      return request.url?.path ?? fallback\n    case .url:\n      return request.text?.trimmingCharacters(in: .whitespacesAndNewlines) ?? fallback\n    }\n  }\n\n  func writeMarker(to dir: URL, id: String, sourceType: String = \"installed\") throws {\n    let marker = dir.appendingPathComponent(\".codmate.json\", isDirectory: false)\n    let obj: [String: Any] = [\n      \"managedByCodMate\": true,\n      \"id\": id,\n      \"sourceType\": sourceType\n    ]\n    let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted])\n    try data.write(to: marker, options: .atomic)\n  }\n\n  func isCodMateManagedSkill(at dir: URL) -> Bool {\n    let marker = dir.appendingPathComponent(\".codmate.json\", isDirectory: false)\n    guard fm.fileExists(atPath: marker.path),\n          let data = try? Data(contentsOf: marker),\n          let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n    else { return false }\n    return (obj[\"managedByCodMate\"] as? Bool) == true\n  }\n\n  func getSourceType(at dir: URL) -> String? {\n    let marker = dir.appendingPathComponent(\".codmate.json\", isDirectory: false)\n    guard fm.fileExists(atPath: marker.path),\n          let data = try? Data(contentsOf: marker),\n          let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n    else { return nil }\n    return obj[\"sourceType\"] as? String\n  }\n\n  func conflictInfo(forProposedId id: String) -> SkillInstallConflict? {\n    let dest = paths.libraryDir.appendingPathComponent(id, isDirectory: true)\n    guard fm.fileExists(atPath: dest.path) else { return nil }\n    let managed = isCodMateManagedSkill(at: dest)\n    let suggested = suggestNewId(basedOn: id)\n    return SkillInstallConflict(\n      proposedId: id,\n      destination: dest,\n      existingIsManaged: managed,\n      suggestedId: suggested\n    )\n  }\n\n  func markImported(id: String) {\n    var records = load()\n    guard let idx = records.firstIndex(where: { $0.id == id }) else { return }\n    records[idx].source = \"Import\"\n    save(records)\n    let dir = URL(fileURLWithPath: records[idx].path, isDirectory: true)\n    try? writeMarker(to: dir, id: id, sourceType: \"import\")\n  }\n\n  private struct FrontMatter {\n    var name: String = \"\"\n    var description: String = \"\"\n    var shortDescription: String = \"\"\n    var tags: [String] = []\n  }\n\n  private func parseFrontMatter(_ text: String) -> FrontMatter {\n    var result = FrontMatter()\n    let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false)\n    guard lines.first?.trimmingCharacters(in: .whitespaces) == \"---\" else { return result }\n    var idx = 1\n    var inMetadata = false\n    var inTagsList = false\n    while idx < lines.count {\n      let raw = String(lines[idx])\n      let trimmed = raw.trimmingCharacters(in: .whitespaces)\n      if trimmed == \"---\" { break }\n      if trimmed.isEmpty || trimmed.hasPrefix(\"#\") {\n        idx += 1\n        continue\n      }\n      let indent = raw.prefix { $0 == \" \" || $0 == \"\\t\" }.count\n      if indent == 0 {\n        inMetadata = false\n        inTagsList = false\n        if let colon = trimmed.firstIndex(of: \":\") {\n          let key = String(trimmed[..<colon]).trimmingCharacters(in: .whitespaces)\n          let value = String(trimmed[trimmed.index(after: colon)...]).trimmingCharacters(in: .whitespaces)\n          if key == \"metadata\" {\n            inMetadata = true\n          } else if key == \"tags\" {\n            if value.hasPrefix(\"[\") {\n              result.tags = parseInlineArray(value)\n            } else if !value.isEmpty {\n              result.tags = [unquote(value)]\n            } else {\n              inTagsList = true\n            }\n          } else if key == \"name\" {\n            result.name = unquote(value)\n          } else if key == \"description\" {\n            result.description = unquote(value)\n          }\n        }\n      } else if inMetadata {\n        let line = trimmed\n        if let colon = line.firstIndex(of: \":\") {\n          let key = String(line[..<colon]).trimmingCharacters(in: .whitespaces)\n          let value = String(line[line.index(after: colon)...]).trimmingCharacters(in: .whitespaces)\n          if key == \"short-description\" {\n            result.shortDescription = unquote(value)\n          }\n        }\n      } else if inTagsList {\n        if trimmed.hasPrefix(\"-\") {\n          let tag = trimmed.replacingOccurrences(of: \"-\", with: \"\", options: .anchored)\n            .trimmingCharacters(in: .whitespaces)\n          if !tag.isEmpty { result.tags.append(unquote(tag)) }\n        }\n      }\n      idx += 1\n    }\n    return result\n  }\n\n  private func parseInlineArray(_ value: String) -> [String] {\n    var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n    if trimmed.hasPrefix(\"[\") { trimmed.removeFirst() }\n    if trimmed.hasSuffix(\"]\") { trimmed.removeLast() }\n    return trimmed.split(separator: \",\").map { unquote(String($0).trimmingCharacters(in: .whitespaces)) }\n  }\n\n  private func unquote(_ value: String) -> String {\n    var trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n    if (trimmed.hasPrefix(\"\\\"\") && trimmed.hasSuffix(\"\\\"\")) || (trimmed.hasPrefix(\"'\") && trimmed.hasSuffix(\"'\")) {\n      trimmed.removeFirst()\n      trimmed.removeLast()\n    }\n    return trimmed\n  }\n}\n"
  },
  {
    "path": "services/SkillsSyncService.swift",
    "content": "import Foundation\n\nactor SkillsSyncService {\n  private let fm: FileManager\n  private let libraryDir: URL\n\n  init(fileManager: FileManager = .default, libraryDir: URL? = nil) {\n    self.fm = fileManager\n    self.libraryDir = libraryDir ?? SkillsStore.Paths.default().libraryDir\n  }\n\n  func syncGlobal(skills: [SkillRecord]) -> [SkillSyncWarning] {\n    let home = SessionPreferencesStore.getRealUserHomeURL()\n    let codexDir = home.appendingPathComponent(\".codex\", isDirectory: true).appendingPathComponent(\"skills\", isDirectory: true)\n    let claudeDir = home.appendingPathComponent(\".claude\", isDirectory: true).appendingPathComponent(\"skills\", isDirectory: true)\n    let geminiDir = home.appendingPathComponent(\".gemini\", isDirectory: true).appendingPathComponent(\"skills\", isDirectory: true)\n\n    var warnings: [SkillSyncWarning] = []\n    if SessionPreferencesStore.isCLIEnabled(.codex) {\n      warnings.append(contentsOf: syncSkills(skills: skills, target: .codex, destination: codexDir))\n    }\n    if SessionPreferencesStore.isCLIEnabled(.claude) {\n      warnings.append(contentsOf: syncSkills(skills: skills, target: .claude, destination: claudeDir))\n    }\n    if SessionPreferencesStore.isCLIEnabled(.gemini) {\n      warnings.append(contentsOf: syncSkills(skills: skills, target: .gemini, destination: geminiDir))\n    }\n    return warnings\n  }\n\n  func syncProject(skills: [SkillRecord], selections: [SkillSelection], projectDirectory: URL) -> [SkillSyncWarning] {\n    let codexDir = projectDirectory.appendingPathComponent(\".codex\", isDirectory: true)\n      .appendingPathComponent(\"skills\", isDirectory: true)\n    let claudeDir = projectDirectory.appendingPathComponent(\".claude\", isDirectory: true)\n      .appendingPathComponent(\"skills\", isDirectory: true)\n    let geminiDir = projectDirectory.appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"skills\", isDirectory: true)\n\n    var warnings: [SkillSyncWarning] = []\n    let selectedSkills = selections.reduce(into: [String: SkillSelection]()) { $0[$1.id] = $1 }\n    let chosen = skills.filter { selectedSkills[$0.id]?.isSelected == true }\n\n    if SessionPreferencesStore.isCLIEnabled(.codex) {\n      warnings.append(contentsOf: syncSkills(\n        skills: chosen,\n        target: .codex,\n        destination: codexDir,\n        selectionOverride: selectedSkills\n      ))\n    }\n    if SessionPreferencesStore.isCLIEnabled(.claude) {\n      warnings.append(contentsOf: syncSkills(\n        skills: chosen,\n        target: .claude,\n        destination: claudeDir,\n        selectionOverride: selectedSkills\n      ))\n    }\n    if SessionPreferencesStore.isCLIEnabled(.gemini) {\n      warnings.append(contentsOf: syncSkills(\n        skills: chosen,\n        target: .gemini,\n        destination: geminiDir,\n        selectionOverride: selectedSkills\n      ))\n    }\n    return warnings\n  }\n\n  struct SkillSelection: Hashable {\n    var id: String\n    var isSelected: Bool\n    var targets: MCPServerTargets\n  }\n\n  private func syncSkills(\n    skills: [SkillRecord],\n    target: MCPServerTarget,\n    destination: URL,\n    selectionOverride: [String: SkillSelection]? = nil\n  ) -> [SkillSyncWarning] {\n    let selected = skills.filter { record in\n      if let override = selectionOverride?[record.id] {\n        return override.isSelected && override.targets.isEnabled(for: target)\n      }\n      return record.isEnabled && record.targets.isEnabled(for: target)\n    }\n\n    if selected.isEmpty {\n      removeManagedEntries(keeping: [], at: destination)\n      return []\n    }\n\n    try? fm.createDirectory(at: destination, withIntermediateDirectories: true)\n    let wanted = Set(selected.map { $0.id })\n\n    var warnings: [SkillSyncWarning] = []\n    for record in selected {\n      if record.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n        warnings.append(SkillSyncWarning(message: \"\\(record.id) has no install path.\" ))\n        continue\n      }\n      let dest = destination.appendingPathComponent(record.id, isDirectory: true)\n      let src = URL(fileURLWithPath: record.path, isDirectory: true)\n      do {\n        // Codex CLI skips symlinks when loading skills, so we must use copy for codex target\n        // Gemini CLI also supports symlinks, so we can use symlinks for both claude and gemini\n        let forceCopy = (target == .codex)\n        try ensureSkillLinked(from: src, to: dest, id: record.id, forceCopy: forceCopy)\n      } catch {\n        warnings.append(SkillSyncWarning(message: \"\\(record.id) could not sync to \\(destination.path)\"))\n      }\n    }\n\n    removeManagedEntries(keeping: wanted, at: destination)\n    return warnings\n  }\n\n  private func removeManagedEntries(keeping ids: Set<String>, at destination: URL) {\n    guard let entries = try? fm.contentsOfDirectory(at: destination, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { return }\n    for entry in entries {\n      let name = entry.lastPathComponent\n      guard !ids.contains(name) else { continue }\n      if isCodMateManagedSkill(at: entry) {\n        try? fm.removeItem(at: entry)\n      }\n    }\n  }\n\n  private func ensureSkillLinked(from source: URL, to dest: URL, id: String, forceCopy: Bool = false) throws {\n    if fm.fileExists(atPath: dest.path) {\n      if isSymbolicLink(dest) {\n        let link = try? fm.destinationOfSymbolicLink(atPath: dest.path)\n        if let link, URL(fileURLWithPath: link).standardizedFileURL == source.standardizedFileURL {\n          // If forceCopy is true but we have a symlink, remove it and copy\n          if forceCopy {\n            try fm.removeItem(at: dest)\n          } else {\n            return\n          }\n        } else {\n          try fm.removeItem(at: dest)\n        }\n      } else if isCodMateManagedSkill(at: dest) {\n        // Check if it's already a copy pointing to the same source\n        let marker = dest.appendingPathComponent(\".codmate.json\", isDirectory: false)\n        if fm.fileExists(atPath: marker.path),\n           let data = try? Data(contentsOf: marker),\n           let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n           (obj[\"id\"] as? String) == id {\n          return  // Already synced\n        }\n        try fm.removeItem(at: dest)\n      } else {\n        throw NSError(domain: \"CodMate\", code: -1, userInfo: [NSLocalizedDescriptionKey: \"Skill conflict at \\(dest.path)\"])\n      }\n    }\n\n    if forceCopy {\n      // Force copy instead of symlink (needed for Codex CLI which skips symlinks)\n      try fm.copyItem(at: source, to: dest)\n      try writeMarker(to: dest, id: id)\n    } else {\n      do {\n        try fm.createSymbolicLink(at: dest, withDestinationURL: source)\n      } catch {\n        try fm.copyItem(at: source, to: dest)\n        try writeMarker(to: dest, id: id)\n      }\n    }\n  }\n\n  private func isSymbolicLink(_ url: URL) -> Bool {\n    let values = try? url.resourceValues(forKeys: [.isSymbolicLinkKey])\n    return values?.isSymbolicLink ?? false\n  }\n\n  private func writeMarker(to dir: URL, id: String) throws {\n    let marker = dir.appendingPathComponent(\".codmate.json\", isDirectory: false)\n    let obj: [String: Any] = [\n      \"managedByCodMate\": true,\n      \"id\": id\n    ]\n    let data = try JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted])\n    try data.write(to: marker, options: .atomic)\n  }\n\n  private func isCodMateManagedSkill(at dir: URL) -> Bool {\n    if isSymbolicLink(dir) {\n      if let target = try? fm.destinationOfSymbolicLink(atPath: dir.path) {\n        let resolved = URL(fileURLWithPath: target).standardizedFileURL\n        return resolved.path.hasPrefix(libraryDir.standardizedFileURL.path)\n      }\n      return false\n    }\n    let marker = dir.appendingPathComponent(\".codmate.json\", isDirectory: false)\n    guard fm.fileExists(atPath: marker.path),\n          let data = try? Data(contentsOf: marker),\n          let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n    else { return false }\n    return (obj[\"managedByCodMate\"] as? Bool) == true\n  }\n}\n"
  },
  {
    "path": "services/StatusBarLogStore.swift",
    "content": "import CoreGraphics\nimport Foundation\n\n@MainActor\nfinal class StatusBarLogStore: ObservableObject {\n  static let shared = StatusBarLogStore()\n\n  @Published private(set) var entries: [StatusBarLogEntry] = []\n  @Published private(set) var isAutoVisible: Bool = false\n  @Published var isExpanded: Bool = false {\n    didSet {\n      if isExpanded {\n        autoHideTask?.cancel()\n        autoHideTask = nil\n      }\n    }\n  }\n  @Published var expandedHeight: CGFloat = 200\n  @Published private(set) var activeTaskCount: Int = 0\n  @Published private(set) var isInteracting: Bool = false\n\n  let collapsedHeight: CGFloat = 26\n  private let minExpandedHeight: CGFloat = 120\n  private let maxExpandedHeight: CGFloat = 520\n  private var autoCollapseEnabled: Bool = true\n\n  private let maxEntries = 200\n  private let autoHideSeconds: TimeInterval = 6\n  private var autoHideTask: Task<Void, Never>?\n  private var activeTaskTokens: Set<String> = []\n\n  private init() {}\n\n  func post(\n    _ message: String,\n    level: StatusBarLogLevel = .info,\n    source: String? = nil\n  ) {\n    let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return }\n    entries.append(StatusBarLogEntry(message: trimmed, level: level, source: source))\n    if entries.count > maxEntries {\n      entries.removeFirst(entries.count - maxEntries)\n    }\n    isAutoVisible = true\n    scheduleAutoHide()\n  }\n\n  func beginTask(\n    _ message: String,\n    level: StatusBarLogLevel = .info,\n    source: String? = nil\n  ) -> String {\n    let token = UUID().uuidString\n    activeTaskTokens.insert(token)\n    activeTaskCount = activeTaskTokens.count\n    post(message, level: level, source: source)\n    return token\n  }\n\n  func endTask(\n    _ token: String,\n    message: String? = nil,\n    level: StatusBarLogLevel = .info,\n    source: String? = nil\n  ) {\n    if activeTaskTokens.remove(token) != nil {\n      activeTaskCount = activeTaskTokens.count\n    }\n    if let message, !message.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      post(message, level: level, source: source)\n    } else {\n      isAutoVisible = true\n      scheduleAutoHide()\n    }\n  }\n\n  func clear() {\n    entries.removeAll()\n    isAutoVisible = false\n    autoHideTask?.cancel()\n    autoHideTask = nil\n  }\n\n  func setExpandedHeight(_ height: CGFloat) {\n    let clamped = min(max(height, minExpandedHeight), maxExpandedHeight)\n    if abs(Double(clamped - expandedHeight)) > 0.5 {\n      expandedHeight = clamped\n    }\n  }\n\n  func setAutoCollapseEnabled(_ isEnabled: Bool) {\n    autoCollapseEnabled = isEnabled\n    if !isEnabled {\n      autoHideTask?.cancel()\n      autoHideTask = nil\n    }\n  }\n\n  func setInteracting(_ isInteracting: Bool) {\n    guard self.isInteracting != isInteracting else { return }\n    self.isInteracting = isInteracting\n    if isInteracting {\n      autoHideTask?.cancel()\n      autoHideTask = nil\n    } else {\n      scheduleAutoHide()\n    }\n  }\n\n  func reveal(expanded: Bool = false) {\n    isAutoVisible = true\n    if expanded {\n      isExpanded = true\n    }\n    scheduleAutoHide()\n  }\n\n  private func scheduleAutoHide() {\n    guard autoCollapseEnabled else { return }\n    if isExpanded { return }\n    autoHideTask?.cancel()\n    let delay = autoHideSeconds\n    autoHideTask = Task { [weak self] in\n      guard let self else { return }\n      try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))\n      await MainActor.run {\n        guard self.autoCollapseEnabled else { return }\n        guard self.activeTaskCount == 0 else { return }\n        if self.isExpanded {\n          return\n        }\n        if self.isInteracting {\n          self.scheduleAutoHide()\n          return\n        }\n        self.isAutoVisible = false\n        self.isExpanded = false\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "services/SystemNotifier.swift",
    "content": "import Foundation\nimport UserNotifications\n\nfinal class SystemNotifier: NSObject {\n    @MainActor static let shared = SystemNotifier()\n    private var bootstrapped = false\n\n    @MainActor func bootstrap() {\n        guard !bootstrapped else { return }\n        bootstrapped = true\n        let center = UNUserNotificationCenter.current()\n        center.delegate = self\n        Task { _ = try? await center.requestAuthorization(options: [.alert, .sound, .badge]) }\n    }\n\n    // MARK: - Public API\n    @MainActor func notify(title: String, body: String) async {\n        await notify(title: title, body: body, threadId: nil)\n    }\n\n    @MainActor func notify(title: String, body: String, threadId: String?) async {\n        let center = UNUserNotificationCenter.current()\n        // Ensure we have requested permission at least once\n        bootstrap()\n        // Query settings to decide if we need a fallback\n        let status = await SystemNotifier.authorizationStatus()\n\n        let content = UNMutableNotificationContent()\n        content.title = title\n        content.body = body\n        if let threadId { content.threadIdentifier = threadId }\n        let request = UNNotificationRequest(\n            identifier: UUID().uuidString,\n            content: content,\n            trigger: UNTimeIntervalNotificationTrigger(timeInterval: 0.2, repeats: false)\n        )\n        do {\n            try await center.add(request)\n        } catch {\n            // Fallback to AppleScript if UNUserNotifications fails\n            Self.notifyViaOSAScript(title: title, body: body)\n            return\n        }\n        // If not authorized to show alerts, attempt fallback so user still gets a toast\n        if status != .authorized {\n            Self.notifyViaOSAScript(title: title, body: body)\n        }\n    }\n\n    // Specialized helper: agent completed and awaits user follow-up.\n    // Also posts an in-app notification to update list indicators.\n    @MainActor func notifyAgentCompleted(sessionID: String, message: String) async {\n        await notify(title: \"CodMate\", body: message, threadId: \"agent\")\n        NotificationCenter.default.post(\n            name: .codMateAgentCompleted,\n            object: nil,\n            userInfo: [\"sessionID\": sessionID, \"message\": message]\n        )\n    }\n\n    // MARK: - Internals\n    private static func notifyViaOSAScript(title: String, body: String) {\n        let script = \"display notification \\\"\\(body.replacingOccurrences(of: \"\\\\\\\\\", with: \"\\\\\\\\\\\\\\\\\").replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\"))\\\" with title \\\"\\(title.replacingOccurrences(of: \"\\\\\\\\\", with: \"\\\\\\\\\\\\\\\\\").replacingOccurrences(of: \"\\\"\", with: \"\\\\\\\"\"))\\\"\"\n        let p = Process()\n        p.executableURL = URL(fileURLWithPath: \"/usr/bin/osascript\")\n        p.arguments = [\"-e\", script]\n        try? p.run()\n    }\n\n    private static func authorizationStatus() async -> UNAuthorizationStatus {\n        await withCheckedContinuation { cont in\n            UNUserNotificationCenter.current().getNotificationSettings { settings in\n                cont.resume(returning: settings.authorizationStatus)\n            }\n        }\n    }\n}\n\nnonisolated extension SystemNotifier: UNUserNotificationCenterDelegate {\n    func userNotificationCenter(\n        _ center: UNUserNotificationCenter,\n        willPresent notification: UNNotification,\n        withCompletionHandler completionHandler:\n            @escaping (UNNotificationPresentationOptions) -> Void\n    ) {\n        // Call completion handler directly without actor hop to avoid sending non-Sendable closure\n        completionHandler([.banner, .list, .sound])\n    }\n}\n\nextension Notification.Name {\n  static let codMateAgentCompleted = Notification.Name(\"CodMate.AgentCompleted\")\n  static let codMateStartEmbeddedNewProject = Notification.Name(\"CodMate.StartEmbeddedNewProject\")\n  static let codMateStartEmbeddedNewSession = Notification.Name(\"CodMate.StartEmbeddedNewSession\")\n  static let codMateToggleSidebar = Notification.Name(\"CodMate.ToggleSidebar\")\n  static let codMateToggleList = Notification.Name(\"CodMate.ToggleList\")\n  static let codMateRepoAuthorizationChanged = Notification.Name(\"CodMate.RepoAuthorizationChanged\")\n  static let codMateTerminalExited = Notification.Name(\"CodMate.TerminalExited\")\n  static let codMateTerminalSessionsUpdated = Notification.Name(\"CodMate.TerminalSessionsUpdated\")\n  static let codMateConversationFilter = Notification.Name(\"CodMate.ConversationFilter\")\n  static let codMateFocusGlobalSearch = Notification.Name(\"CodMate.FocusGlobalSearch\")\n  static let codMateExpandProjectTree = Notification.Name(\"CodMate.ExpandProjectTree\")\n  static let codMateResignQuickSearch = Notification.Name(\"CodMate.ResignQuickSearch\")\n  static let codMateQuickSearchFocusBlocked = Notification.Name(\"CodMate.QuickSearchFocusBlocked\")\n  static let codMateActiveProviderChanged = Notification.Name(\"CodMate.ActiveProviderChanged\")\n  static let codMateResumeSession = Notification.Name(\"CodMate.ResumeSession\")\n  static let codMateGlobalRefresh = Notification.Name(\"CodMate.GlobalRefresh\")\n  static let codMateOpenMainWindow = Notification.Name(\"CodMate.OpenMainWindow\")\n  static let codMateCollapseAllTasks = Notification.Name(\"CodMate.CollapseAllTasks\")\n  static let codMateExpandAllTasks = Notification.Name(\"CodMate.ExpandAllTasks\")\n  static let codMateOpenSettings = Notification.Name(\"CodMate.OpenSettings\")\n  static let codMateFocusSessionSummary = Notification.Name(\"CodMate.FocusSessionSummary\")\n  static let codMateOpenNewProject = Notification.Name(\"CodMate.OpenNewProject\")\n  static let codMateRefreshRequested = Notification.Name(\"CodMate.RefreshRequested\")\n}\n"
  },
  {
    "path": "services/TasksStore.swift",
    "content": "import Foundation\n\n// TasksStore: manages task metadata and session-to-task relationships\n// Layout (under ~/.codmate/tasks):\n//  - metadata/<taskId>.json  (one file per task)\n//  - relationships.json      (central mapping: { version, sessionToTask, taskToProject })\n\nstruct TaskMeta: Codable, Hashable, Sendable {\n    var id: UUID\n    var title: String\n    var description: String?\n    var taskType: TaskType\n    var projectId: String\n    var createdAt: Date\n    var updatedAt: Date\n    var sharedContext: [ContextItem]\n    var agentsConfig: String?\n    var memoryItems: [String]\n    var sessionIds: [String]\n    var status: TaskStatus\n    var tags: [String]\n    var primaryProvider: ProjectSessionSource?\n\n    init(from task: CodMateTask) {\n        self.id = task.id\n        self.title = task.title\n        self.description = task.description\n        self.taskType = task.taskType\n        self.projectId = task.projectId\n        self.createdAt = task.createdAt\n        self.updatedAt = task.updatedAt\n        self.sharedContext = task.sharedContext\n        self.agentsConfig = task.agentsConfig\n        self.memoryItems = task.memoryItems\n        self.sessionIds = task.sessionIds\n        self.status = task.status\n        self.tags = task.tags\n        self.primaryProvider = task.primaryProvider\n    }\n\n    // Custom decoder to handle backward compatibility with old task data\n    init(from decoder: Decoder) throws {\n        let container = try decoder.container(keyedBy: CodingKeys.self)\n\n        id = try container.decode(UUID.self, forKey: .id)\n        title = try container.decode(String.self, forKey: .title)\n        description = try container.decodeIfPresent(String.self, forKey: .description)\n\n        // Provide default value for taskType if not present (backward compatibility)\n        taskType = try container.decodeIfPresent(TaskType.self, forKey: .taskType) ?? .other\n\n        projectId = try container.decode(String.self, forKey: .projectId)\n        createdAt = try container.decode(Date.self, forKey: .createdAt)\n        updatedAt = try container.decode(Date.self, forKey: .updatedAt)\n        sharedContext = try container.decode([ContextItem].self, forKey: .sharedContext)\n        agentsConfig = try container.decodeIfPresent(String.self, forKey: .agentsConfig)\n        memoryItems = try container.decode([String].self, forKey: .memoryItems)\n        sessionIds = try container.decode([String].self, forKey: .sessionIds)\n        status = try container.decode(TaskStatus.self, forKey: .status)\n        tags = try container.decode([String].self, forKey: .tags)\n\n        // primaryProvider is optional, so old data without it will have nil\n        primaryProvider = try container.decodeIfPresent(ProjectSessionSource.self, forKey: .primaryProvider)\n    }\n\n    func asTask() -> CodMateTask {\n        CodMateTask(\n            id: id,\n            title: title,\n            description: description,\n            taskType: taskType,\n            projectId: projectId,\n            createdAt: createdAt,\n            updatedAt: updatedAt,\n            sharedContext: sharedContext,\n            agentsConfig: agentsConfig,\n            memoryItems: memoryItems,\n            sessionIds: sessionIds,\n            status: status,\n            tags: tags,\n            primaryProvider: primaryProvider\n        )\n    }\n}\n\nactor TasksStore {\n    struct Paths {\n        let root: URL\n        let metadataDir: URL\n        let relationshipsURL: URL\n\n        static func `default`(fileManager: FileManager = .default) -> Paths {\n            let home = fileManager.homeDirectoryForCurrentUser\n            let root = home.appendingPathComponent(\".codmate\", isDirectory: true)\n                .appendingPathComponent(\"tasks\", isDirectory: true)\n            return Paths(\n                root: root,\n                metadataDir: root.appendingPathComponent(\"metadata\", isDirectory: true),\n                relationshipsURL: root.appendingPathComponent(\"relationships.json\", isDirectory: false)\n            )\n        }\n    }\n\n    private let fm: FileManager\n    private let paths: Paths\n\n    // Runtime caches\n    private var tasks: [UUID: TaskMeta] = [:] // taskId -> meta\n    private var sessionToTask: [String: UUID] = [:] // sessionId -> taskId\n\n    // Special \"Others\" task ID - consistent across sessions\n    static let othersTaskId = UUID(uuidString: \"00000000-0000-0000-0000-000000000001\")!\n\n    init(paths: Paths = .default(), fileManager: FileManager = .default) {\n        self.fm = fileManager\n        self.paths = paths\n        try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true)\n\n        // Load relationships\n        if let data = try? Data(contentsOf: paths.relationshipsURL),\n           let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],\n           let map = obj[\"sessionToTask\"] as? [String: String]\n        {\n            self.sessionToTask = map.compactMapValues { UUID(uuidString: $0) }\n        }\n\n        // Load metadata\n        var loadedTasks: [UUID: TaskMeta] = [:]\n        if let en = fm.enumerator(at: paths.metadataDir, includingPropertiesForKeys: [.isRegularFileKey], options: [.skipsHiddenFiles]) {\n            let dec = JSONDecoder()\n            dec.dateDecodingStrategy = .iso8601\n            for case let url as URL in en {\n                if url.pathExtension.lowercased() != \"json\" { continue }\n                if let data = try? Data(contentsOf: url),\n                   let meta = try? dec.decode(TaskMeta.self, from: data)\n                {\n                    loadedTasks[meta.id] = meta\n                }\n            }\n        }\n        self.tasks = loadedTasks\n\n        // Ensure \"Others\" task exists (directly inline to avoid actor isolation issue in init)\n        if self.tasks[Self.othersTaskId] == nil {\n            let othersTask = CodMateTask(\n                id: Self.othersTaskId,\n                title: \"Others\",\n                description: \"Automatically collected sessions without explicit task assignment\",\n                taskType: .other,\n                projectId: \"others\",\n                status: .inProgress\n            )\n            let meta = TaskMeta(from: othersTask)\n            self.tasks[Self.othersTaskId] = meta\n\n            // Save to disk\n            let url = paths.metadataDir.appendingPathComponent(meta.id.uuidString + \".json\")\n            let enc = JSONEncoder()\n            enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n            enc.dateEncodingStrategy = .iso8601\n            if let data = try? enc.encode(meta) {\n                try? data.write(to: url, options: .atomic)\n            }\n        }\n    }\n\n    // MARK: - Others Task Management\n\n    func assignToOthers(sessionId: String) {\n        assignSessions([sessionId], to: Self.othersTaskId)\n    }\n\n    // MARK: - Public API\n\n    func listTasks() -> [CodMateTask] {\n        tasks.values.map { $0.asTask() }.sorted { $0.updatedAt > $1.updatedAt }\n    }\n\n    func listTasks(for projectId: String) -> [CodMateTask] {\n        tasks.values\n            .filter { $0.projectId == projectId }\n            .map { $0.asTask() }\n            .sorted { $0.updatedAt > $1.updatedAt }\n    }\n\n    func getTask(id: UUID) -> CodMateTask? {\n        tasks[id]?.asTask()\n    }\n\n    func upsertTask(_ task: CodMateTask) {\n        var meta = tasks[task.id] ?? TaskMeta(from: task)\n        meta.title = task.title\n        meta.description = task.description\n        meta.taskType = task.taskType\n        meta.projectId = task.projectId\n        meta.sharedContext = task.sharedContext\n        meta.agentsConfig = task.agentsConfig\n        meta.memoryItems = task.memoryItems\n        meta.sessionIds = task.sessionIds\n        meta.status = task.status\n        meta.tags = task.tags\n        meta.primaryProvider = task.primaryProvider\n        meta.updatedAt = Date()\n        tasks[task.id] = meta\n\n        // Update session-to-task mappings\n        for sessionId in task.sessionIds {\n            sessionToTask[sessionId] = task.id\n        }\n\n        saveTaskMeta(meta)\n        saveRelationships()\n    }\n\n    func deleteTask(id: UUID) {\n        // Remove meta\n        tasks.removeValue(forKey: id)\n        let metaURL = paths.metadataDir.appendingPathComponent(id.uuidString + \".json\")\n\n        // Move to Trash instead of permanent deletion\n        var resulting: NSURL?\n        if fm.fileExists(atPath: metaURL.path) {\n            do { try fm.trashItem(at: metaURL, resultingItemURL: &resulting) } catch { /* best-effort */ }\n        }\n\n        // Unassign all sessions under this task\n        var changed = false\n        for (sid, tid) in sessionToTask where tid == id {\n            sessionToTask.removeValue(forKey: sid)\n            changed = true\n        }\n        if changed { saveRelationships() }\n    }\n\n    func assignSessions(_ sessionIds: [String], to taskId: UUID?) {\n        var changed = false\n        for sid in sessionIds {\n            let trimmed = sid.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed.isEmpty { continue }\n\n            if let tid = taskId {\n                if sessionToTask[trimmed] != tid {\n                    sessionToTask[trimmed] = tid\n                    changed = true\n                }\n            } else {\n                if sessionToTask.removeValue(forKey: trimmed) != nil {\n                    changed = true\n                }\n            }\n        }\n        if changed { saveRelationships() }\n    }\n\n    func taskId(for sessionId: String) -> UUID? {\n        sessionToTask[sessionId]\n    }\n\n    func addContextItem(_ item: ContextItem, to taskId: UUID) {\n        guard var meta = tasks[taskId] else { return }\n        meta.sharedContext.append(item)\n        meta.updatedAt = Date()\n        tasks[taskId] = meta\n        saveTaskMeta(meta)\n    }\n\n    func removeContextItem(id: UUID, from taskId: UUID) {\n        guard var meta = tasks[taskId] else { return }\n        meta.sharedContext.removeAll { $0.id == id }\n        meta.updatedAt = Date()\n        tasks[taskId] = meta\n        saveTaskMeta(meta)\n    }\n\n    // MARK: - Private Methods\n\n    private func saveTaskMeta(_ meta: TaskMeta) {\n        try? fm.createDirectory(at: paths.metadataDir, withIntermediateDirectories: true)\n        let url = paths.metadataDir.appendingPathComponent(meta.id.uuidString + \".json\")\n        let enc = JSONEncoder()\n        enc.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n        enc.dateEncodingStrategy = .iso8601\n        if let data = try? enc.encode(meta) {\n            try? data.write(to: url, options: .atomic)\n        }\n    }\n\n    private func saveRelationships() {\n        let sessionToTaskStrings = sessionToTask.mapValues { $0.uuidString }\n        let obj: [String: Any] = [\n            \"version\": 1,\n            \"sessionToTask\": sessionToTaskStrings\n        ]\n        if let data = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted]) {\n            try? fm.createDirectory(at: paths.root, withIntermediateDirectories: true)\n            try? data.write(to: paths.relationshipsURL, options: .atomic)\n        }\n    }\n}\n"
  },
  {
    "path": "services/TimelineAttachmentDecoder.swift",
    "content": "import Foundation\n\nstruct TimelineAttachmentDecoder {\n    static func decodeDataURL(_ dataURL: String) -> (data: Data, mimeType: String)? {\n        let trimmed = dataURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard trimmed.hasPrefix(\"data:\") else { return nil }\n        let parts = trimmed.split(separator: \",\", maxSplits: 1, omittingEmptySubsequences: false)\n        guard parts.count == 2 else { return nil }\n\n        let meta = String(parts[0].dropFirst(5))\n        let dataPart = String(parts[1])\n        let metaParts = meta.split(separator: \";\")\n        let mimeType = metaParts.first.map(String.init) ?? \"application/octet-stream\"\n        guard metaParts.contains(\"base64\") else { return nil }\n        guard let data = Data(base64Encoded: dataPart, options: [.ignoreUnknownCharacters]) else { return nil }\n\n        return (data: data, mimeType: mimeType)\n    }\n\n    static func fileExtension(for mimeType: String) -> String {\n        switch mimeType.lowercased() {\n        case \"image/png\": return \"png\"\n        case \"image/jpeg\", \"image/jpg\": return \"jpg\"\n        case \"image/gif\": return \"gif\"\n        case \"image/webp\": return \"webp\"\n        case \"image/heic\": return \"heic\"\n        case \"image/heif\": return \"heif\"\n        case \"image/tiff\": return \"tiff\"\n        case \"image/bmp\": return \"bmp\"\n        case \"image/svg+xml\": return \"svg\"\n        default: return \"bin\"\n        }\n    }\n\n    static func imageData(for attachment: TimelineAttachment) -> Data? {\n        if let url = attachment.url {\n            guard url.isFileURL else { return nil }\n            return try? Data(contentsOf: url)\n        }\n        if let dataURL = attachment.dataURL,\n           let decoded = decodeDataURL(dataURL)\n        {\n            return decoded.data\n        }\n        return nil\n    }\n}\n"
  },
  {
    "path": "services/TimelineAttachmentOpener.swift",
    "content": "import AppKit\nimport Foundation\n\nfinal class TimelineAttachmentOpener {\n    static let shared = TimelineAttachmentOpener()\n    private let resolver = TimelineAttachmentResolver()\n\n    private init() {}\n\n    func open(_ attachment: TimelineAttachment) {\n        guard let url = resolver.resolveURL(for: attachment) else { return }\n        NSWorkspace.shared.open(url)\n    }\n}\n\nprivate final class TimelineAttachmentResolver {\n    private let fileManager = FileManager.default\n    private var cache: [String: URL] = [:]\n    private let baseURL: URL\n\n    init() {\n        baseURL = fileManager.temporaryDirectory\n            .appendingPathComponent(\"CodMate-Attachments\", isDirectory: true)\n        try? fileManager.createDirectory(at: baseURL, withIntermediateDirectories: true)\n    }\n\n    func resolveURL(for attachment: TimelineAttachment) -> URL? {\n        if let url = attachment.url { return url }\n        guard let dataURL = attachment.dataURL else { return nil }\n        if let cached = cache[attachment.id] { return cached }\n        guard let resolved = Self.decodeDataURL(dataURL) else { return nil }\n\n        let filename = \"image-\\(attachment.id).\\(resolved.fileExtension)\"\n        let fileURL = baseURL.appendingPathComponent(filename)\n        if !fileManager.fileExists(atPath: fileURL.path) {\n            do {\n                try resolved.data.write(to: fileURL, options: [.atomic])\n            } catch {\n                return nil\n            }\n        }\n        cache[attachment.id] = fileURL\n        return fileURL\n    }\n\n    private static func decodeDataURL(_ dataURL: String) -> (data: Data, fileExtension: String)? {\n        guard let decoded = TimelineAttachmentDecoder.decodeDataURL(dataURL) else { return nil }\n        return (data: decoded.data, fileExtension: TimelineAttachmentDecoder.fileExtension(for: decoded.mimeType))\n    }\n}\n"
  },
  {
    "path": "services/UniImportMCPNormalizer.swift",
    "content": "import Foundation\n\n// MARK: - Uni-Import Normalizer (JSON-first MVP)\n\nenum UniImportError: Error, LocalizedError { case invalid, empty\n    var errorDescription: String? {\n        switch self {\n        case .invalid: return \"Failed to parse input\"\n        case .empty: return \"No servers detected in the input\"\n        }\n    }\n}\n\nstruct UniImportMCPNormalizer {\n    // Accept plain text (JSON snippets, fenced blocks, or raw object)\n    static func parseText(_ text: String) throws -> [MCPServerDraft] {\n        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { throw UniImportError.empty }\n\n        // Try direct JSON (then a broad { ... } slice fallback)\n        if let json = try? JSONSerialization.jsonObject(with: Data(trimmed.utf8)) {\n            let drafts = draftFromJSON(json)\n            if !drafts.isEmpty { return drafts }\n        }\n\n        // Fenced ```json blocks or widest {...}\n        if let inner = extractJSONSlice(from: trimmed) {\n            if let json = try? JSONSerialization.jsonObject(with: Data(inner.utf8)) {\n                let drafts = draftFromJSON(json)\n                if !drafts.isEmpty { return drafts }\n            }\n        }\n\n        // Try TOML (heuristic): extract a TOML-looking slice and parse minimal keys\n        if let tomlSlice = extractTOMLSlice(from: trimmed) {\n            let drafts = draftFromTOML(tomlSlice)\n            if !drafts.isEmpty { return drafts }\n        }\n\n        throw UniImportError.invalid\n    }\n\n    // Very small heuristic to grab a JSON-looking slice\n    private static func extractJSONSlice(from text: String) -> String? {\n        if let m = text.range(of: \"```json\", options: .caseInsensitive),\n           let end = text.range(of: \"```\", range: m.upperBound..<text.endIndex) {\n            return String(text[m.upperBound..<end.lowerBound])\n        }\n        if let s = text.firstIndex(of: \"{\"), let e = text.lastIndex(of: \"}\") , e > s {\n            return String(text[s...e])\n        }\n        return nil\n    }\n\n    private static func normString(_ v: Any?) -> String? {\n        guard let s = v as? String else { return nil }\n        let t = s.trimmingCharacters(in: .whitespacesAndNewlines)\n        return t.isEmpty ? nil : t\n    }\n\n    private static func normStringArray(_ v: Any?) -> [String]? {\n        if let a = v as? [Any] {\n            let mapped = a.compactMap { ($0 as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }\n            return mapped.isEmpty ? nil : mapped\n        }\n        if let s = normString(v) {\n            let parts = s.split(whereSeparator: { $0.isWhitespace }).map { String($0) }\n            return parts.isEmpty ? nil : parts\n        }\n        return nil\n    }\n\n    private static func normDict(_ v: Any?) -> [String: String]? {\n        guard let o = v as? [String: Any] else { return nil }\n        var out: [String: String] = [:]\n        for (k, raw) in o { out[k] = (raw as? String) ?? String(describing: raw) }\n        return out.isEmpty ? nil : out\n    }\n\n    private static func parseKind(_ value: Any?) -> MCPServerKind {\n        let token = (value as? String)?.lowercased() ?? \"\"\n        switch token {\n        case \"sse\", \"server-sent-events\": return .sse\n        case \"streamable_http\", \"streamable-http\", \"http\", \"http_stream\": return .streamable_http\n        default: return .stdio\n        }\n    }\n\n    private static func buildDraft(name: Any?, config: Any?) -> MCPServerDraft? {\n        guard let raw = config as? [String: Any] else { return nil }\n        let n = normString(name) ?? normString(raw[\"name\"]) ?? \"imported-server\"\n        let kind = parseKind(raw[\"kind\"] ?? raw[\"type\"] ?? raw[\"server_type\"])\n        let command = normString(raw[\"command\"] ?? raw[\"command_path\"] ?? raw[\"launch\"]) ?? nil\n        let args = normStringArray(raw[\"args\"]) ?? nil\n        let env = normDict(raw[\"env\"]) ?? nil\n        let url = normString(raw[\"url\"] ?? raw[\"endpoint\"] ?? raw[\"baseUrl\"]) ?? nil\n        let headers = normDict(raw[\"headers\"]) ?? nil\n\n        var meta = MCPServerMeta()\n        meta.description = normString(raw[\"description\"]) ?? normString((raw[\"meta\"] as? [String: Any])?[\"description\"]) ?? nil\n        meta.version = normString((raw[\"meta\"] as? [String: Any])?[\"version\"]) ?? nil\n        meta.websiteUrl = normString((raw[\"meta\"] as? [String: Any])?[\"websiteUrl\"]) ?? nil\n        meta.repositoryURL = normString((raw[\"meta\"] as? [String: Any])?[\"repository\"]) ?? nil\n\n        return MCPServerDraft(name: n, kind: kind, command: command, args: args, env: env, url: url, headers: headers, meta: meta)\n    }\n\n    private static func draftFromJSON(_ json: Any) -> [MCPServerDraft] {\n        guard let obj = json as? [String: Any] else {\n            return []\n        }\n        if let servers = obj[\"mcpServers\"] as? [String: Any] {\n            return servers.compactMap { key, value in buildDraft(name: key, config: value) }\n        }\n        if let servers = obj[\"servers\"] as? [String: Any] {\n            let drafts = servers.compactMap { key, value in buildDraft(name: key, config: value) }\n            if !drafts.isEmpty { return drafts }\n        }\n        if let array = obj[\"servers\"] as? [Any] {\n            return array.compactMap { entry in\n                let n = (entry as? [String: Any])?[\"name\"]\n                return buildDraft(name: n, config: entry)\n            }\n        }\n        if let single = buildDraft(name: obj[\"name\"], config: obj) { return [single] }\n        return []\n    }\n\n    // MARK: - Minimal TOML support (heuristic)\n\n    private static func extractTOMLSlice(from text: String) -> String? {\n        // 1) fenced ```toml\n        if let m = text.range(of: \"```toml\", options: .regularExpression),\n           let end = text.range(of: \"```\", range: m.upperBound..<text.endIndex) {\n            return String(text[m.upperBound..<end.lowerBound])\n        }\n        // 2) section-based window around [mcp_servers.*] or [[servers]] or [servers]\n        let lines = text.split(whereSeparator: { $0.isNewline }).map(String.init)\n        let sectionRegex = try? NSRegularExpression(pattern: \"^\\\\s*\\\\[(?:mcp_servers(?:\\\\.[^\\\\]]+)?)\\\\]\\\\s*$|^\\\\s*\\\\[\\\\[servers\\\\]\\\\]\\\\s*$|^\\\\s*\\\\[servers\\\\]\\\\s*$\", options: [.caseInsensitive])\n        let isTomlish: (String) -> Bool = { l in\n            let trimmed = l.trimmingCharacters(in: .whitespaces)\n            if trimmed.isEmpty { return true }\n            if trimmed.hasPrefix(\"#\") { return true }\n            if trimmed.contains(\"=\") { return true }\n            return false\n        }\n        var start = -1\n        for (i, l) in lines.enumerated() {\n            if let rx = sectionRegex, rx.firstMatch(in: l, options: [], range: NSRange(location: 0, length: l.utf16.count)) != nil {\n                start = i; break\n            }\n        }\n        if start >= 0 {\n            var end = lines.count\n            var nonToml = 0\n            for j in start..<lines.count {\n                if isTomlish(lines[j]) { nonToml = 0; continue }\n                nonToml += 1\n                if nonToml >= 2 { end = j - 1; break }\n            }\n            return lines[start..<end].joined(separator: \"\\n\")\n        }\n        return nil\n    }\n\n    private static func parseTomlArray(_ s: String) -> [String]? {\n        // very small parser for [\"a\", \"b\"] or [a, b]\n        let inner = s.trimmingCharacters(in: .whitespaces)\n        guard inner.first == \"[\", inner.last == \"]\" else { return nil }\n        let body = inner.dropFirst().dropLast()\n        let parts = body.split(separator: \",\").map { $0.trimmingCharacters(in: .whitespaces) }\n        var out: [String] = []\n        for p in parts {\n            var t = p\n            if t.hasPrefix(\"\\\"\") && t.hasSuffix(\"\\\"\") { t = String(t.dropFirst().dropLast()) }\n            if t.hasPrefix(\"'\") && t.hasSuffix(\"'\") { t = String(t.dropFirst().dropLast()) }\n            if !t.isEmpty { out.append(String(t)) }\n        }\n        return out.isEmpty ? nil : out\n    }\n\n    private static func parseTomlInlineTable(_ s: String) -> [String: String]? {\n        // { key = \"v\", k2 = \"v2\" }\n        let inner = s.trimmingCharacters(in: .whitespaces)\n        guard inner.first == \"{\", inner.last == \"}\" else { return nil }\n        let body = inner.dropFirst().dropLast()\n        var out: [String: String] = [:]\n        for pair in body.split(separator: \",\") {\n            let kv = pair.split(separator: \"=\", maxSplits: 1).map { $0.trimmingCharacters(in: .whitespaces) }\n            if kv.count == 2 {\n                let key = kv[0].trimmingCharacters(in: CharacterSet(charactersIn: \"\\\"' \"))\n                var val = kv[1].trimmingCharacters(in: .whitespaces)\n                if val.hasPrefix(\"\\\"\") && val.hasSuffix(\"\\\"\") { val = String(val.dropFirst().dropLast()) }\n                if val.hasPrefix(\"'\") && val.hasSuffix(\"'\") { val = String(val.dropFirst().dropLast()) }\n                out[key] = val\n            }\n        }\n        return out.isEmpty ? nil : out\n    }\n\n    private static func parseTomlScalar(_ v: String) -> String? {\n        var t = v.trimmingCharacters(in: .whitespaces)\n        if t.hasPrefix(\"\\\"\") && t.hasSuffix(\"\\\"\") { t = String(t.dropFirst().dropLast()) }\n        if t.hasPrefix(\"'\") && t.hasSuffix(\"'\") { t = String(t.dropFirst().dropLast()) }\n        return t\n    }\n\n    private static func draftFromTOML(_ text: String) -> [MCPServerDraft] {\n        var drafts: [MCPServerDraft] = []\n        var currentName: String? = nil\n        var current: [String: String] = [:]\n\n        func flushCurrent() {\n            guard let name = currentName else { return }\n            // build draft from current kv\n            let kindToken = (current[\"kind\"] ?? current[\"type\"] ?? current[\"server_type\"])?.lowercased()\n            let kind: MCPServerKind = {\n                switch kindToken {\n                case \"sse\", \"server-sent-events\": return .sse\n                case \"streamable_http\", \"streamable-http\", \"http\", \"http_stream\": return .streamable_http\n                default: return .stdio\n                }\n            }()\n            let args = current[\"args\"].flatMap(parseTomlArray)\n            let env = current[\"env\"].flatMap(parseTomlInlineTable)\n            let headers = current[\"headers\"].flatMap(parseTomlInlineTable)\n            let meta = MCPServerMeta(description: current[\"meta.description\"],\n                                     version: current[\"meta.version\"],\n                                     websiteUrl: current[\"meta.websiteUrl\"] ?? current[\"meta.website_url\"],\n                                     repositoryURL: current[\"meta.repository\"])\n            let draft = MCPServerDraft(\n                name: name,\n                kind: kind,\n                command: current[\"command\"],\n                args: args,\n                env: env,\n                url: current[\"url\"] ?? current[\"endpoint\"] ?? current[\"baseUrl\"],\n                headers: headers,\n                meta: meta\n            )\n            drafts.append(draft)\n            current.removeAll(keepingCapacity: true)\n        }\n\n        // Normalize lines and iterate\n        let lines = text.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n        let sectionRx = try? NSRegularExpression(pattern: \"^\\\\s*\\\\[(.+)\\\\]\\\\s*$\")\n        let doubleSectionRx = try? NSRegularExpression(pattern: \"^\\\\s*\\\\[\\\\[(.+)\\\\]\\\\]\\\\s*$\")\n\n        for rawLine in lines {\n            let line = rawLine.trimmingCharacters(in: .whitespaces)\n            if line.isEmpty || line.hasPrefix(\"#\") { continue }\n\n            // Section headers\n            if let rx = doubleSectionRx, let _ = rx.firstMatch(in: line, range: NSRange(location: 0, length: line.utf16.count)) {\n                // e.g., [[servers]]\n                flushCurrent()\n                currentName = nil\n                continue\n            }\n            if let rx = sectionRx, let m = rx.firstMatch(in: line, range: NSRange(location: 0, length: line.utf16.count)) {\n                let r = m.range(at: 1)\n                if let rr = Range(r, in: line) {\n                    let section = String(line[rr])\n                    if section.lowercased().hasPrefix(\"mcp_servers.\") {\n                        flushCurrent()\n                        let name = String(section.dropFirst(\"mcp_servers.\".count))\n                        currentName = name\n                    } else if section.lowercased().hasPrefix(\"servers.\") {\n                        flushCurrent()\n                        let name = String(section.dropFirst(\"servers.\".count))\n                        currentName = name\n                    } else {\n                        flushCurrent()\n                        currentName = nil\n                    }\n                }\n                continue\n            }\n\n            // key = value\n            guard let eq = line.firstIndex(of: \"=\") else { continue }\n            let key = line[..<eq].trimmingCharacters(in: .whitespaces)\n            let value = String(line[line.index(after: eq)...]).trimmingCharacters(in: .whitespaces)\n            // accumulate\n            if key == \"name\" { currentName = parseTomlScalar(value) }\n            current[key] = value\n        }\n        flushCurrent()\n\n        return drafts\n    }\n}\n"
  },
  {
    "path": "services/UpdateService.swift",
    "content": "import Foundation\n\nactor UpdateService {\n  static let shared = UpdateService()\n\n  enum CheckTrigger: Sendable {\n    case appLaunch\n    case aboutAuto\n    case manual\n  }\n\n  struct UpdateInfo: Sendable {\n    let latestVersion: String\n    let releaseURL: URL\n    let assetName: String\n    let assetURL: URL\n  }\n\n  enum UpdateState: Sendable {\n    case idle\n    case checking\n    case upToDate(current: String, latest: String)\n    case updateAvailable(UpdateInfo)\n    case error(String)\n  }\n\n  struct Release: Decodable, Sendable {\n    let tagName: String\n    let htmlURL: URL\n    let isDraft: Bool\n    let isPrerelease: Bool\n    let assets: [Asset]\n\n    struct Asset: Decodable, Sendable {\n      let name: String\n      let browserDownloadURL: URL\n\n      enum CodingKeys: String, CodingKey {\n        case name\n        case browserDownloadURL = \"browser_download_url\"\n      }\n    }\n\n    enum CodingKeys: String, CodingKey {\n      case tagName = \"tag_name\"\n      case htmlURL = \"html_url\"\n      case isDraft = \"draft\"\n      case isPrerelease = \"prerelease\"\n      case assets\n    }\n\n    static func decode(from data: Data) throws -> Release {\n      try JSONDecoder().decode(Release.self, from: data)\n    }\n  }\n\n  private let defaults: UserDefaults\n  private let session: URLSession\n  private let calendar: Calendar\n\n  private struct Keys {\n    static let lastCheckDay = \"codmate.update.lastCheckDay\"\n    static let lastCheckTimestamp = \"codmate.update.lastCheckTimestamp\"\n    static let latestVersion = \"codmate.update.latestVersion\"\n    static let latestAssetURL = \"codmate.update.latestAssetURL\"\n    static let latestAssetName = \"codmate.update.latestAssetName\"\n    static let latestReleaseURL = \"codmate.update.latestReleaseURL\"\n  }\n\n  init(\n    defaults: UserDefaults = .standard,\n    session: URLSession = .shared,\n    calendar: Calendar = .current\n  ) {\n    self.defaults = defaults\n    self.session = session\n    self.calendar = calendar\n  }\n\n  func cachedInfo() -> UpdateInfo? {\n    guard\n      let version = defaults.string(forKey: Keys.latestVersion),\n      let assetURLString = defaults.string(forKey: Keys.latestAssetURL),\n      let assetURL = URL(string: assetURLString),\n      let assetName = defaults.string(forKey: Keys.latestAssetName),\n      let releaseURLString = defaults.string(forKey: Keys.latestReleaseURL),\n      let releaseURL = URL(string: releaseURLString)\n    else { return nil }\n    return UpdateInfo(latestVersion: version, releaseURL: releaseURL, assetName: assetName, assetURL: assetURL)\n  }\n\n  func lastCheckedAt() -> Date? {\n    let timestamp = defaults.double(forKey: Keys.lastCheckTimestamp)\n    if timestamp == 0 { return nil }\n    return Date(timeIntervalSince1970: timestamp)\n  }\n\n  func checkIfNeeded(trigger: CheckTrigger) async -> UpdateState {\n    if AppDistribution.isAppStore {\n      return .error(\"Updates are disabled in the App Store build.\")\n    }\n\n    let todayKey = dayKey(Date())\n    let lastKey = defaults.string(forKey: Keys.lastCheckDay)\n    if (trigger == .appLaunch || trigger == .aboutAuto), lastKey == todayKey {\n      if let cached = cachedInfo() {\n        return availability(for: cached)\n      }\n      return .idle\n    }\n\n    return await checkNow()\n  }\n\n  func checkNow() async -> UpdateState {\n    if AppDistribution.isAppStore {\n      return .error(\"Updates are disabled in the App Store build.\")\n    }\n\n    let now = Date()\n    recordCheckAttempt(now)\n\n    do {\n      var request = URLRequest(url: URL(string: \"https://api.github.com/repos/loocor/CodMate/releases/latest\")!)\n      request.setValue(\"CodMate\", forHTTPHeaderField: \"User-Agent\")\n      let (data, response) = try await session.data(for: request)\n      guard let http = response as? HTTPURLResponse else {\n        return .error(\"Invalid response\")\n      }\n      guard http.statusCode == 200 else {\n        return .error(\"HTTP \\(http.statusCode)\")\n      }\n\n      let release = try Release.decode(from: data)\n      if release.isDraft || release.isPrerelease {\n        return .error(\"No stable release available\")\n      }\n      let assetName = UpdateAssetSelector.assetName(for: .current)\n      guard let asset = release.assets.first(where: { $0.name == assetName }) else {\n        return .error(\"No asset for current architecture\")\n      }\n\n      let latestVersion = release.tagName\n      cache(latestVersion: latestVersion, asset: asset, releaseURL: release.htmlURL)\n\n      let info = UpdateInfo(\n        latestVersion: latestVersion,\n        releaseURL: release.htmlURL,\n        assetName: asset.name,\n        assetURL: asset.browserDownloadURL\n      )\n      return availability(for: info)\n    } catch {\n      return .error(error.localizedDescription)\n    }\n  }\n\n  private func availability(for info: UpdateInfo) -> UpdateState {\n    let current = Bundle.main.infoDictionary?[\"CFBundleShortVersionString\"] as? String ?? \"0\"\n    guard let currentVersion = Version(current), let latestVersion = Version(info.latestVersion) else {\n      return .updateAvailable(info)\n    }\n    if latestVersion > currentVersion {\n      return .updateAvailable(info)\n    }\n    return .upToDate(current: current, latest: info.latestVersion)\n  }\n\n  private func cache(latestVersion: String, asset: Release.Asset, releaseURL: URL) {\n    defaults.set(latestVersion, forKey: Keys.latestVersion)\n    defaults.set(asset.name, forKey: Keys.latestAssetName)\n    defaults.set(asset.browserDownloadURL.absoluteString, forKey: Keys.latestAssetURL)\n    defaults.set(releaseURL.absoluteString, forKey: Keys.latestReleaseURL)\n  }\n\n  private func recordCheckAttempt(_ date: Date) {\n    defaults.set(dayKey(date), forKey: Keys.lastCheckDay)\n    defaults.set(date.timeIntervalSince1970, forKey: Keys.lastCheckTimestamp)\n  }\n\n  private func dayKey(_ date: Date) -> String {\n    let comps = calendar.dateComponents([.year, .month, .day], from: date)\n    let y = comps.year ?? 0\n    let m = comps.month ?? 0\n    let d = comps.day ?? 0\n    return String(format: \"%04d-%02d-%02d\", y, m, d)\n  }\n}\n"
  },
  {
    "path": "services/WindowStateStore.swift",
    "content": "import Foundation\nimport CoreGraphics\n\n/// Persists and restores the main window state across app launches\n@MainActor\nfinal class WindowStateStore: ObservableObject {\n  private let defaults: UserDefaults\n\n  private struct Keys {\n    static let selectedProjectIDs = \"codmate.window.selectedProjectIDs\"\n    static let selectedDay = \"codmate.window.selectedDay\"\n    static let selectedDays = \"codmate.window.selectedDays\"\n    static let monthStart = \"codmate.window.monthStart\"\n    static let selectedSessionIDs = \"codmate.window.selectedSessionIDs\"\n    static let selectionPrimaryId = \"codmate.window.selectionPrimaryId\"\n    static let contentColumnWidth = \"codmate.window.contentColumnWidth\"\n    static let reviewLeftPaneWidth = \"codmate.window.reviewLeftPaneWidth\"\n    static let expandedProjects = \"codmate.window.expandedProjects\"\n  }\n\n  init(defaults: UserDefaults = .standard) {\n    self.defaults = defaults\n  }\n\n  // MARK: - Save State\n\n  func saveProjectSelection(_ projectIDs: Set<String>) {\n    let array = Array(projectIDs)\n    defaults.set(array, forKey: Keys.selectedProjectIDs)\n  }\n\n  func saveCalendarSelection(selectedDay: Date?, selectedDays: Set<Date>, monthStart: Date) {\n    if let day = selectedDay {\n      defaults.set(day.timeIntervalSinceReferenceDate, forKey: Keys.selectedDay)\n    } else {\n      defaults.removeObject(forKey: Keys.selectedDay)\n    }\n\n    let intervals = selectedDays.map { $0.timeIntervalSinceReferenceDate }\n    defaults.set(intervals, forKey: Keys.selectedDays)\n\n    defaults.set(monthStart.timeIntervalSinceReferenceDate, forKey: Keys.monthStart)\n  }\n\n  func saveSessionSelection(selectedIDs: Set<SessionSummary.ID>, primaryId: SessionSummary.ID?) {\n    let array = Array(selectedIDs)\n    defaults.set(array, forKey: Keys.selectedSessionIDs)\n\n    if let primary = primaryId {\n      defaults.set(primary, forKey: Keys.selectionPrimaryId)\n    } else {\n      defaults.removeObject(forKey: Keys.selectionPrimaryId)\n    }\n  }\n\n  // MARK: - Column Width Persistence\n  func saveContentColumnWidth(_ width: CGFloat) {\n    defaults.set(Double(width), forKey: Keys.contentColumnWidth)\n  }\n\n  func restoreContentColumnWidth() -> CGFloat? {\n    let w = defaults.double(forKey: Keys.contentColumnWidth)\n    return w > 0 ? CGFloat(w) : nil\n  }\n\n  func saveReviewLeftPaneWidth(_ width: CGFloat) {\n    defaults.set(Double(width), forKey: Keys.reviewLeftPaneWidth)\n  }\n\n  func restoreReviewLeftPaneWidth() -> CGFloat? {\n    let w = defaults.double(forKey: Keys.reviewLeftPaneWidth)\n    return w > 0 ? CGFloat(w) : nil\n  }\n\n  // MARK: - Restore State\n\n  func restoreProjectSelection() -> Set<String> {\n    guard let array = defaults.array(forKey: Keys.selectedProjectIDs) as? [String] else {\n      return []\n    }\n    return Set(array)\n  }\n\n  func restoreCalendarSelection() -> (\n    selectedDay: Date?, selectedDays: Set<Date>, monthStart: Date?\n  ) {\n    let selectedDay: Date? = {\n      let interval = defaults.double(forKey: Keys.selectedDay)\n      guard interval != 0 else { return nil }\n      return Date(timeIntervalSinceReferenceDate: interval)\n    }()\n\n    let selectedDays: Set<Date> = {\n      guard let intervals = defaults.array(forKey: Keys.selectedDays) as? [TimeInterval] else {\n        return []\n      }\n      return Set(intervals.map { Date(timeIntervalSinceReferenceDate: $0) })\n    }()\n\n    let monthStart: Date? = {\n      let interval = defaults.double(forKey: Keys.monthStart)\n      guard interval != 0 else { return nil }\n      return Date(timeIntervalSinceReferenceDate: interval)\n    }()\n\n    return (selectedDay, selectedDays, monthStart)\n  }\n\n  func restoreSessionSelection() -> (\n    selectedIDs: Set<SessionSummary.ID>, primaryId: SessionSummary.ID?\n  ) {\n    let selectedIDs: Set<SessionSummary.ID> = {\n      guard let array = defaults.array(forKey: Keys.selectedSessionIDs) as? [String] else {\n        return []\n      }\n      return Set(array)\n    }()\n\n    let primaryId = defaults.string(forKey: Keys.selectionPrimaryId)\n\n    return (selectedIDs, primaryId)\n  }\n\n  func saveProjectExpansions(_ ids: Set<String>) {\n    defaults.set(Array(ids), forKey: Keys.expandedProjects)\n  }\n\n  func restoreProjectExpansions() -> Set<String> {\n    guard let array = defaults.array(forKey: Keys.expandedProjects) as? [String] else {\n      return []\n    }\n    return Set(array)\n  }\n\n  // MARK: - Clear State\n\n  func clearAll() {\n    defaults.removeObject(forKey: Keys.selectedProjectIDs)\n    defaults.removeObject(forKey: Keys.selectedDay)\n    defaults.removeObject(forKey: Keys.selectedDays)\n    defaults.removeObject(forKey: Keys.monthStart)\n    defaults.removeObject(forKey: Keys.contentColumnWidth)\n    defaults.removeObject(forKey: Keys.reviewLeftPaneWidth)\n    defaults.removeObject(forKey: Keys.selectedSessionIDs)\n    defaults.removeObject(forKey: Keys.selectionPrimaryId)\n    defaults.removeObject(forKey: Keys.expandedProjects)\n  }\n}\n"
  },
  {
    "path": "services/WizardDocsService.swift",
    "content": "import Foundation\nimport AppKit\n\nactor WizardDocsService {\n  private struct DocsIndex: Codable {\n    var sources: [WizardDocSource]\n  }\n\n  private struct CachedDoc: Codable {\n    var url: String\n    var fetchedAt: Date\n    var text: String\n  }\n\n  private let fileManager: FileManager\n  private let cacheURL: URL\n  private var cache: [String: CachedDoc]\n  private var globalSources: [WizardDocSource]\n\n  init() {\n    let fm = FileManager.default\n    fileManager = fm\n    cacheURL = Self.defaultCacheURL(using: fm)\n    globalSources = Self.loadGlobalSourcesSync()\n    cache = Self.loadCacheSync(cacheURL: cacheURL)\n  }\n\n  func snippets(\n    feature: WizardFeature,\n    provider: SessionSource.Kind,\n    overrides: [WizardDocSource] = [],\n    keywords: [String] = []\n  ) async -> [WizardDocSnippet] {\n    let sources = mergedSources(feature: feature, provider: provider, overrides: overrides)\n    guard !sources.isEmpty else { return [] }\n    var out: [WizardDocSnippet] = []\n    for src in sources {\n      let text = await loadText(from: src)\n      guard !text.isEmpty else { continue }\n      let filtered = extractRelevant(text: text, keywords: keywords, maxChars: src.maxChars)\n      if !filtered.isEmpty {\n        out.append(WizardDocSnippet(url: src.url, provider: src.provider, text: filtered))\n      }\n    }\n    return out\n  }\n\n  // MARK: - Sources\n\n  private func mergedSources(\n    feature: WizardFeature,\n    provider: SessionSource.Kind,\n    overrides: [WizardDocSource]\n  ) -> [WizardDocSource] {\n    let providerKey = provider.rawValue\n    var out: [WizardDocSource] = []\n    let fromOverrides = overrides.filter {\n      $0.feature == feature && ($0.provider == nil || $0.provider == providerKey)\n    }\n    if !fromOverrides.isEmpty { out.append(contentsOf: fromOverrides) }\n    let fromGlobal = globalSources.filter {\n      $0.feature == feature && ($0.provider == nil || $0.provider == providerKey)\n    }\n    out.append(contentsOf: fromGlobal)\n    return out\n  }\n\n  private static func defaultCacheURL(using fileManager: FileManager) -> URL {\n    let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first\n    return (caches ?? fileManager.temporaryDirectory)\n      .appendingPathComponent(\"CodMate\", isDirectory: true)\n      .appendingPathComponent(\"wizard-docs-cache.json\", isDirectory: false)\n  }\n\n  private static func loadGlobalSourcesSync() -> [WizardDocSource] {\n    let bundle = Bundle.main\n    var url = bundle.url(\n      forResource: \"wizard-docs\",\n      withExtension: \"json\",\n      subdirectory: \"payload/knowledge\"\n    )\n    if url == nil, let devRoot = devPayloadRootURL() {\n      url = devRoot\n        .appendingPathComponent(\"knowledge\", isDirectory: true)\n        .appendingPathComponent(\"wizard-docs.json\", isDirectory: false)\n    }\n    guard let resolved = url else { return [] }\n    guard let data = try? Data(contentsOf: resolved) else { return [] }\n    let decoder = JSONDecoder()\n    let parsed = (try? decoder.decode(DocsIndex.self, from: data))?.sources ?? []\n    return parsed\n  }\n\n  private static func devPayloadRootURL() -> URL? {\n    let fm = FileManager.default\n    let cwd = URL(fileURLWithPath: fm.currentDirectoryPath, isDirectory: true)\n    if let found = findPayloadRoot(startingAt: cwd, fileManager: fm) {\n      return found\n    }\n    if let execURL = Bundle.main.executableURL {\n      let execDir = execURL.deletingLastPathComponent()\n      if let found = findPayloadRoot(startingAt: execDir, fileManager: fm) {\n        return found\n      }\n    }\n    return nil\n  }\n\n  private static func findPayloadRoot(startingAt start: URL, fileManager: FileManager) -> URL? {\n    var current = start\n    for _ in 0..<6 {\n      let candidate = current\n        .appendingPathComponent(\"payload\", isDirectory: true)\n        .appendingPathComponent(\"knowledge\", isDirectory: true)\n        .appendingPathComponent(\"wizard-docs.json\", isDirectory: false)\n      if fileManager.fileExists(atPath: candidate.path) {\n        return current.appendingPathComponent(\"payload\", isDirectory: true)\n      }\n      current = current.deletingLastPathComponent()\n    }\n    return nil\n  }\n\n  // MARK: - Cache\n\n  private static func loadCacheSync(cacheURL: URL) -> [String: CachedDoc] {\n    guard let data = try? Data(contentsOf: cacheURL) else { return [:] }\n    let decoder = JSONDecoder()\n    decoder.dateDecodingStrategy = .iso8601\n    if let list = try? decoder.decode([CachedDoc].self, from: data) {\n      return Dictionary(uniqueKeysWithValues: list.map { ($0.url, $0) })\n    }\n    return [:]\n  }\n\n  private func saveCache() {\n    let encoder = JSONEncoder()\n    encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n    encoder.dateEncodingStrategy = .iso8601\n    let list = Array(cache.values)\n    guard let data = try? encoder.encode(list) else { return }\n    try? fileManager.createDirectory(at: cacheURL.deletingLastPathComponent(), withIntermediateDirectories: true)\n    try? data.write(to: cacheURL, options: .atomic)\n  }\n\n  // MARK: - Fetch\n\n  private func loadText(from source: WizardDocSource) async -> String {\n    let ttl = TimeInterval((source.cacheTTLHours ?? 72) * 3600)\n    if let cached = cache[source.url], Date().timeIntervalSince(cached.fetchedAt) < ttl {\n      return cached.text\n    }\n\n    guard let url = URL(string: source.url) else { return \"\" }\n    do {\n      let (data, _) = try await URLSession.shared.data(from: url)\n      let text = decodeHTML(data) ?? String(data: data, encoding: .utf8) ?? \"\"\n      if !text.isEmpty {\n        cache[source.url] = CachedDoc(url: source.url, fetchedAt: Date(), text: text)\n        saveCache()\n      }\n      return text\n    } catch {\n      return \"\"\n    }\n  }\n\n  private func decodeHTML(_ data: Data) -> String? {\n    let options: [NSAttributedString.DocumentReadingOptionKey: Any] = [\n      .documentType: NSAttributedString.DocumentType.html,\n      .characterEncoding: String.Encoding.utf8.rawValue\n    ]\n    if let attributed = try? NSAttributedString(data: data, options: options, documentAttributes: nil) {\n      return attributed.string\n    }\n    return nil\n  }\n\n  private func extractRelevant(text: String, keywords: [String], maxChars: Int?) -> String {\n    let limit = maxChars ?? 3000\n    let trimmed = text.replacingOccurrences(of: \"\\r\", with: \"\")\n    let lines = trimmed.split(separator: \"\\n\", omittingEmptySubsequences: false).map(String.init)\n    if keywords.isEmpty {\n      return String(trimmed.prefix(limit))\n    }\n    let loweredKeywords = keywords.map { $0.lowercased() }\n    var matched: [String] = []\n    for (idx, line) in lines.enumerated() {\n      let lower = line.lowercased()\n      if loweredKeywords.contains(where: { lower.contains($0) }) {\n        let prev = idx > 0 ? lines[idx - 1] : \"\"\n        let next = idx + 1 < lines.count ? lines[idx + 1] : \"\"\n        matched.append(prev)\n        matched.append(line)\n        matched.append(next)\n      }\n    }\n    let joined = matched.joined(separator: \"\\n\").trimmingCharacters(in: .whitespacesAndNewlines)\n    if joined.isEmpty {\n      return String(trimmed.prefix(limit))\n    }\n    return String(joined.prefix(limit))\n  }\n}\n"
  },
  {
    "path": "services/WizardResponseParser.swift",
    "content": "import Foundation\n\nenum WizardResponseParser {\n  static func decode<T: Decodable>(_ raw: String) -> T? {\n    let cleaned = stripCodeFences(raw)\n    if let value: T = decodeJSON(cleaned) {\n      return value\n    }\n    if let unwrapped = unwrapPayloadText(cleaned), unwrapped != cleaned {\n      return decode(unwrapped)\n    }\n    return nil\n  }\n\n  static func decodeEnvelope<T: Decodable>(_ raw: String) -> WizardDraftEnvelope<T>? {\n    let cleaned = stripCodeFences(raw)\n    if let envelope: WizardDraftEnvelope<T> = decodeJSON(cleaned) {\n      return envelope\n    }\n    if let unwrapped = unwrapPayloadText(cleaned), unwrapped != cleaned {\n      return decodeEnvelope(unwrapped)\n    }\n    return nil\n  }\n\n  private static func decodeJSON<T: Decodable>(_ raw: String) -> T? {\n    guard let data = raw.data(using: .utf8) else { return nil }\n    return try? JSONDecoder().decode(T.self, from: data)\n  }\n\n  private static func unwrapPayloadText(_ raw: String) -> String? {\n    guard let data = raw.data(using: .utf8) else { return nil }\n    guard let json = try? JSONSerialization.jsonObject(with: data) else { return nil }\n    return extractText(from: json)\n  }\n\n  private static func extractText(from value: Any) -> String? {\n    if let text = value as? String {\n      return text.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n    if let dict = value as? [String: Any] {\n      let keys = [\"result\", \"response\", \"content\", \"text\", \"message\", \"output\"]\n      for key in keys {\n        if let nested = dict[key], let text = extractText(from: nested) {\n          return text\n        }\n      }\n    }\n    if let array = value as? [Any] {\n      for item in array {\n        if let text = extractText(from: item) {\n          return text\n        }\n      }\n    }\n    return nil\n  }\n\n  private static func stripCodeFences(_ raw: String) -> String {\n    var cleaned = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n    if cleaned.hasPrefix(\"```\") {\n      if let firstNewline = cleaned.firstIndex(of: \"\\n\") {\n        cleaned = String(cleaned[cleaned.index(after: firstNewline)...])\n      }\n      if let lastFence = cleaned.range(of: \"```\", options: .backwards) {\n        cleaned = String(cleaned[..<lastFence.lowerBound])\n      }\n      cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n    return cleaned\n  }\n}\n"
  },
  {
    "path": "utils/AppAvailability.swift",
    "content": "import AppKit\n\nenum AppAvailability {\n  static func isInstalled(bundleIdentifier: String?) -> Bool {\n    guard let bundleIdentifier else { return false }\n    return NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleIdentifier) != nil\n  }\n\n  static func isInstalled(bundleIdentifiers: [String]) -> Bool {\n    for identifier in bundleIdentifiers {\n      if isInstalled(bundleIdentifier: identifier) { return true }\n    }\n    return false\n  }\n\n  static func firstInstalledBundleIdentifier(in identifiers: [String]) -> String? {\n    for identifier in identifiers {\n      if isInstalled(bundleIdentifier: identifier) { return identifier }\n    }\n    return nil\n  }\n}\n"
  },
  {
    "path": "utils/AppDistribution.swift",
    "content": "import Foundation\n\n/// Build/distribution flags and helpers.\nenum AppDistribution {\n    #if APPSTORE\n    static let isAppStore = true\n    #else\n    static let isAppStore = false\n    #endif\n}\n\n"
  },
  {
    "path": "utils/AppSandbox.swift",
    "content": "import Foundation\nimport Security\n\nenum AppSandbox {\n    static var isEnabled: Bool {\n        // Primary: query entitlement from our own signed task\n        if let task = SecTaskCreateFromSelf(nil) {\n            if let val = SecTaskCopyValueForEntitlement(task, \"com.apple.security.app-sandbox\" as CFString, nil) as? Bool {\n                return val\n            }\n        }\n        // Fallback: environment probe (not always present on Developer ID builds)\n        return ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil\n    }\n}\n\n"
  },
  {
    "path": "utils/CLIEnvironment.swift",
    "content": "import Foundation\n\n#if canImport(Darwin)\nimport Darwin\n#endif\n\n/// Unified CLI environment configuration for embedded terminals and external shells\nenum CLIEnvironment {\n    static let defaultExecutableNames = [\"codex\", \"claude\", \"gemini\"]\n\n    /// Standard PATH components that include common CLI tool locations\n    /// - Includes: ~/.local/bin (claude), /opt/homebrew/bin (codex on M1),\n    ///   /usr/local/bin (codex on Intel), and standard system paths\n    static let standardPathComponents = [\n        \"$HOME/.bun/bin\",\n        \"$HOME/.local/bin\",\n        \"/opt/homebrew/bin\",\n        \"/usr/local/bin\",\n        \"/usr/bin\",\n        \"/bin\"\n    ]\n\n    // Detect common version-manager bins (nvm/fnm/volta/asdf/nodenv/nodebrew/etc.) that\n    // actually contain codex/claude/gemini, so PATH stays lean but flexible.\n    private static let detectedPathComponentsCache: [String] = detectPathComponents(\n        for: defaultExecutableNames\n    )\n\n    /// Build an injected PATH string that prepends standard paths to existing PATH\n    /// - Parameter additionalPaths: Optional array of additional paths to prepend\n    /// - Returns: A PATH string ready to be exported or used in shell commands\n    static func buildInjectedPATH(additionalPaths: [String] = []) -> String {\n        let components = resolvedPathComponents(\n            additionalPaths: additionalPaths,\n            expandHome: false\n        )\n        return components.joined(separator: \":\") + \":${PATH}\"\n    }\n\n    /// Build an injected PATH string without preserving existing PATH\n    /// Useful for ProcessInfo environment where PATH is merged differently\n    /// - Parameter additionalPaths: Optional array of additional paths to prepend\n    /// - Returns: A PATH string without ${PATH} suffix\n    static func buildBasePATH(additionalPaths: [String] = []) -> String {\n        let components = resolvedPathComponents(\n            additionalPaths: additionalPaths,\n            expandHome: true\n        )\n        return components.joined(separator: \":\")\n    }\n\n    /// Standard locale environment variables for zh_CN UTF-8\n    static let standardLocaleEnv: [String: String] = [\n        \"LANG\": \"zh_CN.UTF-8\",\n        \"LC_ALL\": \"zh_CN.UTF-8\",\n        \"LC_CTYPE\": \"zh_CN.UTF-8\"\n    ]\n\n    /// Standard terminal environment\n    static let standardTermEnv: [String: String] = [\n        \"TERM\": \"xterm-256color\"\n    ]\n\n    /// Build export lines for shell scripts\n    /// - Parameters:\n    ///   - includeLocale: Include locale environment variables\n    ///   - includeTerm: Include TERM environment variable\n    ///   - additional: Additional environment variables to export\n    /// - Returns: Array of export statements\n    static func buildExportLines(\n        includeLocale: Bool = true,\n        includeTerm: Bool = true,\n        additional: [String: String] = [:]\n    ) -> [String] {\n        var lines: [String] = []\n\n        if includeLocale {\n            for (key, value) in standardLocaleEnv {\n                lines.append(\"export \\(key)=\\(value)\")\n            }\n        }\n\n        if includeTerm {\n            for (key, value) in standardTermEnv {\n                lines.append(\"export \\(key)=\\(value)\")\n            }\n        }\n\n        for (key, value) in additional {\n            lines.append(\"export \\(key)=\\(value)\")\n        }\n\n        return lines\n    }\n\n    static func resolvedPATHForCLI(sandboxed: Bool? = nil) -> String {\n        let isSandboxed = sandboxed ?? (ProcessInfo.processInfo.environment[\"APP_SANDBOX_CONTAINER_ID\"] != nil)\n        let base = buildBasePATH()\n        if isSandboxed { return base }\n\n        var paths: [String] = [base]\n        if let shellPath = detectLoginShellPATH(), !shellPath.isEmpty {\n            paths.append(shellPath)\n        }\n        let current = ProcessInfo.processInfo.environment[\"PATH\"] ?? \"\"\n        if !current.isEmpty {\n            paths.append(current)\n        }\n        return mergePATHStrings(paths)\n    }\n\n    static func resolveExecutablePath(_ name: String, path: String) -> String? {\n        if let resolved = which(name, path: path),\n           let sanitized = sanitizeExecutablePath(resolved) {\n            return sanitized\n        }\n        if let resolved = shellWhich(name),\n           let sanitized = sanitizeExecutablePath(resolved) {\n            return sanitized\n        }\n        return manualResolve(name, path: path)\n    }\n\n    static func version(of name: String, path: String) -> String? {\n        let candidates: [[String]] = [[\"--version\"], [\"version\"], [\"-V\"], [\"-v\"]]\n        for args in candidates {\n            var env = ProcessInfo.processInfo.environment\n            env[\"PATH\"] = path\n            env[\"NO_COLOR\"] = \"1\"\n            guard let result = runProcess(\n                executable: \"/usr/bin/env\",\n                arguments: [name] + args,\n                environment: env,\n                timeout: 1.5\n            ) else { continue }\n            if result.timedOut {\n                NSLog(\"[CLIEnvironment] version probe timed out: %@ %@\", name, args.joined(separator: \" \"))\n                return nil\n            }\n            let out = result.stdout\n            let err = result.stderr\n            if out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                var fallback = err\n                fallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)\n                if fallback.isEmpty { continue }\n                if let firstLine = fallback.split(separator: \"\\n\").first {\n                    fallback = String(firstLine)\n                }\n                if let ver = firstVersionToken(in: fallback) { return ver }\n                return String(fallback.prefix(48))\n            }\n            var cleaned = out.trimmingCharacters(in: .whitespacesAndNewlines)\n            if cleaned.isEmpty { continue }\n            if let firstLine = cleaned.split(separator: \"\\n\").first { cleaned = String(firstLine) }\n            if let ver = firstVersionToken(in: cleaned) { return ver }\n            return String(cleaned.prefix(48))\n        }\n        return nil\n    }\n\n    static func version(atExecutablePath executablePath: String, path: String) -> String? {\n        let candidates: [[String]] = [[\"--version\"], [\"version\"], [\"-V\"], [\"-v\"]]\n        for args in candidates {\n            var env = ProcessInfo.processInfo.environment\n            env[\"PATH\"] = path\n            env[\"NO_COLOR\"] = \"1\"\n            guard let result = runProcess(\n                executable: executablePath,\n                arguments: args,\n                environment: env,\n                timeout: 1.5\n            ) else { continue }\n            if result.timedOut {\n                NSLog(\"[CLIEnvironment] version probe timed out: %@ %@\", executablePath, args.joined(separator: \" \"))\n                return nil\n            }\n            let out = result.stdout\n            let err = result.stderr\n            if out.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                var fallback = err\n                fallback = fallback.trimmingCharacters(in: .whitespacesAndNewlines)\n                if fallback.isEmpty { continue }\n                if let firstLine = fallback.split(separator: \"\\n\").first {\n                    fallback = String(firstLine)\n                }\n                if let ver = firstVersionToken(in: fallback) { return ver }\n                return String(fallback.prefix(48))\n            }\n            var cleaned = out.trimmingCharacters(in: .whitespacesAndNewlines)\n            if cleaned.isEmpty { continue }\n            if let firstLine = cleaned.split(separator: \"\\n\").first { cleaned = String(firstLine) }\n            if let ver = firstVersionToken(in: cleaned) { return ver }\n            return String(cleaned.prefix(48))\n        }\n        return nil\n    }\n\n    // MARK: - PATH resolution helpers\n    private static func resolvedPathComponents(\n        additionalPaths: [String],\n        expandHome: Bool\n    ) -> [String] {\n        var components = additionalPaths\n        components.append(contentsOf: detectedPathComponentsCache)\n        components.append(contentsOf: standardPathComponents)\n        let mapped = components.map { expandHome ? expandHomePath($0) : $0 }\n        let filtered = mapped.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }\n            .filter { !$0.isEmpty }\n        return dedupePreservingOrder(filtered)\n    }\n\n    private static func expandHomePath(_ path: String) -> String {\n        if path.hasPrefix(\"~\") {\n            return (path as NSString).expandingTildeInPath\n        }\n        if path.contains(\"$HOME\") {\n            return path.replacingOccurrences(of: \"$HOME\", with: NSHomeDirectory())\n        }\n        return path\n    }\n\n    private static func dedupePreservingOrder(_ items: [String]) -> [String] {\n        var seen = Set<String>()\n        var result: [String] = []\n        for item in items where !item.isEmpty {\n            if seen.insert(item).inserted {\n                result.append(item)\n            }\n        }\n        return result\n    }\n\n    private static func detectPathComponents(for executables: [String]) -> [String] {\n        let fm = FileManager.default\n        let env = ProcessInfo.processInfo.environment\n        let home = NSHomeDirectory()\n\n        func containsExecutable(in dir: String) -> Bool {\n            for name in executables {\n                let candidate = (dir as NSString).appendingPathComponent(name)\n                if fm.isExecutableFile(atPath: candidate) { return true }\n            }\n            return false\n        }\n\n        func addIfExecutable(_ rawDir: String, to results: inout [String]) {\n            let dir = expandHomePath(rawDir)\n            guard !dir.isEmpty, fm.fileExists(atPath: dir) else { return }\n            if containsExecutable(in: dir) { results.append(dir) }\n        }\n\n        func parseSemver(_ raw: String) -> [Int]? {\n            var s = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n            if s.hasPrefix(\"v\") { s.removeFirst() }\n            let parts = s.split(separator: \".\")\n            guard !parts.isEmpty else { return nil }\n            var out: [Int] = []\n            for p in parts {\n                guard let v = Int(p) else { return nil }\n                out.append(v)\n            }\n            return out\n        }\n\n        func compareSemver(_ a: [Int], _ b: [Int]) -> Int {\n            let count = max(a.count, b.count)\n            for i in 0..<count {\n                let av = i < a.count ? a[i] : 0\n                let bv = i < b.count ? b[i] : 0\n                if av != bv { return av > bv ? 1 : -1 }\n            }\n            return 0\n        }\n\n        func bestNvmBin(root: String) -> String? {\n            guard let entries = try? fm.contentsOfDirectory(atPath: root) else { return nil }\n            var candidates: [(bin: String, version: [Int]?, modified: Date?)] = []\n            for name in entries {\n                let dir = (root as NSString).appendingPathComponent(name)\n                let bin = (dir as NSString).appendingPathComponent(\"bin\")\n                if !fm.fileExists(atPath: bin) { continue }\n                if !containsExecutable(in: bin) { continue }\n                let version = parseSemver(name)\n                let modified =\n                    (try? fm.attributesOfItem(atPath: bin)[.modificationDate]) as? Date\n                candidates.append((bin: bin, version: version, modified: modified))\n            }\n            guard !candidates.isEmpty else { return nil }\n            candidates.sort { lhs, rhs in\n                switch (lhs.version, rhs.version) {\n                case let (lv?, rv?):\n                    return compareSemver(lv, rv) > 0\n                case (nil, nil):\n                    if let lm = lhs.modified, let rm = rhs.modified, lm != rm {\n                        return lm > rm\n                    }\n                    return lhs.bin > rhs.bin\n                case (_?, nil):\n                    return true\n                case (nil, _?):\n                    return false\n                }\n            }\n            return candidates.first?.bin\n        }\n\n        var results: [String] = []\n\n        if let nvmBin = env[\"NVM_BIN\"], !nvmBin.isEmpty {\n            addIfExecutable(nvmBin, to: &results)\n        }\n        if let npmPrefix = env[\"NPM_CONFIG_PREFIX\"], !npmPrefix.isEmpty {\n            addIfExecutable(npmPrefix + \"/bin\", to: &results)\n        }\n        if let pnpmHome = env[\"PNPM_HOME\"], !pnpmHome.isEmpty {\n            addIfExecutable(pnpmHome, to: &results)\n        }\n        if let bunInstall = env[\"BUN_INSTALL\"], !bunInstall.isEmpty {\n            addIfExecutable(bunInstall + \"/bin\", to: &results)\n        }\n        let nvmDir = env[\"NVM_DIR\"] ?? (home + \"/.nvm\")\n        let nvmVersions = (nvmDir as NSString).appendingPathComponent(\"versions/node\")\n        if let nvmBest = bestNvmBin(root: nvmVersions) {\n            results.append(nvmBest)\n        }\n\n        let voltaHome = env[\"VOLTA_HOME\"] ?? (home + \"/.volta\")\n        addIfExecutable(voltaHome + \"/bin\", to: &results)\n\n        let fnmDir = env[\"FNM_DIR\"] ?? (home + \"/.fnm\")\n        addIfExecutable(fnmDir + \"/current/bin\", to: &results)\n\n        let asdfDir = env[\"ASDF_DATA_DIR\"] ?? env[\"ASDF_DIR\"] ?? (home + \"/.asdf\")\n        addIfExecutable(asdfDir + \"/shims\", to: &results)\n\n        let nodenvDir = env[\"NODENV_ROOT\"] ?? (home + \"/.nodenv\")\n        addIfExecutable(nodenvDir + \"/shims\", to: &results)\n\n        let nodebrewDir = env[\"NODEBREW_ROOT\"] ?? (home + \"/.nodebrew\")\n        addIfExecutable(nodebrewDir + \"/current/bin\", to: &results)\n\n        addIfExecutable(home + \"/.npm-global/bin\", to: &results)\n        addIfExecutable(home + \"/.npm-packages/bin\", to: &results)\n        addIfExecutable(home + \"/.yarn/bin\", to: &results)\n        addIfExecutable(home + \"/Library/pnpm\", to: &results)\n        addIfExecutable(home + \"/.local/share/pnpm\", to: &results)\n\n        return dedupePreservingOrder(results)\n    }\n\n    private static func detectLoginShellPATH() -> String? {\n        let shell = resolvedShellExecutable()\n        let shellName = URL(fileURLWithPath: shell).lastPathComponent.lowercased()\n        let command: String = shellName == \"fish\" ? \"string join : $PATH\" : \"printf %s \\\"$PATH\\\"\"\n        guard let result = runProcess(\n            executable: shell,\n            arguments: [\"-lic\", command],\n            timeout: 1.0\n        ) else { return nil }\n        if result.timedOut {\n            NSLog(\"[CLIEnvironment] login shell PATH probe timed out (%@)\", shell)\n            return nil\n        }\n        guard result.exitCode == 0 else { return nil }\n        let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n        return str.isEmpty ? nil : str\n    }\n\n    private static func shellWhich(_ name: String) -> String? {\n        let shell = resolvedShellExecutable()\n        guard let result = runProcess(\n            executable: shell,\n            arguments: [\"-lic\", \"command -v \\(name) || which \\(name)\"],\n            timeout: 1.0\n        ) else { return nil }\n        if result.timedOut {\n            NSLog(\"[CLIEnvironment] shell which timed out (%@, %@)\", shell, name)\n            return nil\n        }\n        guard result.exitCode == 0 else { return nil }\n        let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n        return str.isEmpty ? nil : str\n    }\n\n    private static func which(_ name: String, path: String) -> String? {\n        var env = ProcessInfo.processInfo.environment\n        env[\"PATH\"] = path\n        guard let result = runProcess(\n            executable: \"/usr/bin/env\",\n            arguments: [\"which\", name],\n            environment: env,\n            timeout: 1.0\n        ) else { return nil }\n        if result.timedOut {\n            NSLog(\"[CLIEnvironment] which timed out (%@)\", name)\n            return nil\n        }\n        guard result.exitCode == 0 else { return nil }\n        let str = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !str.isEmpty { return str }\n        return nil\n    }\n\n    private static func manualResolve(_ name: String, path: String) -> String? {\n        let fm = FileManager.default\n        for raw in path.split(separator: \":\") {\n            var dir = String(raw)\n            if dir.isEmpty { continue }\n            dir = expandHomePath(dir)\n            let candidate = (dir as NSString).appendingPathComponent(name)\n            if fm.isExecutableFile(atPath: candidate) { return candidate }\n        }\n        return nil\n    }\n\n    private static func mergePATHStrings(_ paths: [String]) -> String {\n        var components: [String] = []\n        for raw in paths {\n            guard !raw.isEmpty else { continue }\n            let parts = raw.split(separator: \":\").map { String($0) }\n            components.append(contentsOf: parts)\n        }\n        let expanded = components.map { expandHomePath($0) }\n        let filtered = expanded.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }\n            .filter { !$0.isEmpty }\n        return dedupePreservingOrder(filtered).joined(separator: \":\")\n    }\n\n    private static func sanitizeExecutablePath(_ raw: String) -> String? {\n        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty, trimmed.contains(\"/\") else { return nil }\n        let expanded = expandHomePath(trimmed)\n        return FileManager.default.isExecutableFile(atPath: expanded) ? expanded : nil\n    }\n\n    private struct ProcessResult {\n        let exitCode: Int32\n        let stdout: String\n        let stderr: String\n        let timedOut: Bool\n    }\n\n    private static func runProcess(\n        executable: String,\n        arguments: [String],\n        environment: [String: String]? = nil,\n        timeout: TimeInterval\n    ) -> ProcessResult? {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: executable)\n        process.arguments = arguments\n        let stdoutPipe = Pipe()\n        let stderrPipe = Pipe()\n        process.standardOutput = stdoutPipe\n        process.standardError = stderrPipe\n        if let environment {\n            var env = ProcessInfo.processInfo.environment\n            for (key, value) in environment {\n                env[key] = value\n            }\n            process.environment = env\n        }\n\n        let semaphore = DispatchSemaphore(value: 0)\n        process.terminationHandler = { _ in\n            semaphore.signal()\n        }\n\n        do {\n            try process.run()\n        } catch {\n            return nil\n        }\n\n        let finished = semaphore.wait(timeout: .now() + timeout) == .success\n        if !finished {\n            process.terminate()\n            _ = semaphore.wait(timeout: .now() + 0.2)\n            if process.isRunning {\n                #if canImport(Darwin)\n                _ = kill(process.processIdentifier, SIGKILL)\n                #endif\n            }\n        }\n\n        process.waitUntilExit()\n        let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()\n        let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()\n        let stdout = String(data: stdoutData, encoding: .utf8) ?? \"\"\n        let stderr = String(data: stderrData, encoding: .utf8) ?? \"\"\n        return ProcessResult(\n            exitCode: process.terminationStatus,\n            stdout: stdout,\n            stderr: stderr,\n            timedOut: !finished\n        )\n    }\n\n    private static func resolvedShellExecutable() -> String {\n        let envShell = ProcessInfo.processInfo.environment[\"SHELL\"] ?? \"/bin/zsh\"\n        let candidate = expandHomePath(envShell)\n        if FileManager.default.isExecutableFile(atPath: candidate) {\n            return candidate\n        }\n        return \"/bin/zsh\"\n    }\n\n    private static func firstVersionToken(in line: String) -> String? {\n        let separators = CharacterSet.whitespacesAndNewlines\n        let tokens = line.components(separatedBy: separators).filter { !$0.isEmpty }\n        for t in tokens {\n            var s = t\n            s = s.trimmingCharacters(in: CharacterSet(charactersIn: \",;()[]{}\"))\n            let parts = s.split(separator: \".\")\n            if parts.count >= 2 && parts.count <= 4 && parts.allSatisfy({ $0.allSatisfy({ $0.isNumber }) || $0.contains(\"-\") }) {\n                let core = parts.prefix(3)\n                if core.allSatisfy({ $0.allSatisfy({ $0.isNumber }) }) { return s }\n            }\n        }\n        return nil\n    }\n}\n"
  },
  {
    "path": "utils/EmbeddedSessionNotification.swift",
    "content": "import Foundation\n\nenum EmbeddedSessionNotification {\n  static let sessionIdKey = \"sessionId\"\n  static let sourceDataKey = \"sourceData\"\n\n  static func postEmbeddedNewSession(sessionId: String, source: SessionSource) {\n    NotificationCenter.default.post(\n      name: .codMateStartEmbeddedNewSession,\n      object: nil,\n      userInfo: userInfo(sessionId: sessionId, source: source)\n    )\n  }\n\n  static func userInfo(sessionId: String, source: SessionSource) -> [AnyHashable: Any] {\n    var info: [AnyHashable: Any] = [sessionIdKey: sessionId]\n    if let data = try? JSONEncoder().encode(source) {\n      info[sourceDataKey] = data\n    }\n    return info\n  }\n\n  static func decodeSource(from userInfo: [AnyHashable: Any]?) -> SessionSource? {\n    guard let data = userInfo?[sourceDataKey] as? Data else { return nil }\n    return try? JSONDecoder().decode(SessionSource.self, from: data)\n  }\n}\n"
  },
  {
    "path": "utils/FilenameSanitizer.swift",
    "content": "import Foundation\n\nfunc sanitizeFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String {\n    var text = s.trimmingCharacters(in: .whitespacesAndNewlines)\n    if text.isEmpty { return fallback }\n    // Replace path separators and reserved colon; strip control characters\n    let disallowed = CharacterSet(charactersIn: \"/:\")\n        .union(.newlines)\n        .union(.controlCharacters)\n    text = text.unicodeScalars.map { disallowed.contains($0) ? Character(\" \") : Character($0) }\n        .reduce(into: String(), { $0.append($1) })\n    // Collapse consecutive spaces\n    while text.contains(\"  \") { text = text.replacingOccurrences(of: \"  \", with: \" \") }\n    text = text.trimmingCharacters(in: .whitespacesAndNewlines)\n    if text.isEmpty { text = fallback }\n    // Limit length to keep file names tidy\n    if text.count > maxLength {\n        let idx = text.index(text.startIndex, offsetBy: maxLength)\n        text = String(text[..<idx])\n    }\n    return text\n}\n\n"
  },
  {
    "path": "utils/FlexibleDecoders.swift",
    "content": "import Foundation\n\nenum FlexibleDecoders {\n    /// JSONDecoder that accepts ISO-8601 timestamps with or without fractional seconds.\n    /// Falls back to UNIX epoch seconds/milliseconds if a numeric string is provided.\n    static func iso8601Flexible() -> JSONDecoder {\n        let decoder = JSONDecoder()\n        decoder.dateDecodingStrategy = .custom { decoder in\n            let container = try decoder.singleValueContainer()\n            let raw = try container.decode(String.self)\n\n            if let d = FlexibleDecoders.iso8601WithFractional.date(from: raw)\n                ?? FlexibleDecoders.iso8601Standard.date(from: raw)\n            {\n                return d\n            }\n            // Fallbacks: numeric seconds or milliseconds since epoch represented as string\n            if let number = Double(raw) {\n                // Heuristic: treat very large numbers as milliseconds\n                if number > 10_000_000_000 { // ~Sat Nov 20 2286 in seconds; anything larger is likely ms\n                    return Date(timeIntervalSince1970: number / 1000.0)\n                } else {\n                    return Date(timeIntervalSince1970: number)\n                }\n            }\n            throw DecodingError.dataCorruptedError(\n                in: container,\n                debugDescription: \"Invalid ISO-8601 date: \\(raw)\"\n            )\n        }\n        return decoder\n    }\n\n    // MARK: - Private formatters\n    private static let iso8601WithFractional: ISO8601DateFormatter = {\n        let f = ISO8601DateFormatter()\n        f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]\n        return f\n    }()\n\n    private static let iso8601Standard: ISO8601DateFormatter = {\n        let f = ISO8601DateFormatter()\n        f.formatOptions = [.withInternetDateTime]\n        return f\n    }()\n}\n"
  },
  {
    "path": "utils/InternalWizardPaths.swift",
    "content": "import CryptoKit\nimport Foundation\n\nenum InternalWizardPaths {\n  static let internalFolderName = \"internal\"\n  static let projectFolderName = \"cli-project\"\n\n  static func internalRoot(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL {\n    home\n      .appendingPathComponent(\".codmate\", isDirectory: true)\n      .appendingPathComponent(internalFolderName, isDirectory: true)\n  }\n\n  static func projectRoot(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL {\n    internalRoot(home: home)\n      .appendingPathComponent(projectFolderName, isDirectory: true)\n  }\n\n  static func ensureProjectRootExists(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> URL {\n    let root = projectRoot(home: home)\n    try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)\n    return root\n  }\n\n  static func ignoredSubpaths(home: URL = SessionPreferencesStore.getRealUserHomeURL()) -> [String] {\n    var paths: [String] = [projectRoot(home: home).path]\n    if let geminiTmp = geminiTempPath(home: home) {\n      paths.append(geminiTmp)\n    }\n    return paths\n  }\n\n  private static func geminiTempPath(home: URL) -> String? {\n    let projectPath = projectRoot(home: home).path\n    guard let hash = geminiProjectHash(for: projectPath) else { return nil }\n    return home\n      .appendingPathComponent(\".gemini\", isDirectory: true)\n      .appendingPathComponent(\"tmp\", isDirectory: true)\n      .appendingPathComponent(hash, isDirectory: true)\n      .path\n  }\n\n  private static func geminiProjectHash(for path: String) -> String? {\n    let canonical = (path as NSString).expandingTildeInPath\n    guard let data = canonical.data(using: .utf8) else { return nil }\n    let digest = SHA256.hash(data: data)\n    return digest.map { String(format: \"%02x\", $0) }.joined()\n  }\n}\n"
  },
  {
    "path": "utils/MarkdownExportBuilder.swift",
    "content": "import Foundation\n\nstruct MarkdownExportBuilder {\n    static func build(\n        session: SessionSummary,\n        turns: [ConversationTurn],\n        visibleKinds: Set<MessageVisibilityKind>,\n        exportURL: URL\n    ) -> String {\n        let df = DateFormatter()\n        df.dateStyle = .medium\n        df.timeStyle = .short\n\n        var lines: [String] = []\n        let title = session.effectiveTitle\n        lines.append(\"# \\(title)\")\n        lines.append(\"\")\n\n        // Metadata summary\n        let sourceName = session.source.baseKind.displayName\n        let remoteSuffix = session.source.remoteHost.map { \" (\\($0))\" } ?? \"\"\n        lines.append(\"- Source: \\(sourceName)\\(remoteSuffix)\")\n        lines.append(\"- Started: \\(df.string(from: session.startedAt))\")\n        if let end = session.endedAt ?? session.lastUpdatedAt, end != session.startedAt {\n            lines.append(\"- Updated: \\(df.string(from: end))\")\n        }\n        if session.duration > 0 {\n            lines.append(\"- Duration: \\(session.readableDuration)\")\n        }\n        if let model = session.displayModel ?? session.model, !model.isEmpty {\n            lines.append(\"- Model: \\(model)\")\n        }\n        if !session.cwd.isEmpty {\n            lines.append(\"- CWD: \\(session.cwd)\")\n        }\n        if let approval = session.approvalPolicy, !approval.isEmpty {\n            lines.append(\"- Approval Policy: \\(approval)\")\n        }\n        if let originator = session.originator.nonEmpty {\n            lines.append(\"- Originator: \\(originator)\")\n        }\n        lines.append(\"\")\n\n        if let comment = session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines),\n           !comment.isEmpty {\n            lines.append(\"## Comment\")\n            lines.append(comment)\n            lines.append(\"\")\n        }\n\n        if let instructions = session.instructions?.trimmingCharacters(in: .whitespacesAndNewlines),\n           !instructions.isEmpty {\n            lines.append(\"## Task Instructions\")\n            lines.append(instructions)\n            lines.append(\"\")\n        }\n\n        lines.append(\"## Conversation\")\n        let filteredTurns = turns.filtering(visibleKinds: visibleKinds)\n        for turn in filteredTurns {\n            let events = turn.allEvents\n            for event in events where visibleKinds.contains(event.visibilityKind) {\n                lines.append(\"\")\n                lines.append(eventHeader(event: event, dateFormatter: df, session: session))\n                if let text = event.text?.trimmingCharacters(in: .whitespacesAndNewlines),\n                   !text.isEmpty {\n                    lines.append(\"\")\n                    lines.append(text)\n                }\n                if event.repeatCount > 1 {\n                    lines.append(\"\")\n                    lines.append(\"_Repeated ×\\(event.repeatCount)_\")\n                }\n                if !event.attachments.isEmpty {\n                    lines.append(\"\")\n                    lines.append(\"_Attachments: \\(attachmentSummary(event.attachments))_\")\n                }\n                if let metadata = event.metadata, !metadata.isEmpty {\n                    lines.append(\"\")\n                    lines.append(\"Metadata:\")\n                    for key in metadata.keys.sorted() {\n                        if let value = metadata[key], !value.isEmpty {\n                            lines.append(\"- \\(key): \\(value)\")\n                        }\n                    }\n                }\n            }\n        }\n        lines.append(\"\")\n        lines.append(\"_Exported to \\(exportURL.lastPathComponent)_\")\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private static func eventHeader(\n        event: TimelineEvent,\n        dateFormatter: DateFormatter,\n        session: SessionSummary\n    ) -> String {\n        let role = eventRoleTitle(event: event, session: session)\n        let time = dateFormatter.string(from: event.timestamp)\n        if let title = event.title,\n           !title.isEmpty,\n           title != role,\n           MessageVisibilityKind.kindFromToken(title) != event.visibilityKind {\n            return \"### \\(role) · \\(title) · \\(time)\"\n        }\n        return \"### \\(role) · \\(time)\"\n    }\n\n    private static func eventRoleTitle(event: TimelineEvent, session: SessionSummary) -> String {\n        event.visibilityKind.settingsLabel\n    }\n\n    private static func attachmentSummary(_ attachments: [TimelineAttachment]) -> String {\n        let imageCount = attachments.filter { $0.kind == .image }.count\n        if imageCount > 0 {\n            return \"\\(imageCount) image\" + (imageCount == 1 ? \"\" : \"s\")\n        }\n        return \"\\(attachments.count) attachment\" + (attachments.count == 1 ? \"\" : \"s\")\n    }\n}\n\nprivate extension String {\n    var nonEmpty: String? {\n        let trimmed = trimmingCharacters(in: .whitespacesAndNewlines)\n        return trimmed.isEmpty ? nil : trimmed\n    }\n}\n"
  },
  {
    "path": "utils/ModelNameSanitizer.swift",
    "content": "import Foundation\n\n/// Utility for sanitizing and cleaning AI model names for display in pickers and UI.\n///\n/// This sanitizer:\n/// - Removes date suffixes (e.g., -20241022, -202410)\n/// - Removes provider prefixes (e.g., anthropic/, google/, openai/)\n/// - Handles duplicate models by keeping the latest version\n/// - Provides clean, user-friendly model names\nstruct ModelNameSanitizer {\n\n    /// Represents a sanitized model with both display name and original ID\n    struct SanitizedModel {\n        let displayName: String\n        let originalId: String\n    }\n\n    /// Common provider prefixes to remove from model names\n    private static let providerPrefixes = [\n        \"anthropic/\",\n        \"google/\",\n        \"openai/\",\n        \"mistralai/\",\n        \"meta-llama/\",\n        \"cohere/\",\n        \"ai21/\",\n        \"aleph-alpha/\",\n        \"amazon/\",\n        \"claude/\",\n        \"gemini/\",\n        \"gpt/\",\n        \"codex/\"\n    ]\n\n    /// Sanitizes a list of model names by removing dates and provider prefixes,\n    /// and eliminating duplicates (keeping the latest version).\n    ///\n    /// - Parameter models: Array of model names to sanitize\n    /// - Returns: Array of SanitizedModel with display names and original IDs\n    static func sanitize(_ models: [String]) -> [SanitizedModel] {\n        var seenBaseNames: [String: ModelVersion] = [:]\n\n        for model in models {\n            let cleaned = removeProviderPrefix(model)\n            let (baseName, version) = extractBaseNameAndVersion(cleaned)\n\n            // Keep the latest version for each base name\n            if let existing = seenBaseNames[baseName] {\n                if version.isNewerThan(existing) {\n                    seenBaseNames[baseName] = version\n                }\n            } else {\n                seenBaseNames[baseName] = version\n            }\n        }\n\n        // Sort by base name for consistent ordering\n        return seenBaseNames.keys.sorted().map { baseName in\n            SanitizedModel(\n                displayName: baseName,\n                originalId: seenBaseNames[baseName]!.originalName\n            )\n        }\n    }\n\n    /// Sanitizes a single model name by removing provider prefix and date suffix.\n    ///\n    /// - Parameter model: Model name to sanitize\n    /// - Returns: Sanitized model name\n    static func sanitizeSingle(_ model: String) -> String {\n        let cleaned = removeProviderPrefix(model)\n        let (baseName, _) = extractBaseNameAndVersion(cleaned)\n        return baseName\n    }\n\n    /// Extracts base name and version from a model name (public helper for version comparison)\n    ///\n    /// - Parameter model: Model name to process\n    /// - Returns: Tuple of (base name, version info)\n    static func extractModelVersion(_ model: String) -> (baseName: String, version: ModelVersion) {\n        let cleaned = removeProviderPrefix(model)\n        return extractBaseNameAndVersion(cleaned)\n    }\n\n    /// Represents version information extracted from a model name (public for comparison)\n    struct ModelVersion {\n        let originalName: String\n        let dateString: String?\n        let format: DateFormat?\n\n        enum DateFormat {\n            case yyyyMMdd\n            case yyyyMM\n        }\n\n        /// Compares if this version is newer than another version\n        func isNewerThan(_ other: ModelVersion) -> Bool {\n            // If both have dates, compare them\n            if let myDate = dateString, let otherDate = other.dateString {\n                return myDate > otherDate\n            }\n\n            // If only this version has a date, it's considered newer\n            if dateString != nil && other.dateString == nil {\n                return true\n            }\n\n            // If only the other version has a date, it's considered newer\n            if dateString == nil && other.dateString != nil {\n                return false\n            }\n\n            // If neither has a date, compare original names lexicographically\n            return originalName > other.originalName\n        }\n    }\n\n    /// Removes provider prefix from a model name.\n    ///\n    /// Example: \"anthropic/claude-3-5-sonnet-20241022\" -> \"claude-3-5-sonnet-20241022\"\n    ///\n    /// - Parameter model: Model name with potential provider prefix\n    /// - Returns: Model name without provider prefix\n    static func removeProviderPrefix(_ model: String) -> String {\n        for prefix in providerPrefixes {\n            if model.hasPrefix(prefix) {\n                return String(model.dropFirst(prefix.count))\n            }\n        }\n        return model\n    }\n\n    /// Extracts the base name and version information from a model name.\n    ///\n    /// Identifies and removes date suffixes in formats:\n    /// - YYYYMMDD (e.g., 20241022)\n    /// - YYYYMM (e.g., 202410)\n    ///\n    /// Example: \"claude-3-5-sonnet-20241022\" -> (\"claude-3-5-sonnet\", ModelVersion)\n    ///\n    /// - Parameter model: Model name to process\n    /// - Returns: Tuple of (base name, version info)\n    private static func extractBaseNameAndVersion(_ model: String) -> (String, ModelVersion) {\n        // Pattern for YYYYMMDD format (8 digits)\n        let datePattern8 = #\"^(.+?)[-_]?(\\d{8})$\"#\n        // Pattern for YYYYMM format (6 digits)\n        let datePattern6 = #\"^(.+?)[-_]?(\\d{6})$\"#\n\n        if let regex = try? NSRegularExpression(pattern: datePattern8),\n           let match = regex.firstMatch(in: model, range: NSRange(model.startIndex..., in: model)),\n           let baseRange = Range(match.range(at: 1), in: model),\n           let dateRange = Range(match.range(at: 2), in: model) {\n            let baseName = String(model[baseRange])\n            let dateString = String(model[dateRange])\n            return (baseName, ModelVersion(originalName: model, dateString: dateString, format: .yyyyMMdd))\n        }\n\n        if let regex = try? NSRegularExpression(pattern: datePattern6),\n           let match = regex.firstMatch(in: model, range: NSRange(model.startIndex..., in: model)),\n           let baseRange = Range(match.range(at: 1), in: model),\n           let dateRange = Range(match.range(at: 2), in: model) {\n            let baseName = String(model[baseRange])\n            let dateString = String(model[dateRange])\n            return (baseName, ModelVersion(originalName: model, dateString: dateString, format: .yyyyMM))\n        }\n\n        // No date pattern found, return as-is\n        return (model, ModelVersion(originalName: model, dateString: nil, format: nil))\n    }\n}\n"
  },
  {
    "path": "utils/ProviderIconResource.swift",
    "content": "import AppKit\nimport CoreImage\nimport SwiftUI\n\n/// Unified provider icon resource library\n/// Manages all provider icons with centralized theme adaptation\nenum ProviderIconResource {\n  /// Icon metadata including theme adaptation requirements\n  struct IconMetadata {\n    let name: String\n    let requiresDarkModeInversion: Bool\n    let aliases: [String]  // Alternative names/IDs that map to this icon\n  }\n\n  /// Registry of all provider icons with their metadata\n  static let iconRegistry: [IconMetadata] = [\n    // OAuth providers\n    IconMetadata(\n      name: \"ChatGPTIcon\", requiresDarkModeInversion: true, aliases: [\"codex\", \"openai\"]),\n    IconMetadata(\n      name: \"ClaudeIcon\", requiresDarkModeInversion: false, aliases: [\"claude\", \"anthropic\"]),\n    IconMetadata(\n      name: \"GeminiIcon\", requiresDarkModeInversion: false, aliases: [\"gemini\", \"google\"]),\n    IconMetadata(\n      name: \"AntigravityIcon\", requiresDarkModeInversion: false, aliases: [\"antigravity\"]),\n    IconMetadata(name: \"QwenIcon\", requiresDarkModeInversion: false, aliases: [\"qwen\"]),\n\n    // API key providers\n    IconMetadata(\n      name: \"DeepSeekIcon\", requiresDarkModeInversion: false, aliases: [\"deepseek\", \"deep-seek\"]),\n    IconMetadata(\n      name: \"MiniMaxIcon\", requiresDarkModeInversion: true, aliases: [\"minimax\", \"mini-max\"]),\n    IconMetadata(\n      name: \"OpenRouterIcon\", requiresDarkModeInversion: true,\n      aliases: [\"openrouter\", \"open-router\"]),\n    IconMetadata(\n      name: \"ZaiIcon\", requiresDarkModeInversion: true, aliases: [\"zai\", \"z.ai\", \"glm\"]),\n    IconMetadata(\n      name: \"KimiIcon\", requiresDarkModeInversion: true, aliases: [\"kimi\", \"k2\", \"moonshot\"]),\n  ]\n\n  /// Lookup map: alias -> icon name\n  private static let aliasMap: [String: String] = {\n    var map: [String: String] = [:]\n    for icon in iconRegistry {\n      map[icon.name.lowercased()] = icon.name\n      for alias in icon.aliases {\n        map[alias.lowercased()] = icon.name\n      }\n    }\n    return map\n  }()\n\n  /// Lookup map: icon name -> metadata\n  private static let metadataMap: [String: IconMetadata] = {\n    Dictionary(uniqueKeysWithValues: iconRegistry.map { ($0.name, $0) })\n  }()\n\n  /// Find icon name by alias (ID, name, or baseURL)\n  static func iconName(for alias: String) -> String? {\n    aliasMap[alias.lowercased()]\n  }\n\n  /// Find icon name by provider ID, name, or baseURL\n  static func iconName(forProviderId id: String?, name: String?, baseURL: String?) -> String? {\n    // Try ID first\n    if let id = id, let iconName = iconName(for: id) {\n      return iconName\n    }\n\n    // Try name\n    if let name = name, let iconName = iconName(for: name) {\n      return iconName\n    }\n\n    // Try baseURL\n    if let baseURL = baseURL?.lowercased() {\n      // Check for domain matches\n      if baseURL.contains(\"deepseek.com\") {\n        return \"DeepSeekIcon\"\n      } else if baseURL.contains(\"minimaxi.com\") || baseURL.contains(\"minimax.com\") {\n        return \"MiniMaxIcon\"\n      } else if baseURL.contains(\"openrouter.ai\") {\n        return \"OpenRouterIcon\"\n      } else if baseURL.contains(\"zai.com\") || baseURL.contains(\"z.ai\")\n        || baseURL.contains(\"bigmodel.cn\")\n      {\n        return \"ZaiIcon\"\n      } else if baseURL.contains(\"moonshot.cn\") || baseURL.contains(\"kimi\") {\n        return \"KimiIcon\"\n      } else if baseURL.contains(\"openai.com\") {\n        return \"ChatGPTIcon\"\n      } else if baseURL.contains(\"anthropic.com\") {\n        return \"ClaudeIcon\"\n      }\n    }\n\n    return nil\n  }\n\n  /// Get metadata for an icon name\n  static func metadata(for iconName: String) -> IconMetadata? {\n    metadataMap[iconName]\n  }\n\n  /// Check if an icon requires dark mode inversion\n  static func requiresDarkModeInversion(_ iconName: String) -> Bool {\n    metadata(for: iconName)?.requiresDarkModeInversion ?? false\n  }\n\n  /// Process NSImage for display with theme adaptation\n  /// - Parameters:\n  ///   - iconName: The icon asset name\n  ///   - size: Target size for the icon\n  ///   - isDarkMode: Whether dark mode is active\n  /// - Returns: Processed NSImage ready for display\n  static func processedImage(\n    named iconName: String,\n    size: NSSize,\n    isDarkMode: Bool\n  ) -> NSImage? {\n    guard let originalImage = NSImage(named: iconName) else { return nil }\n\n    // Resize to target size\n    let resized = NSImage(size: size)\n    resized.lockFocus()\n    originalImage.draw(\n      in: NSRect(origin: .zero, size: size),\n      from: NSRect(origin: .zero, size: originalImage.size),\n      operation: .copy,\n      fraction: 1.0\n    )\n    resized.unlockFocus()\n\n    // Apply inversion if needed\n    if requiresDarkModeInversion(iconName) && isDarkMode {\n      return invertedImage(resized) ?? resized\n    }\n\n    return resized\n  }\n\n  /// Invert an NSImage using Core Image filter\n  private static func invertedImage(_ image: NSImage) -> NSImage? {\n    guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {\n      return nil\n    }\n\n    let ciImage = CIImage(cgImage: cgImage)\n    guard let filter = CIFilter(name: \"CIColorInvert\") else { return nil }\n    filter.setValue(ciImage, forKey: kCIInputImageKey)\n    guard let outputImage = filter.outputImage else { return nil }\n\n    let rep = NSCIImageRep(ciImage: outputImage)\n    let newImage = NSImage(size: image.size)\n    newImage.addRepresentation(rep)\n    return newImage\n  }\n\n  /// Get all registered icon names\n  static var allIconNames: [String] {\n    iconRegistry.map { $0.name }\n  }\n\n  /// Get icons that require dark mode inversion\n  static var darkModeInvertIcons: Set<String> {\n    Set(iconRegistry.filter { $0.requiresDarkModeInversion }.map { $0.name })\n  }\n\n  /// Find icon name for an API key provider\n  /// This is a convenience method that extracts baseURL from provider connectors\n  /// - Parameter provider: The provider to find icon for\n  /// - Returns: Icon name from Assets.xcassets if found, nil otherwise\n  static func iconName(for provider: ProvidersRegistryService.Provider) -> String? {\n    let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?\n      .baseURL\n    let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?\n      .baseURL\n    let baseURL = codexBaseURL ?? claudeBaseURL\n\n    return iconName(\n      forProviderId: provider.id,\n      name: provider.name,\n      baseURL: baseURL\n    )\n  }\n}\n\n/// SwiftUI ViewModifier for applying dark mode inversion to provider icons\nstruct ProviderIconDarkModeModifier: ViewModifier {\n  let iconName: String\n  @Environment(\\.colorScheme) private var colorScheme\n\n  func body(content: Content) -> some View {\n    if ProviderIconResource.requiresDarkModeInversion(iconName) && colorScheme == .dark {\n      content.colorInvert()\n    } else {\n      content\n    }\n  }\n}\n\nextension View {\n  /// Apply dark mode inversion to provider icons if needed\n  func providerIconTheme(iconName: String) -> some View {\n    modifier(ProviderIconDarkModeModifier(iconName: iconName))\n  }\n}\n"
  },
  {
    "path": "utils/ProviderIconThemeHelper.swift",
    "content": "import AppKit\nimport SwiftUI\n\n/// Helper for handling provider icon theme adaptation (dark/light mode)\n/// Now delegates to ProviderIconResource for unified icon management\nenum ProviderIconThemeHelper {\n  /// Icon names that require color inversion in dark mode\n  /// @deprecated: Use ProviderIconResource.darkModeInvertIcons instead\n  static var darkModeInvertIcons: Set<String> {\n    ProviderIconResource.darkModeInvertIcons\n  }\n\n  /// Check if an icon name requires inversion in dark mode\n  /// @deprecated: Use ProviderIconResource.requiresDarkModeInversion instead\n  static func shouldInvertInDarkMode(_ iconName: String) -> Bool {\n    ProviderIconResource.requiresDarkModeInversion(iconName)\n  }\n\n  /// Check if current appearance is dark mode (for AppKit contexts)\n  static func isDarkMode() -> Bool {\n    if let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) {\n      return appearance == .darkAqua\n    }\n    return false\n  }\n\n  /// Process an NSImage for menu display, applying dark mode inversion if needed\n  /// Now uses ProviderIconResource for unified processing\n  static func menuImage(named iconName: String, size: NSSize = NSSize(width: 14, height: 14)) -> NSImage? {\n    ProviderIconResource.processedImage(\n      named: iconName,\n      size: size,\n      isDarkMode: isDarkMode()\n    )\n  }\n}\n"
  },
  {
    "path": "utils/SessionPathFilter.swift",
    "content": "import Foundation\n\n/// Shared utility for filtering session paths based on ignore rules.\n/// Consolidates duplicate logic across SessionIndexer and session providers.\nenum SessionPathFilter {\n    /// Check if an absolute path should be ignored based on ignore rules.\n    /// - Parameters:\n    ///   - absolutePath: The full path to check\n    ///   - ignoredPaths: Array of path substrings to match against (case-insensitive)\n    /// - Returns: `true` if the path should be ignored\n    static func shouldIgnorePath(_ absolutePath: String, ignoredPaths: [String]) -> Bool {\n        guard !ignoredPaths.isEmpty else { return false }\n        let lowercasedPath = absolutePath.lowercased()\n        \n        for ignored in ignoredPaths {\n            let needle = ignored.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !needle.isEmpty else { continue }\n            if lowercasedPath.contains(needle.lowercased()) {\n                return true\n            }\n        }\n        return false\n    }\n    \n    /// Check if a session summary should be ignored based on its file path and working directory.\n    /// - Parameters:\n    ///   - summary: The session summary to check\n    ///   - ignoredPaths: Array of path substrings to match against\n    /// - Returns: `true` if the session should be ignored\n    static func shouldIgnoreSummary(_ summary: SessionSummary, ignoredPaths: [String]) -> Bool {\n        guard !ignoredPaths.isEmpty else { return false }\n        \n        // Check both file path and cwd (working directory is what users typically want to filter by)\n        return shouldIgnorePath(summary.fileURL.path, ignoredPaths: ignoredPaths)\n            || shouldIgnorePath(summary.cwd, ignoredPaths: ignoredPaths)\n    }\n}\n"
  },
  {
    "path": "utils/SessionSummaryMaterialBuilder.swift",
    "content": "import Foundation\n\n/// Builds intelligent summarization material from conversation turns\n/// Implements truncation, deduplication, and code/log trimming strategies\nstruct SessionSummaryMaterialBuilder {\n\n    // MARK: - Constants\n\n    private static let messageSeparator = \"\\n\\n\"\n    private static let sectionSeparator = \"\\n\\n---\\n\\n\"\n\n    private static let defaultMaxLength = 8000\n    private static let assistantMessageMaxLength = 3000\n\n    private static let deduplicationThreshold = 0.95\n\n    private static let codeBlockKeepFirst = 5\n    private static let codeBlockKeepLast = 3\n\n    private static let errorLogKeepFirst = 10\n    private static let errorLogKeepLast = 5\n    private static let errorLogMinLines = 5\n\n    // Precompiled regex patterns for error detection\n    private static let errorPatterns: [NSRegularExpression] = {\n        let patterns = [\n            \"^\\\\s*at \",           // Stack trace\n            \"^\\\\s*Error:\",        // Error message\n            \"^\\\\s*Exception:\",    // Exception\n            \"^\\\\s*Traceback\",     // Python traceback\n            \"^\\\\s*File \\\"\",       // Python file reference\n            \"^\\\\s*\\\\d+\\\\s*\\\\|\",   // Numbered error output\n            \"^\\\\s*/.*:\\\\d+\",      // File path with line number\n        ]\n        return patterns.compactMap { try? NSRegularExpression(pattern: $0, options: []) }\n    }()\n\n    // MARK: - Public Interface\n\n    /// Build summarization material from conversation turns\n    /// - Parameters:\n    ///   - turns: The conversation turns to process\n    ///   - maxLength: Maximum total character count (default: 8000)\n    /// - Returns: Formatted material string for LLM prompt\n    static func build(turns: [ConversationTurn], maxLength: Int = defaultMaxLength) -> String {\n        // Extract user messages using shared helper\n        let rawUserMessages = turns.extractUserMessages()\n\n        // Deduplicate user messages\n        let userMessages = deduplicate(rawUserMessages, threshold: deduplicationThreshold)\n\n        // Process each message: trim code blocks and error logs\n        let processedMessages = userMessages.map { msg in\n            trimCodeBlocks(in: trimErrorLogs(in: msg))\n        }\n\n        // If exceeds maxLength, remove middle messages to fit\n        let finalMessages = truncateMiddleMessages(processedMessages, maxLength: maxLength)\n        let material = finalMessages.joined(separator: messageSeparator)\n\n        // Append last assistant message\n        if let lastAssistant = turns.extractLastAssistantMessage() {\n            let trimmed = String(lastAssistant.prefix(assistantMessageMaxLength))\n            return material + sectionSeparator + \"Assistant's final response:\\n\\n\\(trimmed)\"\n        }\n\n        return material\n    }\n\n    // MARK: - Deduplication\n\n    /// Deduplicate consecutive similar messages using Levenshtein distance\n    /// Only compares adjacent messages (n vs n+1) for O(n) complexity\n    private static func deduplicate(_ messages: [String], threshold: Double) -> [String] {\n        guard !messages.isEmpty else { return [] }\n\n        var result: [String] = [messages[0]]\n\n        for i in 1..<messages.count {\n            let current = messages[i]\n            let previous = messages[i - 1]\n\n            // Only compare with immediately previous message\n            if similarity(previous, current) < threshold {\n                result.append(current)\n            }\n            // If similar to previous, skip (deduplicate)\n        }\n\n        return result\n    }\n\n    /// Calculate similarity ratio between two strings using Levenshtein distance\n    private static func similarity(_ s1: String, _ s2: String) -> Double {\n        let len1 = s1.count\n        let len2 = s2.count\n        let maxLen = max(len1, len2)\n        guard maxLen > 0 else { return 1.0 }\n\n        // Quick length-based check: if length difference > 10%, consider different\n        let lengthDiff = abs(len1 - len2)\n        if Double(lengthDiff) / Double(maxLen) > 0.1 {\n            return 0.0\n        }\n\n        // For very long strings, only compare first 1000 characters to save time\n        let s1Trimmed = len1 > 1000 ? String(s1.prefix(1000)) : s1\n        let s2Trimmed = len2 > 1000 ? String(s2.prefix(1000)) : s2\n\n        let distance = levenshteinDistance(s1Trimmed, s2Trimmed)\n        return 1.0 - Double(distance) / Double(max(s1Trimmed.count, s2Trimmed.count))\n    }\n\n    /// Calculate Levenshtein distance between two strings\n    private static func levenshteinDistance(_ s1: String, _ s2: String) -> Int {\n        let a = Array(s1)\n        let b = Array(s2)\n        var matrix = [[Int]](repeating: [Int](repeating: 0, count: b.count + 1), count: a.count + 1)\n\n        for i in 0...a.count {\n            matrix[i][0] = i\n        }\n        for j in 0...b.count {\n            matrix[0][j] = j\n        }\n\n        for i in 1...a.count {\n            for j in 1...b.count {\n                let cost = a[i-1] == b[j-1] ? 0 : 1\n                matrix[i][j] = min(\n                    matrix[i-1][j] + 1,      // deletion\n                    matrix[i][j-1] + 1,      // insertion\n                    matrix[i-1][j-1] + cost  // substitution\n                )\n            }\n        }\n\n        return matrix[a.count][b.count]\n    }\n\n    // MARK: - Code Block Trimming\n\n    /// Trim code blocks to preserve first and last lines\n    private static func trimCodeBlocks(in text: String) -> String {\n        let pattern = \"```[\\\\s\\\\S]*?```\"\n        guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else {\n            return text\n        }\n\n        let nsString = text as NSString\n        let matches = regex.matches(in: text, options: [], range: NSRange(location: 0, length: nsString.length))\n\n        var result = text\n        // Process matches in reverse to maintain string indices\n        for match in matches.reversed() {\n            let matchRange = match.range\n            let codeBlock = nsString.substring(with: matchRange)\n            let trimmed = trimBlock(codeBlock, keepFirst: codeBlockKeepFirst, keepLast: codeBlockKeepLast)\n\n            let range = Range(matchRange, in: result)!\n            result.replaceSubrange(range, with: trimmed)\n        }\n\n        return result\n    }\n\n    // MARK: - Error Log Trimming\n\n    /// Trim error logs to preserve first and last lines\n    private static func trimErrorLogs(in text: String) -> String {\n        let lines = text.components(separatedBy: .newlines)\n\n        var i = 0\n        var result: [String] = []\n\n        while i < lines.count {\n            // Check if this starts an error block\n            let errorBlockStart = detectErrorBlock(lines: lines, startIndex: i)\n\n            if let blockLength = errorBlockStart, blockLength >= errorLogMinLines {\n                // Found error block, trim it\n                let blockEnd = i + blockLength\n                let blockLines = Array(lines[i..<min(blockEnd, lines.count)])\n                let trimmed = trimBlock(blockLines.joined(separator: \"\\n\"), keepFirst: errorLogKeepFirst, keepLast: errorLogKeepLast)\n                result.append(trimmed)\n                i = blockEnd\n            } else {\n                // Regular line, keep as is\n                result.append(lines[i])\n                i += 1\n            }\n        }\n\n        return result.joined(separator: \"\\n\")\n    }\n\n    /// Detect if lines starting at index form an error block\n    private static func detectErrorBlock(lines: [String], startIndex: Int) -> Int? {\n        var count = 0\n        var consecutiveMatches = 0\n\n        for i in startIndex..<lines.count {\n            let line = lines[i]\n            let lineRange = NSRange(location: 0, length: line.utf16.count)\n\n            // Check if line matches any error pattern (using precompiled regexes)\n            let matches = errorPatterns.contains { regex in\n                regex.firstMatch(in: line, options: [], range: lineRange) != nil\n            }\n\n            if matches {\n                consecutiveMatches += 1\n                count += 1\n            } else if consecutiveMatches >= 3 {\n                // Allow a few non-matching lines within error block\n                count += 1\n                if count - consecutiveMatches > 2 {\n                    break\n                }\n            } else {\n                break\n            }\n        }\n\n        return consecutiveMatches >= 3 ? count : nil\n    }\n\n    // MARK: - Generic Block Trimming\n\n    /// Trim a block of text to keep first N and last M lines\n    private static func trimBlock(_ block: String, keepFirst: Int, keepLast: Int) -> String {\n        let lines = block.components(separatedBy: .newlines)\n\n        guard lines.count > keepFirst + keepLast + 3 else {\n            return block // Too short to trim\n        }\n\n        let firstLines = lines.prefix(keepFirst)\n        let lastLines = lines.suffix(keepLast)\n        let omittedCount = lines.count - keepFirst - keepLast\n\n        return ([\n            firstLines.joined(separator: \"\\n\"),\n            omissionMarker(count: omittedCount, unit: \"lines\"),\n            lastLines.joined(separator: \"\\n\")\n        ]).joined(separator: \"\\n\")\n    }\n\n    // MARK: - Helpers\n\n    /// Create an omission marker with count and unit\n    private static func omissionMarker(count: Int, unit: String) -> String {\n        return \"... (\\(count) \\(unit) omitted) ...\"\n    }\n\n    /// Calculate total length of messages including separators\n    private static func calculateMessagesLength(_ messages: [String], separator: String = messageSeparator) -> Int {\n        let textLength = messages.map { $0.count }.reduce(0, +)\n        let separatorsLength = max(0, messages.count - 1) * separator.count\n        return textLength + separatorsLength\n    }\n\n    // MARK: - Middle Message Truncation\n\n    /// Remove messages from the middle to fit maxLength, preserving first and last messages\n    /// - Parameters:\n    ///   - messages: Array of processed user messages\n    ///   - maxLength: Maximum total character count\n    /// - Returns: Array of messages that fit within maxLength, maintaining original order\n    private static func truncateMiddleMessages(_ messages: [String], maxLength: Int) -> [String] {\n        guard messages.count > 2 else { return messages }\n        guard calculateMessagesLength(messages) > maxLength else { return messages }\n\n        // Protect first and last messages\n        let firstMsg = messages.first!\n        let lastMsg = messages.last!\n        let middleMessages = Array(messages.dropFirst().dropLast())\n\n        // Find a continuous range in the middle to remove\n        // Strategy: expand removal range from center until we fit\n        let middleCount = middleMessages.count\n        let centerIndex = middleCount / 2\n\n        // Try removing progressively larger ranges centered around the middle\n        for removalCount in 1...middleCount {\n            // Calculate removal range centered at centerIndex\n            let halfRemoval = removalCount / 2\n            let removeStart = max(0, centerIndex - halfRemoval)\n            let removeEnd = min(middleCount, removeStart + removalCount)\n\n            // Build result with this removal range\n            let beforeRemoval = Array(middleMessages[0..<removeStart])\n            let afterRemoval = Array(middleMessages[removeEnd..<middleCount])\n\n            let testMessages = [firstMsg] + beforeRemoval + afterRemoval + [lastMsg]\n            let marker = omissionMarker(count: removalCount, unit: \"messages\")\n            let markerLength = marker.count + messageSeparator.count * 2\n\n            if calculateMessagesLength(testMessages) + markerLength <= maxLength {\n                // This works! Build the final result\n                var result = [firstMsg]\n                result.append(contentsOf: beforeRemoval)\n\n                if !beforeRemoval.isEmpty && !afterRemoval.isEmpty {\n                    result.append(marker)\n                }\n\n                result.append(contentsOf: afterRemoval)\n                result.append(lastMsg)\n                return result\n            }\n        }\n\n        // If even removing all middle messages doesn't fit, return first and last only\n        return [firstMsg, omissionMarker(count: middleCount, unit: \"messages\"), lastMsg]\n    }\n}\n"
  },
  {
    "path": "utils/ShellCommandRunner.swift",
    "content": "import Foundation\n\nstruct ShellCommandResult {\n    let stdout: String\n    let stderr: String\n    let exitCode: Int32\n}\n\nenum ShellCommandError: Error {\n    case commandFailed(executable: String, arguments: [String], stderr: String, exitCode: Int32)\n}\n\nstruct ShellCommandRunner {\n    private static func escapedArgument(_ argument: String) -> String {\n        guard !argument.isEmpty else { return \"''\" }\n        let specialCharacters = CharacterSet.whitespacesAndNewlines\n            .union(CharacterSet(charactersIn: \"\\\"'\\\\$`\"))\n        if argument.rangeOfCharacter(from: specialCharacters) == nil {\n            return argument\n        }\n        let escaped = argument.replacingOccurrences(of: \"'\", with: \"'\\\"'\\\"'\")\n        return \"'\\(escaped)'\"\n    }\n\n    private static func describeCommand(executable: String, arguments: [String]) -> String {\n        let parts = [executable] + arguments\n        return parts.map { escapedArgument($0) }.joined(separator: \" \")\n    }\n\n    @discardableResult\n    static func run(\n        executable: String,\n        arguments: [String],\n        currentDirectory: URL? = nil,\n        environment: [String: String]? = nil\n    ) throws -> ShellCommandResult {\n        let process = Process()\n        process.executableURL = URL(fileURLWithPath: executable)\n        process.arguments = arguments\n        process.currentDirectoryURL = currentDirectory\n\n        if let environment {\n            var env = ProcessInfo.processInfo.environment\n            for (key, value) in environment {\n                env[key] = value\n            }\n            process.environment = env\n        }\n\n        let commandDescription = describeCommand(executable: executable, arguments: arguments)\n        print(\"[ShellCommandRunner] Running command: \\(commandDescription)\")\n        if let currentDirectory {\n            print(\"[ShellCommandRunner]   cwd: \\(currentDirectory.path)\")\n        }\n        if let environment, !environment.isEmpty {\n            let envDescription = environment.map { \"\\($0.key)=\\($0.value)\" }.joined(separator: \", \")\n            print(\"[ShellCommandRunner]   env overrides: \\(envDescription)\")\n        }\n\n        let stdoutPipe = Pipe()\n        let stderrPipe = Pipe()\n        process.standardOutput = stdoutPipe\n        process.standardError = stderrPipe\n\n        try process.run()\n        process.waitUntilExit()\n\n        let stdoutData = stdoutPipe.fileHandleForReading.readDataToEndOfFile()\n        let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()\n        let stdout = String(data: stdoutData, encoding: .utf8) ?? \"\"\n        let stderr = String(data: stderrData, encoding: .utf8) ?? \"\"\n        let exitCode = process.terminationStatus\n\n        if exitCode != 0 {\n            throw ShellCommandError.commandFailed(\n                executable: executable,\n                arguments: arguments,\n                stderr: stderr,\n                exitCode: exitCode\n            )\n        }\n\n        return ShellCommandResult(stdout: stdout, stderr: stderr, exitCode: exitCode)\n    }\n}\n"
  },
  {
    "path": "utils/TagView.swift",
    "content": "import SwiftUI\n\n/// A reusable tag/chip component that supports closing, enabling/disabling, and custom styling.\nstruct TagView: View {\n    let text: String\n    var isEnabled: Bool = true\n    var isClosable: Bool = true\n    var isRemovable: Bool = true\n    var onClose: (() -> Void)? = nil\n    var onToggle: ((Bool) -> Void)? = nil\n\n    @State private var isHovered = false\n\n    var body: some View {\n        HStack(spacing: 4) {\n            // Tag text (clickable to toggle if onToggle is provided)\n            Text(text)\n                .font(.caption)\n                .foregroundStyle(isEnabled ? .primary : .secondary)\n                .monospaced()\n                .lineLimit(1)\n                .contentShape(Rectangle())\n                .onTapGesture {\n                    if let onToggle = onToggle {\n                        onToggle(!isEnabled)\n                    }\n                }\n\n            // Close button (if closable and removable)\n            if isClosable && isRemovable, let onClose = onClose {\n                Button {\n                    onClose()\n                } label: {\n                    Image(systemName: \"xmark.circle.fill\")\n                        .font(.system(size: 12))\n                        .foregroundStyle(isEnabled ? .secondary : .tertiary)\n                        .opacity(isHovered ? 1.0 : 0.2)\n                }\n                .buttonStyle(.plain)\n                .help(\"Remove\")\n            }\n        }\n        .padding(.horizontal, 8)\n        .padding(.vertical, 4)\n        .background(backgroundColor)\n        .clipShape(RoundedRectangle(cornerRadius: 6))\n        .overlay(\n            RoundedRectangle(cornerRadius: 6)\n                .stroke(borderColor, lineWidth: isHovered ? 1 : 0)\n        )\n        .onHover { hovering in\n            isHovered = hovering\n        }\n    }\n\n    private var backgroundColor: Color {\n        guard isEnabled else {\n            return Color.secondary.opacity(0.08)\n        }\n        return Color.accentColor.opacity(isHovered ? 0.15 : 0.12)\n    }\n\n    private var borderColor: Color {\n        Color.accentColor.opacity(0.3)\n    }\n}\n\n/// A container view for displaying multiple tags in a flow layout.\nstruct TagsView: View {\n    let tags: [TagItem]\n    var spacing: CGFloat = 6\n    var alignment: HorizontalAlignment = .leading\n\n    var body: some View {\n        FlowLayout(spacing: spacing, alignment: alignment) {\n            ForEach(tags.indices, id: \\.self) { index in\n                TagView(\n                    text: tags[index].text,\n                    isEnabled: tags[index].isEnabled,\n                    isClosable: tags[index].isClosable,\n                    isRemovable: tags[index].isRemovable,\n                    onClose: tags[index].onClose,\n                    onToggle: tags[index].onToggle\n                )\n            }\n        }\n    }\n}\n\n/// Data model for a tag item.\nstruct TagItem: Identifiable {\n    let id: String\n    let text: String\n    var isEnabled: Bool = true\n    var isClosable: Bool = true\n    var isRemovable: Bool = true\n    var onClose: (() -> Void)? = nil\n    var onToggle: ((Bool) -> Void)? = nil\n\n    init(\n        id: String? = nil,\n        text: String,\n        isEnabled: Bool = true,\n        isClosable: Bool = true,\n        isRemovable: Bool = true,\n        onClose: (() -> Void)? = nil,\n        onToggle: ((Bool) -> Void)? = nil\n    ) {\n        self.id = id ?? text\n        self.text = text\n        self.isEnabled = isEnabled\n        self.isClosable = isClosable\n        self.isRemovable = isRemovable\n        self.onClose = onClose\n        self.onToggle = onToggle\n    }\n}\n\n/// A simple flow layout that wraps items to multiple lines.\nstruct FlowLayout: Layout {\n    var spacing: CGFloat = 6\n    var alignment: HorizontalAlignment = .leading\n\n    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {\n        let maxWidth = proposal.width ?? 10000  // Use a large default if unspecified\n        let result = FlowResult(\n            in: maxWidth,\n            subviews: subviews,\n            spacing: spacing\n        )\n        return result.size\n    }\n\n    func placeSubviews(\n        in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()\n    ) {\n        let result = FlowResult(\n            in: bounds.width,\n            subviews: subviews,\n            spacing: spacing\n        )\n        for (index, subview) in subviews.enumerated() {\n            subview.place(\n                at: CGPoint(\n                    x: bounds.minX + result.frames[index].minX,\n                    y: bounds.minY + result.frames[index].minY),\n                proposal: .unspecified)\n        }\n    }\n\n    struct FlowResult {\n        var size: CGSize = .zero\n        var frames: [CGRect] = []\n\n        init(in maxWidth: CGFloat, subviews: Subviews, spacing: CGFloat) {\n            var currentX: CGFloat = 0\n            var currentY: CGFloat = 0\n            var lineHeight: CGFloat = 0\n\n            for (_, subview) in subviews.enumerated() {\n                let size = subview.sizeThatFits(.unspecified)\n\n                if currentX + size.width > maxWidth && currentX > 0 {\n                    // Start a new line\n                    currentY += lineHeight + spacing\n                    currentX = 0\n                    lineHeight = 0\n                }\n\n                frames.append(\n                    CGRect(x: currentX, y: currentY, width: size.width, height: size.height))\n                lineHeight = max(lineHeight, size.height)\n                currentX += size.width + spacing\n            }\n\n            self.size = CGSize(\n                width: maxWidth,\n                height: currentY + lineHeight\n            )\n        }\n    }\n}\n"
  },
  {
    "path": "utils/TerminalFontResolver.swift",
    "content": "import AppKit\n\nenum TerminalFontResolver {\n    // Candidate order: prefer CJK-capable monospace fonts before system defaults\n    private static let preferredMonoCandidates = [\n        \"Sarasa Mono SC\", \"Sarasa Term SC\",\n        \"LXGW WenKai Mono\",\n        \"Noto Sans Mono CJK SC\", \"NotoSansMonoCJKsc-Regular\",\n        \"JetBrains Mono\", \"JetBrainsMono-Regular\", \"JetBrains Mono NL\",\n        \"JetBrainsMonoNL Nerd Font Mono\", \"JetBrainsMono Nerd Font Mono\",\n        \"SF Mono\", \"Menlo\",\n    ]\n\n    static func resolvedFont(name: String, size: CGFloat) -> NSFont {\n        let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmed.isEmpty, let explicit = NSFont(name: trimmed, size: size) {\n            return explicit\n        }\n        for candidate in preferredMonoCandidates {\n            if let font = NSFont(name: candidate, size: size) {\n                return font\n            }\n        }\n        return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)\n    }\n}\n"
  },
  {
    "path": "utils/TimelineEventClassifier.swift",
    "content": "import Foundation\n\nstruct ClassifiedTimelineEvent {\n    let kind: MessageVisibilityKind\n    let callID: String?\n    let isToolLike: Bool\n}\n\nstruct TimelineEventClassifier {\n    private static let skippedEventTypes: Set<String> = [\n        \"reasoning_output\"\n    ]\n\n    static func classify(row: SessionRow) -> ClassifiedTimelineEvent? {\n        switch row.kind {\n        case .sessionMeta:\n            return nil\n        case .assistantMessage:\n            // Assistant message rows are duplicates of response_item message entries.\n            return nil\n        case .turnContext:\n            // Turn context is surfaced elsewhere and not part of the timeline list.\n            return nil\n        case let .eventMessage(payload):\n            return classify(eventMessage: payload)\n        case let .responseItem(payload):\n            return classify(responseItem: payload)\n        case .unknown:\n            return nil\n        }\n    }\n\n    private static func classify(eventMessage payload: EventMessagePayload) -> ClassifiedTimelineEvent? {\n        let type = payload.type.lowercased()\n\n        if type == \"turn_boundary\" { return nil }\n        if skippedEventTypes.contains(type) { return nil }\n        if type == \"turn_aborted\" || type == \"turn aborted\" || type == \"compaction\" || type == \"compacted\" {\n            return nil\n        }\n        if type == \"ghost_snapshot\" || type == \"ghost snapshot\" { return nil }\n        if type == \"environment_context\" { return nil }\n\n        let rawMessage = payload.message ?? payload.text ?? payload.reason ?? \"\"\n        let message = cleanedAssistantText(rawMessage)\n        let hasImages = payload.images?.contains(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) ?? false\n        guard !message.isEmpty || hasImages else { return nil }\n\n        if type == \"token_count\" {\n            return ClassifiedTimelineEvent(kind: .tokenUsage, callID: nil, isToolLike: false)\n        }\n        if type == \"agent_reasoning\" {\n            return ClassifiedTimelineEvent(kind: .reasoning, callID: nil, isToolLike: false)\n        }\n\n        let mappedKind = MessageVisibilityKind.mappedKind(\n            rawType: payload.type,\n            title: payload.kind ?? payload.type,\n            metadata: nil\n        )\n        let effectiveKind: MessageVisibilityKind? = {\n            guard mappedKind == .tool else { return mappedKind }\n            if containsCodeEditMarkers(message) || containsStrongEditOutputMarkers(message) {\n                return .codeEdit\n            }\n            return mappedKind\n        }()\n\n        switch type {\n        case \"user_message\":\n            return ClassifiedTimelineEvent(kind: effectiveKind ?? .user, callID: nil, isToolLike: false)\n        case \"agent_message\":\n            return ClassifiedTimelineEvent(kind: effectiveKind ?? .assistant, callID: nil, isToolLike: false)\n        default:\n            let resolved = effectiveKind ?? .infoOther\n            return ClassifiedTimelineEvent(kind: resolved, callID: nil, isToolLike: isToolLike(resolved))\n        }\n    }\n\n    private static func classify(responseItem payload: ResponseItemPayload) -> ClassifiedTimelineEvent? {\n        let type = payload.type.lowercased()\n        if skippedEventTypes.contains(type) { return nil }\n        if type == \"ghost_snapshot\" || type == \"ghost snapshot\" { return nil }\n\n        if type == \"reasoning\",\n           payload.summary?.isEmpty == false,\n           payload.content?.isEmpty != false\n        {\n            // Skip summary-only duplicate reasoning events.\n            return nil\n        }\n\n        if type == \"message\" {\n            let role = payload.role?.lowercased()\n            if role == \"user\" {\n                // User content is converted into environment context and not shown in timeline.\n                return nil\n            }\n            let text = cleanedAssistantText(joinedText(from: payload.content ?? []))\n            guard !text.isEmpty else { return nil }\n            return ClassifiedTimelineEvent(kind: .assistant, callID: nil, isToolLike: false)\n        }\n\n        let mappedKind = MessageVisibilityKind.mappedKind(\n            rawType: payload.type,\n            title: payload.type,\n            metadata: nil\n        )\n        let detectionText = responseDetectionText(payload: payload)\n        guard !detectionText.isEmpty else { return nil }\n        let resolvedKind: MessageVisibilityKind? = {\n            guard mappedKind == .tool else { return mappedKind }\n            if isCodeEdit(payload: payload, fallbackText: detectionText) { return .codeEdit }\n            return mappedKind\n        }()\n        let finalKind = resolvedKind ?? .infoOther\n        let isTool = isToolLike(finalKind)\n        return ClassifiedTimelineEvent(kind: finalKind, callID: payload.callID, isToolLike: isTool)\n    }\n\n    private static func responseDetectionText(payload: ResponseItemPayload) -> String {\n        let contentText = cleanedAssistantText(joinedText(from: payload.content ?? []))\n        if !contentText.isEmpty { return contentText }\n        let summaryText = cleanedAssistantText(joinedSummary(from: payload.summary ?? []))\n        if !summaryText.isEmpty { return summaryText }\n        let fallbackText = responseFallbackText(payload)\n        if !fallbackText.isEmpty { return fallbackText }\n        if let output = stringValue(payload.output), !output.isEmpty { return output }\n        return \"\"\n    }\n\n    private static func cleanedText(_ text: String) -> String {\n        guard !text.isEmpty else { return text }\n        return text\n            .replacingOccurrences(of: \"<user_instructions>\", with: \"\")\n            .replacingOccurrences(of: \"</user_instructions>\", with: \"\")\n            .trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private static func cleanedAssistantText(_ text: String) -> String {\n        let base = cleanedText(text)\n        return stripTaggedBlocks(\n            base,\n            tags: [\n                \"permissions_instructions\",\n                \"permissions instructions\",\n                \"collaboration_mode\",\n                \"collaboration mode\"\n            ]\n        )\n            .trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n\n    private static func stripTaggedBlocks(_ text: String, tags: [String]) -> String {\n        var result = text\n        for tag in tags {\n            result = stripTaggedBlock(result, tag: tag)\n        }\n        return result\n    }\n\n    private static func stripTaggedBlock(_ text: String, tag: String) -> String {\n        let lowerTag = tag.lowercased()\n        let openToken = \"<\\(lowerTag)>\"\n        let closeToken = \"</\\(lowerTag)>\"\n        var output = text\n        while let openRange = output.lowercased().range(of: openToken) {\n            if let closeRange = output.lowercased().range(\n                of: closeToken,\n                range: openRange.upperBound..<output.endIndex\n            ) {\n                output.removeSubrange(openRange.lowerBound..<closeRange.upperBound)\n            } else {\n                output.removeSubrange(openRange.lowerBound..<output.endIndex)\n                break\n            }\n        }\n        return output\n    }\n\n    private static func joinedText(from blocks: [ResponseContentBlock]) -> String {\n        blocks.compactMap { $0.text }.joined(separator: \"\\n\\n\")\n    }\n\n    private static func joinedSummary(from items: [ResponseSummaryItem]) -> String {\n        items.compactMap { $0.text }.joined(separator: \"\\n\\n\")\n    }\n\n    private static func responseFallbackText(_ payload: ResponseItemPayload) -> String {\n        var lines: [String] = []\n\n        if let name = payload.name, !name.isEmpty {\n            lines.append(\"name: \\(name)\")\n        }\n        if let args = renderValue(payload.arguments), !args.isEmpty {\n            lines.append(formatLabel(\"arguments\", value: args))\n        }\n        if let input = renderValue(payload.input), !input.isEmpty {\n            lines.append(formatLabel(\"input\", value: input))\n        }\n        if let output = renderValue(payload.output), !output.isEmpty {\n            lines.append(formatLabel(\"output\", value: output))\n        }\n        if let ghost = renderValue(payload.ghostCommit), !ghost.isEmpty {\n            lines.append(formatLabel(\"ghost_commit\", value: ghost))\n        }\n        if lines.isEmpty, let callID = payload.callID, !callID.isEmpty {\n            lines.append(\"call_id: \\(callID)\")\n        }\n\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private static func renderValue(_ value: JSONValue?) -> String? {\n        guard let value else { return nil }\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return String(number)\n        case .bool(let flag):\n            return flag ? \"true\" : \"false\"\n        case .null:\n            return nil\n        case .array, .object:\n            let raw = toAny(value)\n            guard JSONSerialization.isValidJSONObject(raw),\n                  let data = try? JSONSerialization.data(withJSONObject: raw, options: [.prettyPrinted, .sortedKeys]),\n                  let text = String(data: data, encoding: .utf8)\n            else { return nil }\n            return text\n        }\n    }\n\n    private static func toAny(_ value: JSONValue) -> Any {\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return number\n        case .bool(let flag):\n            return flag\n        case .array(let array):\n            return array.map(toAny)\n        case .object(let dict):\n            return dict.mapValues(toAny)\n        case .null:\n            return NSNull()\n        }\n    }\n\n    private static func formatLabel(_ label: String, value: String) -> String {\n        value.contains(\"\\n\") ? \"\\(label):\\n\\(value)\" : \"\\(label): \\(value)\"\n    }\n\n    private static func isToolLike(_ kind: MessageVisibilityKind) -> Bool {\n        switch kind {\n        case .tool, .codeEdit:\n            return true\n        default:\n            return false\n        }\n    }\n\n    private static func isCodeEdit(payload: ResponseItemPayload, fallbackText: String) -> Bool {\n        let name = normalizeToolName(payload.name)\n        if codeEditToolNames.contains(name) { return true }\n\n        if containsEditKeys(payload.arguments) || containsEditKeys(payload.input) {\n            return true\n        }\n\n        if name == \"execcommand\" || name == \"bash\" || name == \"runshellcommand\" {\n            let argsText = stringValue(payload.arguments) ?? \"\"\n            if containsCodeEditMarkers(argsText) { return true }\n        }\n\n        if let outputText = stringValue(payload.output),\n           containsStrongEditOutputMarkers(outputText) { return true }\n\n        if containsCodeEditMarkers(fallbackText) { return true }\n\n        return false\n    }\n\n    private static var codeEditToolNames: Set<String> {\n        [\n            \"edit\",\n            \"write\",\n            \"replace\",\n            \"applypatch\",\n            \"patch\",\n            \"createfile\",\n            \"writefile\",\n            \"deletefile\",\n            \"fileedit\",\n            \"filewrite\",\n            \"updatefile\",\n            \"insert\",\n            \"append\",\n            \"move\",\n            \"rename\",\n            \"remove\",\n            \"multiedit\"\n        ]\n    }\n\n    private static func normalizeToolName(_ name: String?) -> String {\n        let raw = name?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? \"\"\n        if raw.isEmpty { return \"\" }\n        return raw\n            .replacingOccurrences(of: \"_\", with: \"\")\n            .replacingOccurrences(of: \"-\", with: \"\")\n            .replacingOccurrences(of: \" \", with: \"\")\n    }\n\n    private static func containsEditKeys(_ value: JSONValue?) -> Bool {\n        guard let value else { return false }\n        switch value {\n        case .object(let dict):\n            let keys = Set(dict.keys.map { $0.lowercased() })\n            let hasPath = keys.contains(\"file_path\") || keys.contains(\"filepath\") || keys.contains(\"path\")\n            let hasOldNew = keys.contains(\"old_string\") || keys.contains(\"new_string\")\n            let hasPatch = keys.contains(\"patch\") || keys.contains(\"diff\")\n            let hasContent = keys.contains(\"content\") || keys.contains(\"new_content\") || keys.contains(\"text\")\n            if hasOldNew || hasPatch { return true }\n            if hasPath && hasContent { return true }\n            return dict.values.contains { containsEditKeys($0) }\n        case .array(let array):\n            return array.contains { containsEditKeys($0) }\n        default:\n            return false\n        }\n    }\n\n    private static func containsCodeEditMarkers(_ text: String) -> Bool {\n        let lowered = text.lowercased()\n        if lowered.contains(\"*** begin patch\") { return true }\n        if lowered.contains(\"*** update file\") { return true }\n        if lowered.contains(\"*** add file\") { return true }\n        if lowered.contains(\"*** delete file\") { return true }\n        if lowered.contains(\"update file:\") { return true }\n        return false\n    }\n\n    private static func containsStrongEditOutputMarkers(_ text: String) -> Bool {\n        let lowered = text.lowercased()\n        if lowered.contains(\"updated the following files\") { return true }\n        if lowered.contains(\"success. updated the following files\") { return true }\n        return false\n    }\n\n    private static func stringValue(_ value: JSONValue?) -> String? {\n        guard let value else { return nil }\n        switch value {\n        case .string(let string):\n            return string\n        case .number(let number):\n            return String(number)\n        case .bool(let flag):\n            return flag ? \"true\" : \"false\"\n        case .object, .array:\n            return nil\n        case .null:\n            return nil\n        }\n    }\n}\n"
  },
  {
    "path": "utils/TokenFormatter.swift",
    "content": "import Foundation\n\nenum TokenFormatter {\n  /// Compact readable string for tokens (uses K/M/B suffixes).\n  static func short(_ value: Int) -> String {\n    let absValue = Double(abs(value))\n    let sign = value < 0 ? \"-\" : \"\"\n\n    switch absValue {\n    case 0..<1_000:\n      return \"\\(value.formatted())\"\n    case 1_000..<1_000_000:\n      return \"\\(sign)\\(format(absValue / 1_000, digits: 1))K\"\n    case 1_000_000..<1_000_000_000:\n      return \"\\(sign)\\(format(absValue / 1_000_000, digits: 2))M\"\n    default:\n      return \"\\(sign)\\(format(absValue / 1_000_000_000, digits: 2))B\"\n    }\n  }\n\n  /// Decimal string with optional K/M suffix used by usage panes.\n  static func string(from value: Int) -> String {\n    let absValue = abs(value)\n    switch absValue {\n    case 1_000_000...:\n      return format(Double(value) / 1_000_000, digits: 1) + \"M\"\n    case 1_000...:\n      return format(Double(value) / 1_000, digits: 1) + \"K\"\n    default:\n      return NumberFormatter.decimalFormatter.string(from: NSNumber(value: value)) ?? \"\\(value)\"\n    }\n  }\n\n  private static func format(_ value: Double, digits: Int) -> String {\n    let formatter = NumberFormatter()\n    formatter.maximumFractionDigits = digits\n    formatter.minimumFractionDigits = 0\n    formatter.numberStyle = .decimal\n    return formatter.string(from: NSNumber(value: value)) ?? \"\\(value)\"\n  }\n}\n"
  },
  {
    "path": "utils/UpdateSupport.swift",
    "content": "import Foundation\n\nstruct Version: Comparable, Sendable {\n  let components: [Int]\n\n  init?(_ raw: String) {\n    let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)\n    if trimmed.isEmpty { return nil }\n    let noPrefix = trimmed.hasPrefix(\"v\") ? String(trimmed.dropFirst()) : trimmed\n    let core = noPrefix.split(separator: \"-\", maxSplits: 1, omittingEmptySubsequences: true).first ?? \"\"\n    var parts = core.split(separator: \".\").compactMap { Int($0) }\n    if parts.isEmpty { return nil }\n    while parts.count > 1, parts.last == 0 {\n      parts.removeLast()\n    }\n    self.components = parts\n  }\n\n  static func < (lhs: Version, rhs: Version) -> Bool {\n    let maxCount = max(lhs.components.count, rhs.components.count)\n    for idx in 0..<maxCount {\n      let l = idx < lhs.components.count ? lhs.components[idx] : 0\n      let r = idx < rhs.components.count ? rhs.components[idx] : 0\n      if l != r { return l < r }\n    }\n    return false\n  }\n}\n\nenum CPUArch: String, Sendable {\n  case arm64\n  case x86_64\n\n  static var current: CPUArch {\n    #if arch(arm64)\n      return .arm64\n    #else\n      return .x86_64\n    #endif\n  }\n}\n\nenum UpdateAssetSelector {\n  static func assetName(for arch: CPUArch) -> String {\n    switch arch {\n    case .arm64: return \"codmate-arm64.dmg\"\n    case .x86_64: return \"codmate-x86_64.dmg\"\n    }\n  }\n}\n"
  },
  {
    "path": "utils/WarpTitlePrompt.swift",
    "content": "import Foundation\n\n#if canImport(AppKit)\nimport AppKit\n\nenum WarpTitlePrompt {\n    static func requestCustomTitle(defaultValue: String) -> String? {\n        let alert = NSAlert()\n        alert.messageText = \"Warp Tab Title\"\n        alert.informativeText = \"Enter a short slug for the new tab (letters, digits, hyphen). Leave blank to use the suggested value.\"\n        alert.alertStyle = .informational\n        alert.addButton(withTitle: \"Confirm\")\n        alert.addButton(withTitle: \"Cancel\")\n\n        let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 360, height: 24))\n        field.stringValue = defaultValue\n        alert.accessoryView = field\n        field.selectText(nil)\n        alert.window.initialFirstResponder = field\n\n        let response = alert.runModal()\n        if response == .alertFirstButtonReturn {\n            return field.stringValue\n        } else {\n            return nil\n        }\n    }\n}\n#else\nenum WarpTitlePrompt {\n    static func requestCustomTitle(defaultValue: String) -> String? { defaultValue }\n}\n#endif\n"
  },
  {
    "path": "utils/WindowConfigurator.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct WindowConfigurator: NSViewRepresentable {\n    let apply: (NSWindow) -> Void\n\n    func makeNSView(context: Context) -> NSView {\n        let view = NSView(frame: .zero)\n        DispatchQueue.main.async {\n            if let window = view.window {\n                apply(window)\n            } else {\n                // Try again on next runloop if window not yet attached\n                DispatchQueue.main.async { [weak view] in\n                    if let w = view?.window { apply(w) }\n                }\n            }\n        }\n        return view\n    }\n\n    func updateNSView(_ nsView: NSView, context: Context) {}\n}\n\n"
  },
  {
    "path": "views/APIKeyProviderIconView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct APIKeyProviderIconView: View {\n  let provider: ProvidersRegistryService.Provider\n  var size: CGFloat = 16\n  var cornerRadius: CGFloat = 4\n  var isSelected: Bool = false\n\n  @Environment(\\.colorScheme) private var colorScheme\n\n  var body: some View {\n    Group {\n      // Priority 1: Custom SF Symbol icon (for user-created providers)\n      if let customIconName = provider.customIcon {\n        Image(systemName: customIconName)\n          .font(.system(size: size * 0.9))\n          .foregroundStyle(isSelected ? Color.accentColor : Color.primary)\n          .frame(width: size, height: size)\n      }\n      // Priority 2: Preset PNG icon from resources\n      else if let image = processedIcon {\n        Image(nsImage: image)\n          .resizable()\n          .interpolation(.high)\n          .aspectRatio(contentMode: .fit)\n          .frame(width: size, height: size)\n          .clipShape(RoundedRectangle(cornerRadius: cornerRadius))\n          .overlay(\n            RoundedRectangle(cornerRadius: cornerRadius)\n              .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)\n          )\n      }\n      // Fallback: Default circle icon\n      else {\n        Image(systemName: isSelected ? \"largecircle.fill.circle\" : \"circle\")\n          .foregroundStyle(Color.accentColor)\n          .frame(width: size, height: size)\n      }\n    }\n    .frame(width: size, height: size, alignment: .center)\n    .id(colorScheme) // Force refresh when colorScheme changes\n  }\n\n  /// Computed property that depends on colorScheme, ensuring real-time theme updates\n  private var processedIcon: NSImage? {\n    // Use unified icon resource library\n    let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL\n    let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL\n    let baseURL = codexBaseURL ?? claudeBaseURL\n    \n    guard let iconName = ProviderIconResource.iconName(\n      forProviderId: provider.id,\n      name: provider.name,\n      baseURL: baseURL\n    ) else { return nil }\n    \n    // Use unified resource processing with theme adaptation\n    // This computed property depends on colorScheme, so SwiftUI will recompute it when theme changes\n    let isDarkMode = colorScheme == .dark\n    return ProviderIconResource.processedImage(\n      named: iconName,\n      size: NSSize(width: size, height: size),\n      isDarkMode: isDarkMode\n    )\n  }\n\n  private func iconNameForProvider(_ provider: ProvidersRegistryService.Provider) -> String? {\n    let codexBaseURL = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL\n    let claudeBaseURL = provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL\n    let baseURL = codexBaseURL ?? claudeBaseURL\n    \n    return ProviderIconResource.iconName(\n      forProviderId: provider.id,\n      name: provider.name,\n      baseURL: baseURL\n    )\n  }\n}\n"
  },
  {
    "path": "views/AboutViews.swift",
    "content": "import AppKit\nimport SwiftUI\n\nstruct OpenSourceLicensesView: View {\n    let repoURL: URL\n    @State private var content: String = \"\"\n    @Environment(\\.dismiss) private var dismiss\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack {\n                Text(\"Open Source Licenses\")\n                    .font(.title3).fontWeight(.semibold)\n                Spacer()\n                Button(\"Done\") { dismiss() }\n                    .keyboardShortcut(.defaultAction)\n            }\n            .padding(.bottom, 4)\n\n            if content.isEmpty {\n                ProgressView()\n                    .task { await loadContent() }\n            } else {\n                ScrollView {\n                    Text(content)\n                        .font(.system(.body, design: .monospaced))\n                        .textSelection(.enabled)\n                        .frame(maxWidth: .infinity, alignment: .topLeading)\n                        .padding(.top, 4)\n                }\n            }\n        }\n        .padding(16)\n        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n    }\n\n    private func candidateLocalURLs() -> [URL] {\n        var urls: [URL] = []\n        if let bundled = Bundle.main.url(forResource: \"THIRD-PARTY-NOTICES\", withExtension: \"md\") {\n            urls.append(bundled)\n        }\n        let cwd = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)\n        urls.append(cwd.appendingPathComponent(\"THIRD-PARTY-NOTICES.md\"))\n        // When running from Xcode/DerivedData, try a few parents\n        let execDir = Bundle.main.bundleURL\n        urls.append(execDir.appendingPathComponent(\"Contents/Resources/THIRD-PARTY-NOTICES.md\"))\n        return urls\n    }\n\n    private func loadContent() async {\n        for url in candidateLocalURLs() {\n            if FileManager.default.fileExists(atPath: url.path),\n                let data = try? Data(contentsOf: url),\n                let text = String(data: data, encoding: .utf8)\n            {\n                await MainActor.run { self.content = text }\n                return\n            }\n        }\n        // Fallback to remote raw file on GitHub if local not found\n        if let remote = URL(\n            string: \"https://raw.githubusercontent.com/loocor/CodMate/main/THIRD-PARTY-NOTICES.md\")\n        {\n            do {\n                let (data, _) = try await URLSession.shared.data(from: remote)\n                if let text = String(data: data, encoding: .utf8) {\n                    await MainActor.run { self.content = text }\n                }\n            } catch {\n                await MainActor.run {\n                    self.content =\n                        \"Unable to load licenses. Please see THIRD-PARTY-NOTICES.md in the repository.\"\n                }\n            }\n        }\n    }\n}\n\nstruct UpdateSection: View {\n    @ObservedObject var viewModel: UpdateViewModel\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            LabeledContent(\"Version\") {\n                Text(versionString)\n            }\n            .frame(maxWidth: .infinity, alignment: .leading)\n\n            if AppDistribution.isAppStore {\n                Text(\"Updates are managed by the App Store.\")\n                    .font(.subheadline)\n                    .foregroundColor(.secondary)\n            } else {\n                Group {\n                    if case .upToDate(let current, _) = viewModel.state {\n                        VStack(alignment: .leading, spacing: 8) {\n                            if let lastCheckedAt = viewModel.lastCheckedAt {\n                                Text(\n                                    \"Up to date (\\(current)), Last checked \\(Self.lastCheckedFormatter.string(from: lastCheckedAt))\"\n                                )\n                                .font(.subheadline)\n                            } else {\n                                Text(\"Up to date (\\(current)).\")\n                                    .font(.subheadline)\n                            }\n                            Button(\"Check Now\") { viewModel.checkNow() }\n                                .controlSize(.small)\n                        }\n                    } else {\n                        content\n                    }\n                }\n            }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .padding(12)\n        .background(\n            RoundedRectangle(cornerRadius: 10, style: .continuous)\n                .fill(Color.gray.opacity(0.06))\n        )\n        .overlay(\n            RoundedRectangle(cornerRadius: 10, style: .continuous)\n                .stroke(Color.gray.opacity(0.15), lineWidth: 1)\n        )\n        .alert(\"Install\", isPresented: $viewModel.showInstallInstructions) {\n            Button(\"OK\", role: .cancel) {}\n        } message: {\n            Text(viewModel.installInstructions)\n        }\n    }\n\n    private var versionString: String {\n        let info = Bundle.main.infoDictionary\n        let version = info?[\"CFBundleShortVersionString\"] as? String ?? \"—\"\n        let build = info?[\"CFBundleVersion\"] as? String ?? \"—\"\n        return \"\\(version) (\\(build))\"\n    }\n\n    private var buildTimestampString: String {\n        guard let executableURL = Bundle.main.executableURL,\n            let attrs = try? FileManager.default.attributesOfItem(atPath: executableURL.path),\n            let date = attrs[.modificationDate] as? Date\n        else { return \"Unavailable\" }\n        return Self.buildDateFormatter.string(from: date)\n    }\n\n    private static let buildDateFormatter: DateFormatter = {\n        let df = DateFormatter()\n        df.dateStyle = .medium\n        df.timeStyle = .medium\n        return df\n    }()\n\n    @ViewBuilder\n    private var content: some View {\n        switch viewModel.state {\n        case .idle:\n            VStack(alignment: .leading, spacing: 4) {\n                Text(\"Check for updates.\")\n                    .font(.subheadline)\n                Button(\"Check Now\") { viewModel.checkNow() }\n                    .controlSize(.small)\n            }\n        case .checking:\n            HStack(spacing: 8) {\n                ProgressView()\n                Text(\"Checking...\")\n                    .font(.subheadline)\n            }\n        case .upToDate(let current, _):\n            Text(\"Up to date (\\(current)).\")\n                .font(.subheadline)\n        case .updateAvailable(let info):\n            VStack(alignment: .leading, spacing: 6) {\n                HStack(alignment: .top) {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"New version available: \\(info.latestVersion)\")\n                            .font(.subheadline)\n                            .fontWeight(.semibold)\n                        Text(info.assetName)\n                            .font(.caption)\n                            .foregroundColor(.secondary)\n                    }\n                    Spacer()\n                    if viewModel.isDownloading {\n                        HStack(spacing: 6) {\n                            ProgressView()\n                            Text(\"Downloading...\")\n                                .font(.subheadline)\n                                .foregroundColor(.secondary)\n                        }\n                    } else {\n                        Button(\"Download & Install\") { viewModel.downloadIfNeeded() }\n                            .controlSize(.small)\n                    }\n                }\n                if let lastError = viewModel.lastError {\n                    Text(\"Download failed: \\(lastError)\")\n                        .font(.caption)\n                        .foregroundColor(.red)\n                }\n            }\n        case .error(let message):\n            HStack {\n                Text(\"Update check failed: \\(message)\")\n                    .font(.subheadline)\n                    .foregroundColor(.red)\n                Spacer()\n                Button(\"Retry\") { viewModel.checkNow() }\n                    .controlSize(.small)\n            }\n        }\n    }\n\n    private static let lastCheckedFormatter: DateFormatter = {\n        let formatter = DateFormatter()\n        formatter.dateStyle = .medium\n        formatter.timeStyle = .medium\n        return formatter\n    }()\n}\n\nstruct AboutSettingsView: View {\n    @ObservedObject var updateViewModel: UpdateViewModel\n    @State private var showLicensesSheet = false\n\n    var body: some View {\n        ScrollView {\n            VStack(alignment: .leading, spacing: 20) {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"About CodMate\")\n                        .font(.title2)\n                        .fontWeight(.bold)\n                    Text(\n                        \"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.\"\n                    )\n                    .font(.subheadline)\n                    .foregroundColor(.secondary)\n                }\n\n                VStack(alignment: .leading, spacing: 12) {\n                    UpdateSection(viewModel: updateViewModel)\n                        .onAppear {\n                            updateViewModel.loadCached()\n                            updateViewModel.checkIfNeeded(trigger: .aboutAuto)\n                        }\n\n                    LabeledContent(\"Repository\") {\n                        Link(repoURL.absoluteString, destination: repoURL)\n                    }\n                    LabeledContent(\"Project URL\") {\n                        Link(projectURL.absoluteString, destination: projectURL)\n                    }\n                    LabeledContent(\"Open Source Licenses\") {\n                        Button(\"View…\") { showLicensesSheet = true }\n                            .buttonStyle(.bordered)\n                    }\n                }\n                .frame(maxWidth: .infinity, alignment: .leading)\n\n                // Discord Community\n                HStack(spacing: 12) {\n                    Image(systemName: \"bubble.left.and.bubble.right.fill\")\n                        .font(.title2)\n                        .foregroundStyle(.blue)\n                        .frame(width: 32)\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"Join our Discord community\")\n                            .font(.headline)\n                            .fontWeight(.semibold)\n                        Text(\"Get help, share feedback, and connect with other users\")\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                        Link(\"Join Discord\", destination: discordURL)\n                            .font(.subheadline)\n                            .fontWeight(.medium)\n                            .padding(.top, 2)\n                    }\n                    Spacer()\n                }\n            }\n            .frame(maxWidth: .infinity, alignment: .topLeading)\n            .padding(.top, 24)\n            .padding(.horizontal, 24)\n            .padding(.bottom, 32)\n        }\n        .sheet(isPresented: $showLicensesSheet) {\n            OpenSourceLicensesView(repoURL: repoURL)\n                .frame(minWidth: 900, minHeight: 520)\n        }\n    }\n\n    private var projectURL: URL { URL(string: \"https://umate.ai/codmate\")! }\n    private var repoURL: URL { URL(string: \"https://github.com/loocor/CodMate\")! }\n    private var discordURL: URL { URL(string: \"https://discord.gg/5AcaTpVCcx\")! }\n}\n"
  },
  {
    "path": "views/AdvancedPathPane.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct AdvancedPathPane: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    @EnvironmentObject private var listViewModel: SessionListViewModel\n    @StateObject private var cliVM = CLIPathVM()\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 18) {\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"Directories\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Projects Directory\", systemImage: \"folder\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Directory where CodMate stores projects data\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(preferences.projectsRoot.path)\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            Button(\"Change…\", action: selectProjectsRoot)\n                                .buttonStyle(.bordered)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Notes Directory\", systemImage: \"text.book.closed\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Where session titles and comments are saved\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(preferences.notesRoot.path)\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            Button(\"Change…\", action: selectNotesRoot)\n                                .buttonStyle(.bordered)\n                        }\n                    }\n                }\n            }\n\n            VStack(alignment: .leading, spacing: 10) {\n                HStack {\n                    Text(\"CLI Command Paths\").font(.headline).fontWeight(.semibold)\n                    Spacer(minLength: 8)\n                    Button {\n                        cliVM.refresh()\n                    } label: {\n                        Label(\"Refresh Auto-Detect\", systemImage: \"arrow.clockwise\")\n                    }\n                    .buttonStyle(.bordered)\n                }\n\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        commandRow(\n                            title: \"Codex Command\",\n                            description: \"Optional override for codex CLI\",\n                            override: $preferences.codexCommandPath,\n                            autoInfo: cliVM.codex,\n                            isDisabled: !preferences.isCLIEnabled(.codex),\n                            onChoose: { selectCommandPath(kind: .codex) }\n                        )\n                        gridDivider\n                        commandRow(\n                            title: \"Claude Command\",\n                            description: \"Optional override for claude CLI\",\n                            override: $preferences.claudeCommandPath,\n                            autoInfo: cliVM.claude,\n                            isDisabled: !preferences.isCLIEnabled(.claude),\n                            onChoose: { selectCommandPath(kind: .claude) }\n                        )\n                        gridDivider\n                        commandRow(\n                            title: \"Gemini Command\",\n                            description: \"Optional override for gemini CLI\",\n                            override: $preferences.geminiCommandPath,\n                            autoInfo: cliVM.gemini,\n                            isDisabled: !preferences.isCLIEnabled(.gemini),\n                            onChoose: { selectCommandPath(kind: .gemini) }\n                        )\n                    }\n                }\n            }\n\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"CLI & PATH\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n                        GridRow {\n                            Text(\"codex\").font(.subheadline)\n                            Text(statusLabel(for: cliVM.codex))\n                                .font(.caption)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"claude\").font(.subheadline)\n                            Text(statusLabel(for: cliVM.claude))\n                                .font(.caption)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"gemini\").font(.subheadline)\n                            Text(statusLabel(for: cliVM.gemini))\n                                .font(.caption)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"PATH\").font(.subheadline)\n                            Text(cliVM.pathEnv)\n                                .font(.caption)\n                                .lineLimit(2)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                    }\n                }\n            }\n        }\n        .task {\n            await Task.yield()\n            cliVM.refresh()\n        }\n    }\n\n    // MARK: - Helpers\n    @ViewBuilder\n    private func commandRow(\n        title: String,\n        description: String,\n        override: Binding<String>,\n        autoInfo: CLIPathVM.CLIInfo,\n        isDisabled: Bool,\n        onChoose: @escaping () -> Void\n    ) -> some View {\n        GridRow {\n            VStack(alignment: .leading, spacing: 0) {\n                Text(title).font(.subheadline).fontWeight(.medium)\n                Text(description)\n                    .font(.caption)\n                    .foregroundColor(.secondary)\n            }\n            VStack(alignment: .leading, spacing: 6) {\n                TextField(placeholderText(for: autoInfo), text: override)\n                    .textFieldStyle(.roundedBorder)\n                if let warning = overrideWarning(for: override.wrappedValue, autoInfo: autoInfo) {\n                    Text(warning)\n                        .font(.caption2)\n                        .foregroundColor(.orange)\n                }\n            }\n            HStack(spacing: 8) {\n                Button(autoInfo.path == nil ? \"Choose…\" : \"Change…\", action: onChoose)\n                    .buttonStyle(.bordered)\n                Button(clearLabel(for: override.wrappedValue, autoInfo: autoInfo)) {\n                    override.wrappedValue = \"\"\n                }\n                .buttonStyle(.bordered)\n                .disabled(override.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n            }\n        }\n        .disabled(isDisabled)\n    }\n\n    private func placeholderText(for info: CLIPathVM.CLIInfo) -> String {\n        if let path = info.path {\n            if let version = info.version, !version.isEmpty {\n                return \"\\(path) (\\(version))\"\n            }\n            return path\n        }\n        return \"Optional override (absolute path)\"\n    }\n\n    private func clearLabel(for value: String, autoInfo: CLIPathVM.CLIInfo) -> String {\n        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmed.isEmpty, autoInfo.path != nil {\n            return \"Reset\"\n        }\n        return \"Clear\"\n    }\n\n    private func statusLabel(for info: CLIPathVM.CLIInfo) -> String {\n        if let version = info.version, !version.isEmpty {\n            return version\n        }\n        return info.path == nil ? \"N/A\" : \"Yes\"\n    }\n\n    private func overrideWarning(for value: String, autoInfo: CLIPathVM.CLIInfo) -> String? {\n        let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return nil }\n        let expanded = expandHomePath(trimmed)\n        if !FileManager.default.isExecutableFile(atPath: expanded) {\n            if autoInfo.path == nil {\n                return \"Override not executable; auto-detect also failed.\"\n            }\n            return \"Override not executable; auto-detect will be used.\"\n        }\n        return nil\n    }\n\n    private func expandHomePath(_ path: String) -> String {\n        if path.hasPrefix(\"~\") {\n            return (path as NSString).expandingTildeInPath\n        }\n        if path.contains(\"$HOME\") {\n            return path.replacingOccurrences(of: \"$HOME\", with: NSHomeDirectory())\n        }\n        return path\n    }\n\n    private func selectProjectsRoot() {\n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.canCreateDirectories = true\n        panel.directoryURL = preferences.projectsRoot\n        panel.message = \"Select the directory where CodMate stores projects data\"\n\n        panel.begin { response in\n            guard response == .OK, let url = panel.url else { return }\n            Task { await listViewModel.updateProjectsRoot(to: url) }\n        }\n    }\n\n    private func selectNotesRoot() {\n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.canCreateDirectories = true\n        panel.directoryURL = preferences.notesRoot\n        panel.message = \"Select the directory where session notes are stored\"\n\n        panel.begin { response in\n            guard response == .OK, let url = panel.url else { return }\n            Task { await listViewModel.updateNotesRoot(to: url) }\n        }\n    }\n\n    private func selectCommandPath(kind: SessionSource.Kind) {\n        let panel = NSOpenPanel()\n        panel.canChooseFiles = true\n        panel.canChooseDirectories = false\n        panel.allowsMultipleSelection = false\n        panel.message = \"Select the \\(kind.cliExecutableName) executable\"\n        panel.prompt = \"Select\"\n        panel.begin { response in\n            guard response == .OK, let url = panel.url else { return }\n            switch kind {\n            case .codex:\n                preferences.codexCommandPath = url.path\n            case .claude:\n                preferences.claudeCommandPath = url.path\n            case .gemini:\n                preferences.geminiCommandPath = url.path\n            }\n        }\n    }\n\n    @ViewBuilder\n    private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n        VStack(alignment: .leading, spacing: 8) {\n            content()\n        }\n        .padding(10)\n        .background(Color(nsColor: .separatorColor).opacity(0.35))\n        .cornerRadius(10)\n    }\n\n    @ViewBuilder\n    private var gridDivider: some View {\n        Divider()\n    }\n}\n"
  },
  {
    "path": "views/AdvancedSettingsView.swift",
    "content": "import SwiftUI\n\nstruct AdvancedSettingsView: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack(alignment: .firstTextBaseline) {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"Advanced Settings\")\n                        .font(.title2)\n                        .fontWeight(.bold)\n                    Text(\"Paths, command resolution, and deeper diagnostics.\")\n                        .font(.subheadline)\n                        .foregroundColor(.secondary)\n                }\n                Spacer(minLength: 8)\n            }\n\n            Group {\n                if #available(macOS 15.0, *) {\n                    TabView {\n                        Tab(\"Path\", systemImage: \"folder\") {\n                            SettingsTabContent {\n                                AdvancedPathPane(preferences: preferences)\n                            }\n                        }\n                        Tab(\"Dialectics\", systemImage: \"doc.text.magnifyingglass\") {\n                            SettingsTabContent {\n                                DialecticsPane(preferences: preferences)\n                            }\n                        }\n                    }\n                } else {\n                    TabView {\n                        SettingsTabContent {\n                            AdvancedPathPane(preferences: preferences)\n                        }\n                        .tabItem { Label(\"Path\", systemImage: \"folder\") }\n                        SettingsTabContent {\n                            DialecticsPane(preferences: preferences)\n                        }\n                        .tabItem { Label(\"Dialectics\", systemImage: \"doc.text.magnifyingglass\") }\n                    }\n                }\n            }\n            .controlSize(.regular)\n            .padding(.bottom, 16)\n        }\n    }\n}\n"
  },
  {
    "path": "views/AttributedTextView.swift",
    "content": "import SwiftUI\nimport AppKit\n\n// High-performance NSTextView wrapper with optional line numbers, wrapping and simple diff/syntax colors.\nstruct AttributedTextView: NSViewRepresentable {\n    final class Coordinator {\n        var lastText: String = \"\"\n        var lastIsDiff: Bool = false\n        var lastWrap: Bool = true\n        var lastFontSize: CGFloat = 12\n        var textStorage = NSTextStorage()\n        var lastSearchQuery: String = \"\"\n    }\n\n    var text: String\n    var isDiff: Bool\n    var wrap: Bool\n    var showLineNumbers: Bool\n    var fontSize: CGFloat = 12\n    var searchQuery: String = \"\"\n    var lineFragmentPaddingOverride: CGFloat? = nil\n\n    func makeCoordinator() -> Coordinator { Coordinator() }\n\n    func makeNSView(context: Context) -> NSScrollView {\n        let scroll = NSScrollView()\n        scroll.hasVerticalScroller = true\n        scroll.hasHorizontalScroller = true\n        scroll.borderType = .noBorder\n        scroll.drawsBackground = false\n\n        let layoutMgr = LineNumberLayoutManager()\n        layoutMgr.showsLineNumbers = showLineNumbers\n        layoutMgr.wrapEnabled = wrap\n        context.coordinator.textStorage.addLayoutManager(layoutMgr)\n        let container = NSTextContainer(size: .zero)\n        container.widthTracksTextView = wrap\n        container.heightTracksTextView = false\n        layoutMgr.addTextContainer(container)\n\n        let tv = NSTextView(frame: .zero, textContainer: container)\n        tv.isEditable = false\n        tv.isSelectable = true\n        tv.isRichText = false\n        tv.usesFindBar = true\n        tv.drawsBackground = false\n        // Use inner lineFragmentPadding as gutter to keep drawing inside container clip\n        let gutterWidth: CGFloat = lineFragmentPaddingOverride ?? (showLineNumbers ? 44 : 6)\n        tv.textContainerInset = NSSize(width: 8, height: 8)\n        tv.textContainer?.lineFragmentPadding = gutterWidth\n        tv.linkTextAttributes = [:]\n        tv.font = preferredFont(size: fontSize)\n        tv.allowsUndo = false\n        tv.isVerticallyResizable = true\n        tv.minSize = NSSize(width: 0, height: 0)\n        tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)\n        tv.autoresizingMask = [.width]\n\n        if !wrap {\n            tv.isHorizontallyResizable = true\n            container.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)\n        } else {\n            tv.isHorizontallyResizable = false\n            // Seed a sensible initial width when wrapping, otherwise container width may be 0\n            let initialW = max(1, scroll.contentSize.width)\n            container.containerSize = NSSize(width: initialW, height: CGFloat.greatestFiniteMagnitude)\n        }\n\n        scroll.documentView = tv\n        layoutMgr.textView = tv\n\n        // Seed content\n        apply(text: text, isDiff: isDiff, wrap: wrap, tv: tv, storage: context.coordinator.textStorage, coordinator: context.coordinator)\n        context.coordinator.lastText = text\n        context.coordinator.lastIsDiff = isDiff\n        context.coordinator.lastWrap = wrap\n        context.coordinator.lastFontSize = fontSize\n        applySearchHighlight(searchQuery, in: tv)\n        context.coordinator.lastSearchQuery = searchQuery\n        return scroll\n    }\n\n    func updateNSView(_ nsView: NSScrollView, context: Context) {\n        guard let tv = nsView.documentView as? NSTextView,\n              let container = tv.textContainer else { return }\n\n        // Update wrapping\n        if context.coordinator.lastWrap != wrap {\n            container.widthTracksTextView = wrap\n            if wrap {\n                tv.isHorizontallyResizable = false\n                // Ensure container follows current view width to lay out lines\n                let w = max(1, tv.enclosingScrollView?.contentSize.width ?? tv.bounds.width)\n                container.containerSize = NSSize(width: w, height: CGFloat.greatestFiniteMagnitude)\n            } else {\n                tv.isHorizontallyResizable = true\n                container.containerSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)\n            }\n            context.coordinator.lastWrap = wrap\n            // Propagate to layout manager\n            if let lm = tv.layoutManager as? LineNumberLayoutManager { lm.wrapEnabled = wrap }\n            // Ensure layout refresh after wrap mode change\n            tv.layoutManager?.ensureLayout(for: container)\n            tv.needsDisplay = true\n        }\n        // While staying in the same wrap mode, keep the container width in sync with the visible width\n        if wrap {\n            let currentW = tv.enclosingScrollView?.contentSize.width ?? tv.bounds.width\n            let cw = container.containerSize.width\n            if abs(currentW - cw) > 0.5 {\n                container.containerSize = NSSize(width: max(1, currentW), height: CGFloat.greatestFiniteMagnitude)\n                // Keep layout in sync with container width changes\n                tv.layoutManager?.ensureLayout(for: container)\n                tv.needsDisplay = true\n            }\n        }\n\n        // Update font if changed\n        if context.coordinator.lastFontSize != fontSize {\n            tv.font = preferredFont(size: fontSize)\n            context.coordinator.lastFontSize = fontSize\n        }\n\n        // Update line number rendering via custom layout manager and inner padding\n        if let lm = tv.layoutManager as? LineNumberLayoutManager {\n            lm.showsLineNumbers = showLineNumbers\n            lm.wrapEnabled = wrap\n        }\n        let gutterWidth2: CGFloat = lineFragmentPaddingOverride ?? (showLineNumbers ? 44 : 6)\n        tv.textContainerInset = NSSize(width: 8, height: 8)\n        tv.textContainer?.lineFragmentPadding = gutterWidth2\n\n        // Update content only when changed to avoid re-layout cost\n        if text != context.coordinator.lastText || isDiff != context.coordinator.lastIsDiff {\n            apply(text: text, isDiff: isDiff, wrap: wrap, tv: tv, storage: context.coordinator.textStorage, coordinator: context.coordinator)\n            context.coordinator.lastText = text\n            context.coordinator.lastIsDiff = isDiff\n            // Re-apply highlight after content changes\n            applySearchHighlight(searchQuery, in: tv)\n            context.coordinator.lastSearchQuery = searchQuery\n        }\n\n        // Update highlight if query changed\n        if searchQuery != context.coordinator.lastSearchQuery {\n            applySearchHighlight(searchQuery, in: tv)\n            context.coordinator.lastSearchQuery = searchQuery\n        }\n    }\n\n    private func preferredFont(size: CGFloat) -> NSFont {\n        let candidates = [\n            \"JetBrains Mono\", \"JetBrainsMono-Regular\", \"JetBrains Mono NL\",\n            \"SF Mono\", \"Menlo\"\n        ]\n        for name in candidates { if let f = NSFont(name: name, size: size) { return f } }\n        return NSFont.monospacedSystemFont(ofSize: size, weight: .regular)\n    }\n\n    private func apply(text: String, isDiff: Bool, wrap: Bool, tv: NSTextView, storage: NSTextStorage, coordinator: Coordinator) {\n        // Build attributed string off-main to keep UI snappy\n        let input = text\n        let font = preferredFont(size: fontSize)\n        DispatchQueue.global(qos: .userInitiated).async {\n            let attr = NSMutableAttributedString(string: input, attributes: [\n                .font: font,\n                .foregroundColor: NSColor.labelColor\n            ])\n            // Precompute newline UTF-16 offsets for fast line-number lookup\n            let ns = input as NSString\n            var nl: [Int] = []\n            nl.reserveCapacity(1024)\n            let len = ns.length\n            if len > 0 {\n                // Use getCharacters buffer for speed\n                let buf = UnsafeMutablePointer<UniChar>.allocate(capacity: len)\n                ns.getCharacters(buf, range: NSRange(location: 0, length: len))\n                for i in 0..<len { if buf[i] == 10 { nl.append(i) } }\n                buf.deallocate()\n            }\n            var diffRightNumbers: [Int?] = []\n            var diffLeftNumbers: [Int?] = []\n            var diffMaxRight: Int = 0\n            var diffMaxLeft: Int = 0\n            if isDiff {\n                DiffStyler.apply(to: attr)\n                // Build mapping from visual lines → right-side (new file) line numbers\n                let full = input as NSString\n                var mapR: [Int?] = []\n                var mapL: [Int?] = []\n                mapR.reserveCapacity(nl.count + 1)\n                mapL.reserveCapacity(nl.count + 1)\n                var currentRight: Int? = nil\n                var currentLeft: Int? = nil\n                var lineIndex = 0\n                full.enumerateSubstrings(in: NSRange(location: 0, length: full.length), options: .byLines) { _, range, _, stop in\n                    defer { lineIndex += 1 }\n                    guard range.length > 0 else { mapR.append(nil); mapL.append(nil); return }\n                    let firstChar = full.substring(with: NSRange(location: range.location, length: 1))\n                    // Detect hunk header: @@ -l,ct +r,ct @@\n                    if DiffStyler_lineStarts(with: \"@@\", in: full, at: range) {\n                        // Parse left/right starts\n                        let lineStr = full.substring(with: range)\n                        currentRight = parseRightStart(fromHunkHeader: lineStr)\n                        currentLeft = parseLeftStart(fromHunkHeader: lineStr)\n                        mapR.append(nil)\n                        mapL.append(nil)\n                        return\n                    }\n                    // Ignore file headers\n                    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) {\n                        mapR.append(nil); mapL.append(nil); return\n                    }\n                    if firstChar == \"+\" && !DiffStyler_lineStarts(with: \"+++\", in: full, at: range) {\n                        if let r0 = currentRight { mapR.append(r0); diffMaxRight = max(diffMaxRight, r0); currentRight = r0 + 1 } else { mapR.append(nil) }\n                        mapL.append(nil)\n                    } else if firstChar == \" \" {\n                        if let r0 = currentRight { mapR.append(r0); diffMaxRight = max(diffMaxRight, r0); currentRight = r0 + 1 } else { mapR.append(nil) }\n                        if let l0 = currentLeft { mapL.append(l0); diffMaxLeft = max(diffMaxLeft, l0); currentLeft = l0 + 1 } else { mapL.append(nil) }\n                    } else if firstChar == \"-\" && !DiffStyler_lineStarts(with: \"---\", in: full, at: range) {\n                        if let l0 = currentLeft { mapL.append(l0); diffMaxLeft = max(diffMaxLeft, l0); currentLeft = l0 + 1 } else { mapL.append(nil) }\n                        mapR.append(nil)\n                    } else {\n                        mapR.append(nil)\n                        mapL.append(nil)\n                    }\n                }\n                diffRightNumbers = mapR\n                diffLeftNumbers = mapL\n            } else {\n                // Light syntax hints for common formats\n                SyntaxStyler.applyLight(to: attr)\n            }\n            DispatchQueue.main.async {\n                storage.setAttributedString(attr)\n                tv.textStorage?.setAttributedString(attr)\n                if let lm = tv.layoutManager as? LineNumberLayoutManager {\n                    lm.newlineOffsets = nl\n                    lm.diffMode = isDiff\n                    lm.diffRightLineNumbers = diffRightNumbers\n                    lm.diffLeftLineNumbers = diffLeftNumbers\n                }\n                // Dynamic gutter width based on maximum line number digits\n                let totalLines = max(1, nl.count + 1)\n                let targetMax = isDiff ? max(1, max(diffMaxRight, diffMaxLeft)) : totalLines\n                let digits = max(2, String(targetMax).count)\n                let sample = String(repeating: \"8\", count: digits) as NSString\n                let numWidth = sample.size(withAttributes: [.font: font]).width\n                let gap: CGFloat = 8 // spacing between numbers and text start\n                let leftPad: CGFloat = 5 // inner left padding inside gutter\n                let minGutter: CGFloat = 36\n                let gutter = max(minGutter, ceil(numWidth + gap + leftPad))\n                tv.textContainer?.lineFragmentPadding = gutter\n                tv.needsDisplay = true\n                tv.setSelectedRange(NSRange(location: 0, length: 0))\n            }\n        }\n    }\n}\n\n// MARK: - Search highlight helpers\nprivate let cmHighlightKey = NSAttributedString.Key(\"cmHighlight\")\n\nprivate func applySearchHighlight(_ query: String, in tv: NSTextView) {\n    guard let storage = tv.textStorage else { return }\n    let str = storage.string as NSString\n    let fullRange = NSRange(location: 0, length: str.length)\n    // Clear previous highlights (only our custom key)\n    storage.enumerateAttribute(cmHighlightKey, in: fullRange) { value, range, _ in\n        if value != nil {\n            storage.removeAttribute(.backgroundColor, range: range)\n            storage.removeAttribute(cmHighlightKey, range: range)\n        }\n    }\n    let q = query.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !q.isEmpty else { return }\n    let options: NSString.CompareOptions = [.caseInsensitive]\n    var searchRange = fullRange\n    let highlight = NSColor.systemYellow.withAlphaComponent(0.35)\n    while searchRange.length > 0 {\n        let r = str.range(of: q, options: options, range: searchRange)\n        if r.location == NSNotFound { break }\n        storage.addAttributes([.backgroundColor: highlight, cmHighlightKey: 1], range: r)\n        let nextLoc = r.location + r.length\n        if nextLoc >= str.length { break }\n        searchRange = NSRange(location: nextLoc, length: str.length - nextLoc)\n    }\n}\n\nprivate enum DiffStyler {\n    static func apply(to s: NSMutableAttributedString) {\n        let full = s.string as NSString\n        full.enumerateSubstrings(in: NSRange(location: 0, length: full.length), options: .byLines) { _, range, _, _ in\n            guard range.length > 0 else { return }\n            let first = full.substring(with: NSRange(location: range.location, length: 1))\n            let bg: NSColor?\n            let fg: NSColor?\n            if first == \"+\" && !lineStarts(with: \"+++\", in: full, at: range) {\n                bg = NSColor.systemGreen.withAlphaComponent(0.12); fg = nil\n            } else if first == \"-\" && !lineStarts(with: \"---\", in: full, at: range) {\n                bg = NSColor.systemRed.withAlphaComponent(0.12); fg = nil\n            } else if lineStarts(with: \"@@\", in: full, at: range) {\n                bg = NSColor.systemBlue.withAlphaComponent(0.08); fg = NSColor.systemBlue\n            } 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) {\n                bg = NSColor.quaternaryLabelColor.withAlphaComponent(0.12); fg = NSColor.secondaryLabelColor\n            } else {\n                bg = nil; fg = nil\n            }\n            var attrs: [NSAttributedString.Key: Any] = [:]\n            if let bg { attrs[.backgroundColor] = bg }\n            if let fg { attrs[.foregroundColor] = fg }\n            if !attrs.isEmpty { s.addAttributes(attrs, range: range) }\n        }\n    }\n    private static func lineStarts(with prefix: String, in str: NSString, at range: NSRange) -> Bool {\n        if str.length >= range.location + prefix.count {\n            return str.substring(with: NSRange(location: range.location, length: prefix.count)) == prefix\n        }\n        return false\n    }\n}\n\nprivate enum SyntaxStyler {\n\n    // Cached regex patterns (compiled once for performance)\n    private static let keywordPattern: NSRegularExpression? = {\n        // Common keywords across multiple languages (combined into one regex)\n        let keywords = [\n            // JavaScript/TypeScript\n            \"function\", \"const\", \"let\", \"var\", \"if\", \"else\", \"return\", \"for\", \"while\",\n            \"import\", \"export\", \"class\", \"extends\", \"async\", \"await\", \"try\", \"catch\",\n            // Swift\n            \"func\", \"struct\", \"enum\", \"protocol\", \"private\", \"public\", \"guard\", \"defer\",\n            // Python\n            \"def\", \"lambda\", \"with\", \"as\", \"pass\", \"yield\", \"raise\", \"except\",\n            // Rust\n            \"fn\", \"impl\", \"trait\", \"mod\", \"use\", \"pub\", \"mut\", \"unsafe\",\n            // Go\n            \"package\", \"type\", \"interface\", \"chan\", \"go\", \"range\",\n            // Common control flow\n            \"switch\", \"case\", \"default\", \"break\", \"continue\",\n            // Common types and values\n            \"int\", \"bool\", \"string\", \"float\", \"void\",\n            \"null\", \"true\", \"false\", \"nil\", \"undefined\",\n            // YAML/TOML specific\n            \"yes\", \"no\", \"on\", \"off\"\n        ]\n        let pattern = \"\\\\b(\" + keywords.joined(separator: \"|\") + \")\\\\b\"\n        return try? NSRegularExpression(pattern: pattern, options: [])\n    }()\n\n    private static let numberPattern: NSRegularExpression? = {\n        try? NSRegularExpression(pattern: \"\\\\b(0x[0-9A-Fa-f]+|\\\\d+\\\\.?\\\\d*)\\\\b\", options: [])\n    }()\n\n    static func applyLight(to s: NSMutableAttributedString) {\n        let str = s.string as NSString\n        let fullString = s.string\n        let fullRange = NSRange(location: 0, length: str.length)\n\n        // Color palette (using system colors for auto Light/Dark adaptation)\n        let keywordColor = NSColor.systemPink\n        let stringColor = NSColor.systemRed\n        let commentColor = NSColor.systemGreen\n        let numberColor = NSColor.systemPurple\n\n        // 1. Strings (all quote types in one pass)\n        highlightStrings(in: s, str: str, color: stringColor)\n\n        // 2. Comments (both // and # in one pass)\n        highlightComments(in: s, fullString: fullString, color: commentColor)\n\n        // 3. Keywords (single regex with all keywords combined)\n        keywordPattern?.enumerateMatches(in: fullString, range: fullRange) { match, _, _ in\n            if let range = match?.range {\n                s.addAttribute(.foregroundColor, value: keywordColor, range: range)\n            }\n        }\n\n        // 4. Numbers (single regex)\n        numberPattern?.enumerateMatches(in: fullString, range: fullRange) { match, _, _ in\n            if let range = match?.range {\n                s.addAttribute(.foregroundColor, value: numberColor, range: range)\n            }\n        }\n    }\n\n    // Highlight strings (all quote types: \", ', `)\n    private static func highlightStrings(in s: NSMutableAttributedString, str: NSString, color: NSColor) {\n        let quotes: [UInt16] = [34, 39, 96] // \", ', `\n\n        for quote in quotes {\n            var idx = 0\n            while idx < str.length {\n                let c = str.character(at: idx)\n                if c == quote {\n                    let start = idx\n                    idx += 1\n                    var escaping = false\n                    while idx < str.length {\n                        let cc = str.character(at: idx)\n                        if cc == 92 { escaping.toggle() } // '\\\\'\n                        else if cc == quote && !escaping { break }\n                        else { escaping = false }\n                        idx += 1\n                    }\n                    let end = min(idx + 1, str.length)\n                    s.addAttribute(.foregroundColor, value: color, range: NSRange(location: start, length: end - start))\n                }\n                idx += 1\n            }\n        }\n    }\n\n    // Highlight comments (//, #, ; for different file formats)\n    private static func highlightComments(in s: NSMutableAttributedString, fullString: String, color: NSColor) {\n        // Support multiple comment styles:\n        // // - C-style (JS, Swift, Rust, Go, etc.)\n        // #  - Shell-style (Python, Ruby, YAML, TOML, ENV)\n        // ;  - INI-style (INI files)\n        let commentStarts = [\"//\", \"#\", \";\"]\n\n        for commentStart in commentStarts {\n            let scanner = Scanner(string: fullString)\n            scanner.charactersToBeSkipped = nil\n            while !scanner.isAtEnd {\n                _ = scanner.scanUpToString(commentStart)\n                if scanner.scanString(commentStart) != nil {\n                    let start = scanner.currentIndex\n                    _ = scanner.scanUpToCharacters(from: .newlines)\n                    let end = scanner.currentIndex\n                    s.addAttribute(.foregroundColor, value: color, range: NSRange(start..<end, in: fullString))\n                }\n            }\n        }\n    }\n}\n\n// Custom layout manager draws line numbers within left inset (no separate ruler).\nfinal class LineNumberLayoutManager: NSLayoutManager {\n    var showsLineNumbers: Bool = true\n    weak var textView: NSTextView?\n    private let numberColor = NSColor.secondaryLabelColor\n    private let deletionNumberColor = NSColor.systemRed\n    var wrapEnabled: Bool = false\n    // UTF-16 offsets of \"\\n\" in the current textStorage string\n    var newlineOffsets: [Int] = []\n    // Diff mode line-number mapping (visual line index → side line numbers)\n    var diffMode: Bool = false\n    var diffRightLineNumbers: [Int?] = []\n    var diffLeftLineNumbers: [Int?] = []\n\n    func lineNumberFor(charIndex idx: Int) -> Int {\n        if newlineOffsets.isEmpty { return 1 }\n        var lo = 0, hi = newlineOffsets.count\n        while lo < hi {\n            let mid = (lo + hi) >> 1\n            if newlineOffsets[mid] < idx { lo = mid + 1 } else { hi = mid }\n        }\n        return lo + 1 // lines start at 1\n    }\n\n    override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {\n        super.drawBackground(forGlyphRange: glyphsToShow, at: origin)\n        guard let textView = textView, let container = textView.textContainer else { return }\n\n        let visibleRect = textView.enclosingScrollView?.contentView.bounds ?? textView.visibleRect\n        if showsLineNumbers {\n            // Draw gutter strictly inside container: [origin.x, origin.x + padding)\n            let padding = textView.textContainer?.lineFragmentPadding ?? 0\n            let gutterRect = NSRect(\n                x: origin.x,\n                y: visibleRect.minY,\n                width: max(0, padding),\n                height: visibleRect.height\n            )\n            (textView.backgroundColor).setFill()\n            NSBezierPath(rect: gutterRect).fill()\n        }\n\n        guard showsLineNumbers else { return }\n        // Convert view rect to container coordinates for querying glyphs\n        let containerRect = NSRect(x: visibleRect.origin.x - origin.x,\n                                   y: visibleRect.origin.y - origin.y,\n                                   width: visibleRect.width,\n                                   height: visibleRect.height)\n        let glyphRange = self.glyphRange(forBoundingRect: containerRect, in: container)\n        var lastDrawnLogicalLine: Int? = nil\n        self.enumerateLineFragments(forGlyphRange: glyphRange) { _, usedRect, _, lineGlyphRange, _ in\n            let y = origin.y + usedRect.minY\n            // Determine logical line index for this visual fragment\n            let charRange = self.characterRange(forGlyphRange: lineGlyphRange, actualGlyphRange: nil)\n            let logicalLine = self.lineNumberFor(charIndex: charRange.location)\n            let idx = logicalLine - 1\n            let rightVal = (self.diffMode && idx >= 0 && idx < self.diffRightLineNumbers.count) ? self.diffRightLineNumbers[idx] : nil\n            let leftVal = (self.diffMode && idx >= 0 && idx < self.diffLeftLineNumbers.count) ? self.diffLeftLineNumbers[idx] : nil\n            let isDeletion = self.diffMode && leftVal != nil && rightVal == nil\n            let drawColor = isDeletion ? self.deletionNumberColor : self.numberColor\n            let attrs: [NSAttributedString.Key: Any] = [\n                .font: textView.font ?? NSFont.monospacedSystemFont(ofSize: 11, weight: .regular),\n                .foregroundColor: drawColor\n            ]\n            let shouldDrawThisFragment: Bool = {\n                if self.wrapEnabled {\n                    // In wrap mode, draw the number for every visual fragment to avoid gaps\n                    return true\n                } else {\n                    // In non-wrap mode, draw once per visible logical line\n                    return lastDrawnLogicalLine != logicalLine\n                }\n            }()\n            let numString: String = {\n                if !shouldDrawThisFragment { return \"\" }\n                if self.diffMode {\n                    if isDeletion, let l = leftVal { return String(l) }\n                    if let r = rightVal { return String(r) }\n                    return \"\"\n                }\n                return String(logicalLine)\n            }()\n            guard !numString.isEmpty else { return }\n            let num = numString as NSString\n            let size = num.size(withAttributes: attrs)\n            let padding = textView.textContainer?.lineFragmentPadding ?? 0\n            let gap: CGFloat = 8 // spacing between numbers and text start\n            let x = origin.x + padding - gap - size.width\n            num.draw(at: NSPoint(x: x, y: y), withAttributes: attrs)\n            lastDrawnLogicalLine = logicalLine\n        }\n    }\n\n    private func isAtLineStart(charIndex: Int) -> Bool {\n        if charIndex == 0 { return true }\n        // Binary search for (charIndex - 1) in newlineOffsets\n        var lo = 0, hi = newlineOffsets.count\n        let target = charIndex - 1\n        while lo < hi {\n            let mid = (lo + hi) >> 1\n            let v = newlineOffsets[mid]\n            if v == target { return true }\n            if v < target { lo = mid + 1 } else { hi = mid }\n        }\n        return false\n    }\n}\n\n// MARK: - Helpers for diff parsing\nprivate func DiffStyler_lineStarts(with prefix: String, in str: NSString, at range: NSRange) -> Bool {\n    if str.length >= range.location + prefix.count {\n        return str.substring(with: NSRange(location: range.location, length: prefix.count)) == prefix\n    }\n    return false\n}\n\nprivate func parseRightStart(fromHunkHeader header: String) -> Int? {\n    // Example: @@ -10,7 +12,9 @@ or @@ -10 +12 @@\n    // Extract the +<num> portion\n    guard let plusRange = header.range(of: \"+\") else { return nil }\n    var digits = \"\"\n    var idx = plusRange.upperBound\n    while idx < header.endIndex {\n        let ch = header[idx]\n        if ch.isNumber { digits.append(ch) } else { break }\n        idx = header.index(after: idx)\n    }\n    return Int(digits)\n}\n\nprivate func parseLeftStart(fromHunkHeader header: String) -> Int? {\n    // Extract the -<num> portion from a hunk header\n    guard let dashRange = header.range(of: \"-\") else { return nil }\n    var digits = \"\"\n    var idx = header.index(after: dashRange.lowerBound)\n    while idx < header.endIndex {\n        let ch = header[idx]\n        if ch.isNumber { digits.append(ch) } else { break }\n        idx = header.index(after: idx)\n    }\n    return Int(digits)\n}\n"
  },
  {
    "path": "views/AutoAssignSheet.swift",
    "content": "import SwiftUI\n\nstruct AutoAssignSheet: View {\n    @EnvironmentObject var viewModel: SessionListViewModel\n    @Binding var isPresented: Bool\n\n    enum Scope: String, CaseIterable, Identifiable {\n        case today = \"Today\"\n        case all = \"All\"\n        case custom = \"Custom\"\n        var id: String { rawValue }\n        var localizedName: String {\n             switch self {\n             case .today: return \"Today\"\n             case .all: return \"All Time\"\n             case .custom: return \"Custom Range\"\n             }\n        }\n    }\n\n    @State private var scope: Scope = .today\n    @State private var startDate: Date = Date()\n    @State private var endDate: Date = Date()\n    @State private var isProcessing = false\n    @State private var progressMessage: String = \"\"\n    @State private var progressValue: Double = 0.0\n    @State private var assignedCount: Int = 0\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 20) {\n            Text(\"Auto Assign to Projects\")\n                .font(.headline)\n\n            VStack(alignment: .center, spacing: 12) {\n                Picker(\"Scope\", selection: $scope) {\n                    ForEach(Scope.allCases) { s in\n                        Text(s.localizedName).tag(s)\n                    }\n                }\n                .pickerStyle(.segmented)\n                .labelsHidden()\n                .fixedSize()\n                .disabled(isProcessing)\n\n                if scope == .custom {\n                    HStack(spacing: 8) {\n                        DatePicker(\"From\", selection: $startDate, displayedComponents: .date)\n                            .labelsHidden()\n                        Text(\"-\")\n                            .foregroundStyle(.secondary)\n                        DatePicker(\"To\", selection: $endDate, displayedComponents: .date)\n                            .labelsHidden()\n                    }\n                    .disabled(isProcessing)\n                }\n                \n                Text(\"Matches sessions to projects based on their working directory.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n            }\n            .frame(maxWidth: .infinity)\n\n            if isProcessing {\n                VStack(alignment: .leading, spacing: 8) {\n                    ProgressView(value: progressValue, total: 1.0)\n                    Text(progressMessage)\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                }\n            }\n\n            Spacer()\n\n            HStack {\n                Button(\"Cancel\") { isPresented = false }\n                    .keyboardShortcut(.cancelAction)\n                    .disabled(isProcessing)\n                \n                Spacer()\n                \n                Button(\"Start Assignment\") {\n                    startAssignment()\n                }\n                .keyboardShortcut(.defaultAction)\n                .disabled(isProcessing)\n            }\n        }\n        .padding()\n        .frame(width: 400, height: scope == .custom ? 260 : 200)\n    }\n\n    private func startAssignment() {\n        isProcessing = true\n        progressValue = 0.0\n        progressMessage = \"Analyzing sessions...\"\n        assignedCount = 0\n        \n        Task {\n            await performAssignment()\n        }\n    }\n    \n    private func performAssignment() async {\n        let vm = self.viewModel\n        \n        // 1. Identify candidates based on scope\n        let candidates = filterCandidates()\n        \n        if candidates.isEmpty {\n            await MainActor.run {\n                progressMessage = \"No unassigned sessions found for this scope.\"\n                progressValue = 1.0\n                isProcessing = false\n            }\n            // Small delay to let user see the message? Or rely on system notification\n             await SystemNotifier.shared.notify(title: \"CodMate\", body: \"No unassigned sessions found.\")\n             isPresented = false\n            return\n        }\n        \n        await MainActor.run {\n            progressMessage = \"Found \\(candidates.count) unassigned sessions. Matching...\"\n        }\n\n        // 2. Match sessions to projects\n        // We can batch this to show progress\n        var assignments: [String: [String]] = [:]\n        let total = Double(candidates.count)\n        var processed = 0\n        \n        for session in candidates {\n            if let bestId = vm.bestMatchingProjectId(for: session) {\n                assignments[bestId, default: []].append(session.id)\n            }\n            \n            processed += 1\n            if processed % 50 == 0 {\n                let current = processed\n                await MainActor.run {\n                    progressValue = Double(current) / total\n                }\n            }\n        }\n\n        guard !assignments.isEmpty else {\n            await MainActor.run {\n                progressMessage = \"No matching projects found.\"\n                progressValue = 1.0\n                isProcessing = false\n            }\n            await SystemNotifier.shared.notify(title: \"CodMate\", body: \"No matching project paths found.\")\n            isPresented = false\n            return\n        }\n\n        // 3. Apply assignments\n        await MainActor.run {\n            progressMessage = \"Assigning sessions...\"\n            progressValue = 1.0 // Matching done\n        }\n        \n        var assignedTotal = 0\n        for (pid, ids) in assignments {\n            assignedTotal += ids.count\n            await vm.assignSessions(to: pid, ids: ids)\n        }\n        \n        await MainActor.run {\n            vm.scheduleApplyFilters()\n            isProcessing = false\n            isPresented = false\n        }\n        \n        await SystemNotifier.shared.notify(\n          title: \"CodMate\",\n          body: \"Auto-assigned \\(assignedTotal) session(s).\"\n        )\n    }\n    \n    private func filterCandidates() -> [SessionSummary] {\n        let vm = self.viewModel\n        let allUnassigned = vm.allSessions.filter { vm.projectIdForSession($0.id) == nil }\n        \n        switch scope {\n        case .all:\n            return allUnassigned\n            \n        case .today:\n            let today = Date()\n            let cal = Calendar.current\n            return allUnassigned.filter { session in\n                let createdMatch = cal.isDate(session.startedAt, inSameDayAs: today)\n                let updatedMatch: Bool\n                if let last = session.lastUpdatedAt {\n                    updatedMatch = cal.isDate(last, inSameDayAs: today)\n                } else {\n                    updatedMatch = false\n                }\n                return createdMatch || updatedMatch\n            }\n            \n        case .custom:\n            let start = Calendar.current.startOfDay(for: startDate)\n            guard let end = Calendar.current.date(byAdding: .day, value: 1, to: Calendar.current.startOfDay(for: endDate)) else {\n                // Calendar operation failed (edge case), fallback to all unassigned\n                return allUnassigned\n            }\n            let range = start..<end\n            \n            return allUnassigned.filter { session in\n                let createdMatch = range.contains(session.startedAt)\n                let updatedMatch: Bool\n                if let last = session.lastUpdatedAt {\n                    updatedMatch = range.contains(last)\n                } else {\n                    updatedMatch = false\n                }\n                return createdMatch || updatedMatch\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/CLIProxyAdvancedPane.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct CLIProxyAdvancedPane: View {\n  @StateObject private var service = CLIProxyService.shared\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      // Conflict warning\n      if let warning = service.conflictWarning {\n        HStack(spacing: 8) {\n          Image(systemName: \"exclamationmark.triangle.fill\")\n            .foregroundColor(.orange)\n          Text(warning)\n            .font(.caption)\n            .foregroundColor(.secondary)\n        }\n        .padding(12)\n        .background(Color.orange.opacity(0.1))\n        .cornerRadius(8)\n      }\n\n      settingsCard {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n          GridRow {\n            VStack(alignment: .leading, spacing: 0) {\n              Label(\"Binary Location\", systemImage: \"app.badge\")\n                .font(.subheadline).fontWeight(.medium)\n              Text(\"CLIProxyAPI binary executable path\")\n                .font(.caption).foregroundColor(.secondary)\n            }\n            Text(service.binaryFilePath)\n              .font(.system(.caption, design: .monospaced))\n              .lineLimit(1)\n              .truncationMode(.middle)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n              .onTapGesture(count: 2) {\n                revealBinaryInFinder()\n              }\n              .help(\"Double-click to reveal in Finder\")\n            HStack(spacing: 8) {\n              if service.isInstalling {\n                ProgressView()\n                  .scaleEffect(0.6)\n                  .frame(width: 14, height: 14)\n                Text(\"Installing\")\n                  .font(.caption)\n                  .foregroundColor(.secondary)\n              } else {\n                Button(actionButtonTitle) {\n                  Task {\n                    if service.binarySource == .homebrew {\n                      try? await service.brewUpgrade()\n                    } else {\n                      try? await service.install()\n                    }\n                  }\n                }\n                .buttonStyle(.borderedProminent)\n                .tint(actionButtonColor)\n              }\n            }\n            .frame(width: 90, alignment: .trailing)\n            .disabled(service.isInstalling)\n          }\n\n          gridDivider\n\n          GridRow {\n            VStack(alignment: .leading, spacing: 0) {\n              Label(\"Config File\", systemImage: \"doc.text\")\n                .font(.subheadline).fontWeight(.medium)\n              Text(\"CLIProxyAPI configuration file\")\n                .font(.caption).foregroundColor(.secondary)\n            }\n            Text(configFilePath)\n              .font(.system(.caption, design: .monospaced))\n              .lineLimit(1)\n              .truncationMode(.middle)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n            Button(\"Reveal\") { revealConfigInFinder() }\n              .buttonStyle(.bordered)\n              .frame(width: 90, alignment: .trailing)\n          }\n\n          gridDivider\n\n          GridRow {\n            VStack(alignment: .leading, spacing: 0) {\n              Label(\"Auth Directory\", systemImage: \"folder\")\n                .font(.subheadline).fontWeight(.medium)\n              Text(\"OAuth credential storage\")\n                .font(.caption).foregroundColor(.secondary)\n            }\n            Text(authDirPath)\n              .font(.system(.caption, design: .monospaced))\n              .lineLimit(1)\n              .truncationMode(.middle)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n            Button(\"Reveal\") { revealAuthDirInFinder() }\n              .buttonStyle(.bordered)\n              .frame(width: 90, alignment: .trailing)\n          }\n\n          gridDivider\n\n          GridRow {\n            VStack(alignment: .leading, spacing: 0) {\n              Label(\"Logs\", systemImage: \"doc.plaintext\")\n                .font(.subheadline).fontWeight(.medium)\n              Text(\"CLIProxyAPI log files directory\")\n                .font(.caption).foregroundColor(.secondary)\n            }\n            Text(logsPath)\n              .font(.system(.caption, design: .monospaced))\n              .lineLimit(1)\n              .truncationMode(.middle)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n            Button(\"Reveal\") { revealLogsInFinder() }\n              .buttonStyle(.bordered)\n              .frame(width: 90, alignment: .trailing)\n          }\n        }\n      }\n    }\n  }\n\n  private var gridDivider: some View {\n    Divider()\n  }\n\n  private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      content()\n    }\n    .padding(12)\n    .background(Color(nsColor: .separatorColor).opacity(0.35))\n    .cornerRadius(10)\n  }\n\n  private var configFilePath: String {\n    let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!\n    let configPath = appSupport.appendingPathComponent(\"CodMate/config.yaml\")\n    return configPath.path\n  }\n\n  private var authDirPath: String {\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    return home.appendingPathComponent(\".codmate/auth\").path\n  }\n\n  private var logsPath: String {\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    return home.appendingPathComponent(\".codmate/auth/logs\").path\n  }\n\n  private func revealConfigInFinder() {\n    let url = URL(fileURLWithPath: configFilePath)\n    NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)\n  }\n\n  private func revealAuthDirInFinder() {\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    let authPath = home.appendingPathComponent(\".codmate/auth\")\n    NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: authPath.path)\n  }\n\n  private func revealLogsInFinder() {\n    let home = FileManager.default.homeDirectoryForCurrentUser\n    let logsPath = home.appendingPathComponent(\".codmate/auth/logs\")\n    NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: logsPath.path)\n  }\n\n  private func revealBinaryInFinder() {\n    let url = URL(fileURLWithPath: service.binaryFilePath)\n    NSWorkspace.shared.selectFile(url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)\n  }\n\n  private var binarySourceDescription: String {\n    switch service.binarySource {\n    case .none:\n      return \"No binary detected\"\n    case .homebrew:\n      return \"Homebrew installation (managed via brew services)\"\n    case .codmate:\n      return \"CodMate built-in installation\"\n    case .other:\n      return \"Other installation (potential conflicts)\"\n    }\n  }\n\n  private var binarySourceLabel: String {\n    switch service.binarySource {\n    case .none:\n      return \"Not Detected\"\n    case .homebrew:\n      return \"Homebrew\"\n    case .codmate:\n      return \"CodMate\"\n    case .other:\n      return \"Other\"\n    }\n  }\n\n  private var binarySourceColor: Color {\n    switch service.binarySource {\n    case .none:\n      return .secondary\n    case .homebrew:\n      return .green\n    case .codmate:\n      return .blue\n    case .other:\n      return .orange\n    }\n  }\n\n  private var actionButtonTitle: String {\n    switch service.binarySource {\n    case .none:\n      return \"Install\"\n    case .homebrew:\n      return service.isBinaryInstalled ? \"Upgrade\" : \"Install\"\n    case .codmate:\n      return service.isBinaryInstalled ? \"Reinstall\" : \"Install\"\n    case .other:\n      return service.isBinaryInstalled ? \"Reinstall\" : \"Install\"\n    }\n  }\n\n  private var actionButtonColor: Color {\n    switch service.binarySource {\n    case .none:\n      return .blue\n    case .homebrew:\n      return .green\n    case .codmate:\n      return service.isBinaryInstalled ? .red : .blue\n    case .other:\n      return service.isBinaryInstalled ? .red : .blue\n    }\n  }\n}\n"
  },
  {
    "path": "views/CalendarMonthView.swift",
    "content": "import SwiftUI\n\nstruct CalendarMonthView: View {\n    let monthStart: Date\n    let counts: [Int: Int]\n    let selectedDays: Set<Date>\n    // When provided, days not in this set will have their count text dimmed\n    // to indicate no sessions for the currently selected project on that day.\n    let enabledDays: Set<Int>?\n    let onSelectDay: (Date) -> Void\n\n    var body: some View {\n        let cal = Calendar.current\n        let weekdaySymbols = cal.shortStandaloneWeekdaySymbols\n        let grid = monthGrid()\n        let spacing: CGFloat = CalendarMonthLayout.columnSpacing\n        let rowCount = grid.count\n        let contentHeight = CalendarMonthLayout.contentHeight(forRowCount: rowCount)\n\n        GeometryReader { geometry in\n            let totalWidth = geometry.size.width\n            let columnWidth = (totalWidth - spacing * 6) / 7\n\n            VStack(spacing: CalendarMonthLayout.sectionSpacing) {\n                weekdayHeader(\n                    weekdaySymbols: weekdaySymbols, columnWidth: columnWidth, spacing: spacing)\n\n                calendarGrid(\n                    grid: grid,\n                    calendar: cal,\n                    columnWidth: columnWidth,\n                    spacing: spacing\n                )\n            }\n            .frame(width: totalWidth, height: contentHeight, alignment: .top)\n        }\n        .frame(height: contentHeight)\n    }\n\n    private func weekdayHeader(weekdaySymbols: [String], columnWidth: CGFloat, spacing: CGFloat)\n        -> some View\n    {\n        HStack(spacing: spacing) {\n            ForEach(weekdaySymbols, id: \\.self) { w in\n                Text(w)\n                    .frame(width: columnWidth)\n                    .foregroundStyle(.secondary)\n                    .font(.caption)\n            }\n        }\n        .frame(height: CalendarMonthLayout.weekdayHeaderHeight)\n    }\n\n    private func calendarGrid(\n        grid: [[Int]], calendar: Calendar, columnWidth: CGFloat, spacing: CGFloat\n    ) -> some View {\n        VStack(spacing: spacing) {\n            ForEach(0..<grid.count, id: \\.self) { row in\n                calendarRow(\n                    days: grid[row],\n                    calendar: calendar,\n                    columnWidth: columnWidth,\n                    spacing: spacing\n                )\n            }\n        }\n    }\n\n    private func calendarRow(\n        days: [Int], calendar: Calendar, columnWidth: CGFloat, spacing: CGFloat\n    ) -> some View {\n        HStack(spacing: spacing) {\n            ForEach(Array(days.enumerated()), id: \\.offset) { _, day in\n                dayCell(day: day, calendar: calendar, columnWidth: columnWidth)\n            }\n        }\n    }\n\n    private func dayCell(day: Int, calendar: Calendar, columnWidth: CGFloat) -> some View {\n        let isSelected = isSelectedDay(day: day, calendar: calendar)\n        let today = calendar.startOfDay(for: Date())\n        let cellDate = calendar.date(bySetting: .day, value: max(day, 1), of: monthStart).map {\n            calendar.startOfDay(for: $0)\n        }\n        let isSelectable = (day > 0) && (cellDate ?? today) <= today\n        return Button {\n            if day > 0, isSelectable, let date = cellDate {\n                onSelectDay(date)\n            }\n        } label: {\n            dayCellContent(day: day, isSelected: isSelected, isDisabled: !isSelectable)\n        }\n        .buttonStyle(.plain)\n        .frame(width: columnWidth, height: 38)\n        .allowsHitTesting(isSelectable && day > 0)\n        .help(\n            day > 0\n                ? helpText(for: day, isSelected: isSelected, isDisabled: !isSelectable) : \"\"\n        )\n    }\n\n    private func dayCellContent(day: Int, isSelected: Bool, isDisabled: Bool) -> some View {\n        ZStack(alignment: .topLeading) {\n            RoundedRectangle(cornerRadius: 6)\n                .fill(day > 0 ? Color.secondary.opacity(isDisabled ? 0.03 : 0.06) : Color.clear)\n\n            if day > 0 {\n                dayNumber(day: day, isDisabled: isDisabled)\n            }\n\n            if day > 0, let count = counts[day], count > 0 {\n                let dimmed: Bool = {\n                    guard let enabledDays else { return false }\n                    return !enabledDays.contains(day)\n                }()\n                sessionCount(count: count, dimmed: dimmed)\n            }\n        }\n        .overlay(\n            RoundedRectangle(cornerRadius: 6)\n                .strokeBorder(Color.accentColor, lineWidth: isSelected ? 2 : 0)\n        )\n        .opacity(isDisabled ? 0.45 : 1)\n    }\n\n    private func dayNumber(day: Int, isDisabled: Bool) -> some View {\n        Text(\"\\(day)\")\n            .font(.caption)\n            .foregroundStyle(.secondary.opacity(isDisabled ? 0.2 : 0.5))\n            .padding(4)\n    }\n\n    private func sessionCount(count: Int, dimmed: Bool) -> some View {\n        VStack {\n            Spacer()\n            HStack {\n                Spacer()\n                Text(\"\\(count)\")\n                    .font(.body.bold())\n                    .foregroundStyle(dimmed ? Color.secondary.opacity(0.5) : Color.primary)\n                    .padding(4)\n            }\n        }\n    }\n\n    private func isSelectedDay(day: Int, calendar: Calendar) -> Bool {\n        guard day > 0 else { return false }\n        let cellDate = calendar.startOfDay(\n            for: calendar.date(bySetting: .day, value: day, of: monthStart)!)\n        for d in selectedDays { if calendar.isDate(d, inSameDayAs: cellDate) { return true } }\n        return false\n    }\n\n    private func helpText(for day: Int, isSelected: Bool, isDisabled: Bool) -> String {\n        if isDisabled {\n            return \"Future days cannot be filtered yet\"\n        }\n        let count = counts[day] ?? 0\n        if isSelected {\n            return \"\\(count) sessions • Click again to clear day filter\"\n        } else {\n            return \"\\(count) sessions • Click to filter by this day\"\n        }\n    }\n\n    private func monthGrid() -> [[Int]] {\n        let cal = Calendar.current\n        let range = cal.range(of: .day, in: .month, for: monthStart) ?? 1..<29\n        let firstWeekdayIndex = cal.component(.weekday, from: monthStart) - cal.firstWeekday\n        let leading = (firstWeekdayIndex + 7) % 7\n        var days = Array(repeating: 0, count: leading) + Array(range)\n        while days.count % 7 != 0 { days.append(0) }\n        return stride(from: 0, to: days.count, by: 7).map { Array(days[$0..<$0 + 7]) }\n    }\n}\n\nprivate enum CalendarMonthLayout {\n    static let dayCellHeight: CGFloat = 38\n    static let columnSpacing: CGFloat = 2\n    static let sectionSpacing: CGFloat = 8\n    static let weekdayHeaderHeight: CGFloat = 18\n\n    static func contentHeight(forRowCount rowCount: Int) -> CGFloat {\n        weekdayHeaderHeight\n            + sectionSpacing\n            + CGFloat(rowCount) * dayCellHeight\n            + CGFloat(max(0, rowCount - 1)) * columnSpacing\n    }\n}\n\n#Preview {\n    let calendar = Calendar.current\n    let monthStart = calendar.date(from: DateComponents(year: 2024, month: 12, day: 1))!\n\n    // Mock data with some days having session counts\n    let mockCounts: [Int: Int] = [\n        3: 2,\n        7: 1,\n        12: 4,\n        15: 1,\n        18: 3,\n        22: 2,\n        25: 1,\n        28: 5,\n    ]\n\n    return CalendarMonthView(\n        monthStart: monthStart,\n        counts: mockCounts,\n        selectedDays: [calendar.date(from: DateComponents(year: 2024, month: 12, day: 15))!],\n        enabledDays: nil\n    ) { selectedDay in\n        print(\"Selected day: \\(selectedDay)\")\n    }\n    .padding()\n    .frame(width: 300)\n}\n"
  },
  {
    "path": "views/ClaudeCodeSettingsView.swift",
    "content": "import SwiftUI\nimport AppKit\nimport Combine\n\nstruct ClaudeCodeSettingsView: View {\n    @ObservedObject var vm: ClaudeCodeVM\n    @ObservedObject var preferences: SessionPreferencesStore\n    @StateObject private var providerCatalog = UnifiedProviderCatalogModel()\n    @State private var providerModels: [String] = []\n    @State private var modelMappingData: ModelMappingData?\n    @State private var lastProviderId: String?\n    @State private var showDisableBlockedAlert = false\n\n    private struct ModelMappingData: Identifiable {\n        let id = UUID()\n        let providerId: String\n        let defaultModel: String?\n        let aliases: [String: String]\n        let models: [String]\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack(alignment: .firstTextBaseline) {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"Claude Code Settings\")\n                        .font(.title2)\n                        .fontWeight(.bold)\n                    Text(\"Configure provider, model aliases, and review launch environment.\")\n                        .font(.subheadline)\n                        .foregroundStyle(.secondary)\n                }\n                Spacer(minLength: 8)\n                Link(destination: URL(string: \"https://docs.claude.com/en/docs/claude-code/settings\")!) {\n                    Label(\"Docs\", systemImage: \"questionmark.circle\").labelStyle(.iconOnly)\n                }\n                .buttonStyle(.plain)\n            }\n\n            GroupBox {\n                HStack(spacing: 12) {\n                    VStack(alignment: .leading, spacing: 2) {\n                        Label(\"Enable Claude Code\", systemImage: \"power\")\n                            .font(.subheadline).fontWeight(.medium)\n                    Text(\"Turning this off hides Claude UI, stops session scans, and makes settings read-only.\")\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    Spacer()\n                    Toggle(\"\", isOn: claudeEnabledBinding)\n                        .labelsHidden()\n                        .toggleStyle(.switch)\n                        .controlSize(.small)\n                }\n                .padding(10)\n            }\n            Group {\n                if #available(macOS 15.0, *) {\n                    TabView {\n                        Tab(\"Provider\", systemImage: \"server.rack\") { SettingsTabContent { providerPane } }\n                        Tab(\"Runtime\", systemImage: \"gearshape.2\") { SettingsTabContent { runtimePane } }\n                        Tab(\"Sessions\", systemImage: \"folder.badge.gearshape\") { SettingsTabContent { sessionsPane } }\n                        Tab(\"Raw Config\", systemImage: \"doc.text\") { SettingsTabContent { rawPane } }\n                    }\n                } else {\n                    TabView {\n                        SettingsTabContent { providerPane }\n                            .tabItem { Label(\"Provider\", systemImage: \"server.rack\") }\n                        SettingsTabContent { runtimePane }\n                            .tabItem { Label(\"Runtime\", systemImage: \"gearshape.2\") }\n                        SettingsTabContent { sessionsPane }\n                            .tabItem { Label(\"Sessions\", systemImage: \"folder.badge.gearshape\") }\n                        SettingsTabContent { rawPane }\n                            .tabItem { Label(\"Raw Config\", systemImage: \"doc.text\") }\n                    }\n                }\n            }\n            .padding(.bottom, 16)\n            .disabled(!preferences.cliClaudeEnabled)\n            .opacity(preferences.cliClaudeEnabled ? 1.0 : 0.6)\n        }\n        .task {\n            await vm.loadAll()\n            await vm.loadProxyDefaults(preferences: preferences)\n            await reloadProxyCatalog()\n        }\n        // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode\n        .onChange(of: preferences.oauthProvidersEnabled) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n        .onChange(of: preferences.apiKeyProvidersEnabled) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n        .onChange(of: CLIProxyService.shared.isRunning) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n        .sheet(item: $modelMappingData) { data in\n            ClaudeModelMappingSheet(\n                availableModels: data.models,\n                defaultModel: data.defaultModel,\n                aliases: data.aliases,\n                providerId: data.providerId,\n                providerCatalog: providerCatalog,\n                onSave: { newDefault, newAliases in\n                    saveModelMappings(providerId: data.providerId, defaultModel: newDefault, aliases: newAliases)\n                },\n                onAutoFill: { selectedDefault in\n                    autoFillMappings(providerId: data.providerId, selectedDefault: selectedDefault)\n                }\n            )\n        }\n        .alert(\"At least one CLI must remain enabled.\", isPresented: $showDisableBlockedAlert) {\n            Button(\"OK\", role: .cancel) {}\n        }\n    }\n\n    private var claudeEnabledBinding: Binding<Bool> {\n        Binding(\n            get: { preferences.cliClaudeEnabled },\n            set: { newValue in\n                if preferences.setCLIEnabled(.claude, enabled: newValue) == false {\n                    showDisableBlockedAlert = true\n                }\n            }\n        )\n    }\n\n    // MARK: - Provider\n    private var providerPane: some View {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Active Provider\", systemImage: \"server.rack\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Use built-in provider or route through CLI Proxy API.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                SimpleProviderPicker(providerId: $preferences.claudeProxyProviderId)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                    .onChange(of: preferences.claudeProxyProviderId) { _ in\n                        normalizeProxySelection()\n                        if preferences.claudeProxyProviderId == nil {\n                            Task { await reloadProxyCatalog(forceRefresh: true) }\n                        }\n                        vm.scheduleApplyProxySelectionDebounced(\n                            providerId: preferences.claudeProxyProviderId,\n                            modelId: preferences.claudeProxyModelId,\n                            preferences: preferences\n                        )\n                    }\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Model List\", systemImage: \"list.bullet\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Pick a default model and map Claude tiers to model IDs.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                SimpleModelPicker(\n                    models: providerModels,\n                    isDisabled: preferences.claudeProxyProviderId == nil\n                        || !providerCatalog.isProviderAvailable(preferences.claudeProxyProviderId),\n                    onEditModels: canEditModelMappings ? { presentModelMappingEditor() } : nil,\n                    editModelsHelp: \"Edit model mappings\",\n                    providerId: preferences.claudeProxyProviderId,\n                    providerCatalog: providerCatalog,\n                    modelId: $preferences.claudeProxyModelId\n                )\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .onChange(of: preferences.claudeProxyModelId) { _ in\n                    vm.scheduleApplyProxySelectionDebounced(\n                        providerId: preferences.claudeProxyProviderId,\n                        modelId: preferences.claudeProxyModelId,\n                        preferences: preferences\n                    )\n                }\n            }\n        }\n    }\n\n    // MARK: - Models / Aliases\n    // modelsPane removed; Provider pane now includes the default model picker like Codex\n\n    private var rawPane: some View {\n        let displayText = vm.rawSettingsText\n\n        return ZStack(alignment: .topTrailing) {\n            ScrollView {\n                Text(displayText.isEmpty ? \"(empty settings.json)\" : displayText)\n                    .font(.system(.caption, design: .monospaced))\n                    .textSelection(.enabled)\n                    .frame(maxWidth: .infinity, alignment: .topLeading)\n            }\n            HStack(spacing: 8) {\n                Button { Task { await vm.reloadRawSettings() } } label: {\n                    Image(systemName: \"arrow.clockwise\")\n                }\n                .help(\"Reload\")\n                .buttonStyle(.borderless)\n                Button { vm.openSettingsInEditor() } label: {\n                    Image(systemName: \"square.and.pencil\")\n                }\n                .help(\"Open in default editor\")\n                .buttonStyle(.borderless)\n            }\n        }\n        .task { await vm.reloadRawSettings() }\n    }\n\n    // MARK: - Sessions\n    private var sessionsPane: some View {\n        SessionsPathPane(preferences: preferences, fixedKind: .claude)\n    }\n\n    private func buildRawConfigText() -> String {\n        // Prefer showing the canonical user settings file in full\n        let settingsURL = SessionPreferencesStore.getRealUserHomeURL()\n            .appendingPathComponent(\".claude\", isDirectory: true)\n            .appendingPathComponent(\"settings.json\")\n\n        if let fileText = try? String(contentsOf: settingsURL, encoding: .utf8) {\n            return fileText\n        }\n\n        // Fallback: build preview from current settings\n        var lines = vm.launchEnvPreview()\n\n        // Append launch/runtime flags preview\n        lines.append(\"\\n# Launch flags preview\")\n        lines.append(\"permission-mode=\\(preferences.claudePermissionMode.rawValue)\")\n        lines.append(\"sandbox=\\(preferences.defaultResumeSandboxMode.rawValue)\")\n        lines.append(\"approvals=\\(preferences.defaultResumeApprovalPolicy.rawValue)\")\n\n        // Debug info\n        if preferences.claudeDebug {\n            lines.append(\"debug=true filter=\\(preferences.claudeDebugFilter)\")\n        } else {\n            lines.append(\"debug=false\")\n        }\n\n        lines.append(\"verbose=\\(preferences.claudeVerbose ? \"true\" : \"false\")\")\n        lines.append(\"ide=\\(preferences.claudeIDE ? \"true\" : \"false\")\")\n        lines.append(\"strictMCP=\\(preferences.claudeStrictMCP ? \"true\" : \"false\")\")\n\n        // Tools configuration\n        let allowedTools = preferences.claudeAllowedTools.trimmingCharacters(in: .whitespaces)\n        if !allowedTools.isEmpty {\n            lines.append(\"allowed-tools=\\(allowedTools)\")\n        }\n\n        let disallowedTools = preferences.claudeDisallowedTools.trimmingCharacters(in: .whitespaces)\n        if !disallowedTools.isEmpty {\n            lines.append(\"disallowed-tools=\\(disallowedTools)\")\n        }\n\n        let fallbackModel = preferences.claudeFallbackModel.trimmingCharacters(in: .whitespaces)\n        if !fallbackModel.isEmpty {\n            lines.append(\"fallback-model=\\(fallbackModel)\")\n        }\n\n        // Build example command\n        let exampleCommand = buildExampleCommand()\n        lines.append(\"\\n# Example command\")\n        lines.append(exampleCommand)\n\n        return lines.joined(separator: \"\\n\")\n    }\n\n    private func buildExampleCommand() -> String {\n        var example: [String] = [\"claude\"]\n\n        // Permission mode\n        if preferences.claudePermissionMode.rawValue != \"default\" {\n            example.append(\"--permission-mode \\(preferences.claudePermissionMode.rawValue)\")\n        }\n\n        // Debug/Verbose\n        if preferences.claudeDebug {\n            let debugFilter = preferences.claudeDebugFilter.trimmingCharacters(in: .whitespaces)\n            if !debugFilter.isEmpty {\n                example.append(\"--debug \\(debugFilter)\")\n            } else {\n                example.append(\"--debug\")\n            }\n        }\n\n        if preferences.claudeVerbose {\n            example.append(\"--verbose\")\n        }\n\n        // Tools\n        let allowedTools = preferences.claudeAllowedTools.trimmingCharacters(in: .whitespaces)\n        if !allowedTools.isEmpty {\n            example.append(\"--allowed-tools \\\"\\(allowedTools)\\\"\")\n        }\n\n        let disallowedTools = preferences.claudeDisallowedTools.trimmingCharacters(in: .whitespaces)\n        if !disallowedTools.isEmpty {\n            example.append(\"--disallowed-tools \\\"\\(disallowedTools)\\\"\")\n        }\n\n        // IDE\n        if preferences.claudeIDE {\n            example.append(\"--ide\")\n        }\n\n        // Fallback model\n        let fallbackModel = preferences.claudeFallbackModel.trimmingCharacters(in: .whitespaces)\n        if !fallbackModel.isEmpty {\n            example.append(\"--fallback-model \\(fallbackModel)\")\n        }\n\n        return example.joined(separator: \" \")\n    }\n\n    // MARK: - Runtime (Claude-native)\n    private var runtimePane: some View {\n        runtimePaneGrid\n            .onReceive(preferences.objectWillChange) { _ in\n                Task { vm.scheduleApplyRuntimeSettings(preferences) }\n            }\n    }\n\n    private var runtimePaneGrid: some View {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            // Claude-native permission mode\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Permission Mode\", systemImage: \"hand.raised\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Affects edit confirmations and planning.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                Picker(\"\", selection: $preferences.claudePermissionMode) {\n                    ForEach(ClaudePermissionMode.allCases) { Text($0.rawValue).tag($0) }\n                }\n                .labelsHidden()\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            // Dangerous permission skips (explicit)\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Skip Permissions (Dangerous)\", systemImage: \"exclamationmark.triangle\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Bypass permission prompts; use with caution.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"Enable\", isOn: $preferences.claudeSkipPermissions)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Allow Skip Permissions\", systemImage: \"checkmark.shield\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Permit using the dangerous skip flag.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"Enable\", isOn: $preferences.claudeAllowSkipPermissions)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            // Removed: Unsandboxed commands toggle (no official CLI/setting key)\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Debug\", systemImage: \"ladybug\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Enable debug output; optional category filter.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                HStack(spacing: 8) {\n                    Toggle(\"Enable\", isOn: $preferences.claudeDebug)\n                    TextField(\"api,hooks\", text: $preferences.claudeDebugFilter)\n                        .frame(width: 220)\n                }\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Verbose Output\", systemImage: \"text.alignleft\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Override verbose mode from config.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"Enable\", isOn: $preferences.claudeVerbose)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Allowed Tools\", systemImage: \"checkmark.circle\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Comma or space-separated tool names.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                TextField(\"Bash(git:*), Edit\", text: $preferences.claudeAllowedTools)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Disallowed Tools\", systemImage: \"xmark.circle\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Comma or space-separated tool names to block.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                TextField(\"Bash(rm:*), Edit\", text: $preferences.claudeDisallowedTools)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Other\", systemImage: \"ellipsis.circle\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Additional runtime options.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                HStack(spacing: 16) {\n                    Toggle(\"IDE auto-connect\", isOn: $preferences.claudeIDE)\n                    Toggle(\"Strict MCP config\", isOn: $preferences.claudeStrictMCP)\n                }\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Fallback Model\", systemImage: \"arrow.down.circle\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Optional model when default is overloaded (print mode).\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                TextField(\"haiku\", text: $preferences.claudeFallbackModel)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n        }\n    }\n\n    private func reloadProxyCatalog(forceRefresh: Bool = false) async {\n        await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh)\n        normalizeProxySelection()\n    }\n\n    private func normalizeProxySelection() {\n        let normalized = providerCatalog.normalizeProviderId(preferences.claudeProxyProviderId)\n        if normalized != preferences.claudeProxyProviderId {\n            preferences.claudeProxyProviderId = normalized\n        }\n        let providerChanged = lastProviderId != nil && lastProviderId != preferences.claudeProxyProviderId\n        lastProviderId = preferences.claudeProxyProviderId\n        guard let providerId = preferences.claudeProxyProviderId else {\n            providerModels = []\n            preferences.claudeProxyModelId = nil\n            return\n        }\n        providerModels = providerCatalog.models(for: providerId)\n        if providerChanged {\n            preferences.claudeProxyModelId = nil\n            return\n        }\n        guard !providerModels.isEmpty else {\n            return\n        }\n    }\n\n    private var canEditModelMappings: Bool {\n        preferences.claudeProxyProviderId != nil\n    }\n\n    private func presentModelMappingEditor() {\n        guard let providerId = preferences.claudeProxyProviderId else { return }\n        // Use sheet(item:) pattern to ensure fresh data is passed each time\n        modelMappingData = ModelMappingData(\n            providerId: providerId,\n            defaultModel: preferences.claudeProxyModelId,\n            aliases: loadModelAliases(for: providerId),\n            models: providerCatalog.models(for: providerId)\n        )\n    }\n\n    private func loadModelAliases(for providerId: String) -> [String: String] {\n        // First try the exact providerId\n        if let aliases = preferences.claudeProxyModelAliases[providerId], !aliases.isEmpty {\n            return aliases\n        }\n        // For OAuth providers, also try the base providerId (without accountId)\n        let parsed = UnifiedProviderID.parse(providerId)\n        if case .oauth(let provider, _) = parsed {\n            let baseId = UnifiedProviderID.oauth(provider, accountId: nil)\n            if let aliases = preferences.claudeProxyModelAliases[baseId], !aliases.isEmpty {\n                return aliases\n            }\n        }\n        return [:]\n    }\n\n    private func saveModelMappings(providerId: String, defaultModel: String?, aliases: [String: String]) {\n        preferences.claudeProxyModelId = defaultModel\n        var stored = preferences.claudeProxyModelAliases\n        if aliases.isEmpty {\n            stored.removeValue(forKey: providerId)\n        } else {\n            stored[providerId] = aliases\n        }\n        preferences.claudeProxyModelAliases = stored\n        vm.scheduleApplyProxySelectionDebounced(\n            providerId: preferences.claudeProxyProviderId,\n            modelId: preferences.claudeProxyModelId,\n            preferences: preferences\n        )\n    }\n\n    private func autoFillMappings(providerId: String, selectedDefault: String?) -> [String: String] {\n        // Get fresh models from catalog based on the provider used when opening the editor\n        let models = providerCatalog.models(for: providerId)\n        let trimmedDefault = selectedDefault?.trimmingCharacters(in: .whitespacesAndNewlines)\n        let preferred = (trimmedDefault?.isEmpty == false) ? trimmedDefault : selectDefaultModel(from: models)\n        let opus = selectModel(from: models, tokens: [\"opus\"]) ?? preferred\n        let sonnet = selectModel(from: models, tokens: [\"sonnet\"]) ?? preferred\n        let haiku = selectModel(from: models, tokens: [\"haiku\", \"flash\", \"lite\", \"mini\"]) ?? preferred\n        var out: [String: String] = [:]\n        if let preferred { out[\"default\"] = preferred }\n        if let opus { out[\"opus\"] = opus }\n        if let sonnet { out[\"sonnet\"] = sonnet }\n        if let haiku { out[\"haiku\"] = haiku }\n        return out\n    }\n\n    private func selectDefaultModel(from models: [String]) -> String? {\n        if let match = selectModel(from: models, tokens: [\"sonnet\", \"opus\", \"haiku\"]) { return match }\n        if let match = selectModel(from: models, tokens: [\"pro\", \"latest\", \"preview\"]) { return match }\n        return models.first\n    }\n\n    private func selectModel(from models: [String], tokens: [String]) -> String? {\n        guard !models.isEmpty else { return nil }\n\n        // Find all models matching any token, then select the one with highest version\n        var candidates: [String] = []\n        for token in tokens {\n            let matching = models.filter { $0.localizedCaseInsensitiveContains(token) }\n            candidates.append(contentsOf: matching)\n        }\n\n        guard !candidates.isEmpty else { return nil }\n\n        // Use ModelNameSanitizer's version comparison logic to find the highest version\n        // For each token category (opus, sonnet, haiku), select the model with the latest version\n        var bestModel: String? = nil\n        var bestVersion: ModelNameSanitizer.ModelVersion? = nil\n\n        for model in candidates {\n            let (baseName, version) = ModelNameSanitizer.extractModelVersion(model)\n\n            // Check if this model's base name matches any token\n            let matchesToken = tokens.contains { token in\n                baseName.localizedCaseInsensitiveContains(token)\n            }\n\n            if matchesToken {\n                if let existing = bestVersion {\n                    if version.isNewerThan(existing) {\n                        bestVersion = version\n                        bestModel = model\n                    }\n                } else {\n                    bestVersion = version\n                    bestModel = model\n                }\n            }\n        }\n\n        // If no version-based match found, fall back to first match (for models without date suffixes)\n        return bestModel ?? candidates.first\n    }\n\n    // aliasPicker removed\n}\n\nprivate struct SettingsCard<Content: View>: View {\n    let content: () -> Content\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) { content() }\n            .padding(10)\n            .background(Color(nsColor: .separatorColor).opacity(0.35))\n            .cornerRadius(10)\n    }\n}\n\nprivate func settingsCard<Content: View>(@ViewBuilder _ content: @escaping () -> Content) -> some View {\n    SettingsCard(content: content)\n}\n\nprivate var gridDivider: some View { Divider().opacity(0.5) }\n\n// MARK: - Runtime Settings Change Handler\n// Removed complex onChange modifier due to type-checker performance; using a single\n// onReceive(preferences.objectWillChange) above to debounce runtime writes.\n\nextension ClaudeCodeVM {\n    var selectedClaudeBaseURL: String? {\n        guard let id = activeProviderId,\n              let p = providers.first(where: { $0.id == id }) else { return nil }\n        return p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL\n    }\n    var selectedClaudeEnvKey: String? {\n        guard let id = activeProviderId,\n              let p = providers.first(where: { $0.id == id }) else { return nil }\n        return p.envKey ?? p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.envKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n    }\n\n    func launchEnvPreview() -> [String] {\n        var lines: [String] = [\n            \"# Environment variables applied when launching Claude\",\n        ]\n        if let base = selectedClaudeBaseURL, !base.isEmpty {\n            lines.append(\"export ANTHROPIC_BASE_URL=\\(base)\")\n        } else {\n            lines.append(\"# ANTHROPIC_BASE_URL not set (uses tool default)\")\n        }\n        if !(activeProviderId == nil && loginMethod == .subscription) {\n            let key = selectedClaudeEnvKey ?? \"ANTHROPIC_AUTH_TOKEN\"\n            lines.append(\"export ANTHROPIC_AUTH_TOKEN=$\\(key)\")\n        } else {\n            lines.append(\"# Using Claude subscription login; no token env injected\")\n        }\n        // Aliases (only when a third‑party provider is selected)\n        if activeProviderId != nil {\n            if !aliasOpus.trimmingCharacters(in: .whitespaces).isEmpty {\n                lines.append(\"export ANTHROPIC_DEFAULT_OPUS_MODEL=\\(aliasOpus)\")\n            }\n            if !aliasSonnet.trimmingCharacters(in: .whitespaces).isEmpty {\n                lines.append(\"export ANTHROPIC_DEFAULT_SONNET_MODEL=\\(aliasSonnet)\")\n            }\n            if !aliasHaiku.trimmingCharacters(in: .whitespaces).isEmpty {\n                lines.append(\"export ANTHROPIC_DEFAULT_HAIKU_MODEL=\\(aliasHaiku)\")\n            }\n            if !aliasDefault.trimmingCharacters(in: .whitespaces).isEmpty {\n                lines.append(\"export ANTHROPIC_MODEL=\\(aliasDefault)\")\n            }\n            if !aliasHaiku.trimmingCharacters(in: .whitespaces).isEmpty {\n                lines.append(\"export ANTHROPIC_SMALL_FAST_MODEL=\\(aliasHaiku)\")\n            }\n        }\n        return lines\n    }\n}\n"
  },
  {
    "path": "views/ClaudeModelMappingSheet.swift",
    "content": "import SwiftUI\n#if os(macOS)\nimport AppKit\n#endif\n\nstruct ClaudeModelMappingSheet: View {\n  let availableModels: [String]\n  let defaultModel: String?\n  let aliases: [String: String]\n  let providerId: String?\n  let providerCatalog: UnifiedProviderCatalogModel?\n  let onSave: (_ defaultModel: String?, _ aliases: [String: String]) -> Void\n  let onAutoFill: (_ selectedDefault: String?) -> [String: String]\n\n  @State private var draftDefault: String = \"\"\n  @State private var draftAliases: [String: String] = [:]\n  @Environment(\\.dismiss) private var dismiss\n\n  init(\n    availableModels: [String],\n    defaultModel: String?,\n    aliases: [String: String],\n    providerId: String? = nil,\n    providerCatalog: UnifiedProviderCatalogModel? = nil,\n    onSave: @escaping (_ defaultModel: String?, _ aliases: [String: String]) -> Void,\n    onAutoFill: @escaping (_ selectedDefault: String?) -> [String: String]\n  ) {\n    self.availableModels = availableModels\n    self.defaultModel = defaultModel\n    self.aliases = aliases\n    self.providerId = providerId\n    self.providerCatalog = providerCatalog\n    self.onSave = onSave\n    self.onAutoFill = onAutoFill\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 16) {\n      Text(\"Model Mappings\").font(.title2).fontWeight(.semibold)\n      Text(\"Map Claude Code tiers to CLI Proxy model IDs. Defaults apply to Claude Code 2.x; the default model also feeds legacy variables.\")\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      VStack(alignment: .leading, spacing: 12) {\n        mappingRow(\n          title: \"Default\",\n          help: \"Used for ANTHROPIC_MODEL and as the fallback for missing tiers.\",\n          binding: $draftDefault\n        )\n        mappingRow(\n          title: \"Opus\",\n          help: \"ANTHROPIC_DEFAULT_OPUS_MODEL\",\n          binding: aliasBinding(\"opus\")\n        )\n        mappingRow(\n          title: \"Sonnet\",\n          help: \"ANTHROPIC_DEFAULT_SONNET_MODEL\",\n          binding: aliasBinding(\"sonnet\")\n        )\n        mappingRow(\n          title: \"Haiku\",\n          help: \"ANTHROPIC_DEFAULT_HAIKU_MODEL + ANTHROPIC_SMALL_FAST_MODEL\",\n          binding: aliasBinding(\"haiku\")\n        )\n      }\n      .padding(10)\n      .background(Color(nsColor: .separatorColor).opacity(0.35))\n      .cornerRadius(10)\n\n      HStack(spacing: 8) {\n        Button(\"Auto Fill\") {\n          let auto = onAutoFill(normalized(draftDefault))\n          for (key, value) in auto {\n            draftAliases[key] = value\n          }\n          if let autoDefault = auto[\"default\"], draftDefault.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n            draftDefault = autoDefault\n          }\n        }\n        Spacer()\n        Button(\"Cancel\", role: .cancel) { dismiss() }\n        Button(\"Save\") {\n          let cleanedDefault = normalized(draftDefault)\n          let cleanedAliases = sanitizeAliases(draftAliases)\n          onSave(cleanedDefault, cleanedAliases)\n          dismiss()\n        }\n        .buttonStyle(.borderedProminent)\n      }\n    }\n    .padding(16)\n    .frame(minWidth: 560)\n    .onAppear {\n      // Reload data when sheet appears to ensure we have the latest values\n      // This is critical for SwiftUI sheets which capture initial values at creation time\n      // and may not reflect updates that happen after the sheet closure is created\n      draftDefault = defaultModel ?? \"\"\n      draftAliases = aliases\n    }\n  }\n\n  @State private var searchText: [String: String] = [:]\n  @State private var isPopoverPresented: [String: Bool] = [:]\n\n  private func mappingRow(title: String, help: String, binding: Binding<String>) -> some View {\n    VStack(alignment: .leading, spacing: 4) {\n      HStack(spacing: 8) {\n        Text(title)\n          .frame(width: 90, alignment: .leading)\n        TextField(\"model-id\", text: binding)\n        if !availableModels.isEmpty {\n          searchableModelButton(binding: binding, title: title)\n        }\n        Button {\n          binding.wrappedValue = \"\"\n        } label: {\n          Image(systemName: \"xmark.circle\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Clear\")\n      }\n      Text(help)\n        .font(.caption)\n        .foregroundStyle(.secondary)\n        .padding(.leading, 98)\n    }\n  }\n\n  private func searchableModelButton(binding: Binding<String>, title: String) -> some View {\n    let searchKey = title\n    let isPresented = Binding(\n      get: { isPopoverPresented[searchKey] ?? false },\n      set: { isPopoverPresented[searchKey] = $0 }\n    )\n    let searchBinding = Binding(\n      get: { searchText[searchKey] ?? \"\" },\n      set: { searchText[searchKey] = $0 }\n    )\n\n    return Button {\n      isPresented.wrappedValue = true\n    } label: {\n      Image(systemName: \"chevron.down\")\n    }\n    .help(\"Pick from available models\")\n    .popover(isPresented: isPresented, arrowEdge: .bottom) {\n      searchableModelListPopover(\n        binding: binding,\n        searchKey: searchKey,\n        searchBinding: searchBinding,\n        isPresented: isPresented\n      )\n    }\n  }\n\n  private func searchableModelListPopover(\n    binding: Binding<String>,\n    searchKey: String,\n    searchBinding: Binding<String>,\n    isPresented: Binding<Bool>\n  ) -> some View {\n    let filteredModels = filteredModels(for: searchKey)\n\n    return VStack(alignment: .leading, spacing: 8) {\n      // Search field\n      TextField(\"Search models\", text: searchBinding)\n        .textFieldStyle(.roundedBorder)\n        .padding(.top, 16)\n\n      Divider()\n\n      // Model list\n      ScrollView {\n        LazyVStack(alignment: .leading, spacing: 0) {\n          if filteredModels.isEmpty {\n            Text(\"No models found\")\n              .foregroundStyle(.secondary)\n              .padding(.vertical, 8)\n          } else {\n            ForEach(Array(filteredModels.enumerated()), id: \\.element) { index, model in\n              Button {\n                binding.wrappedValue = model\n                searchText[searchKey] = \"\"  // Clear search after selection\n                isPresented.wrappedValue = false\n              } label: {\n                HStack {\n                  modelLabelWithProvider(model: model)\n                  Spacer()\n                  if binding.wrappedValue == model {\n                    Image(systemName: \"checkmark\")\n                  }\n                }\n                .frame(maxWidth: .infinity, alignment: .leading)\n                .padding(.vertical, 8)\n                .padding(.horizontal, 8)\n                .background(\n                  Group {\n                    if binding.wrappedValue == model {\n                      Color.accentColor.opacity(0.1)\n                    } else if index % 2 == 1 {\n                      Color(nsColor: .separatorColor).opacity(0.08)\n                    } else {\n                      Color.clear\n                    }\n                  }\n                )\n                .contentShape(Rectangle())\n              }\n              .buttonStyle(ClaudeModelRowButtonStyle())\n              .onHover { hovering in\n                #if os(macOS)\n                if hovering {\n                  NSCursor.pointingHand.push()\n                } else {\n                  NSCursor.pop()\n                }\n                #endif\n              }\n            }\n          }\n        }\n      }\n      .frame(width: 400, height: 300)\n    }\n    .padding(.bottom, 16)\n    .padding(.horizontal, 16)\n  }\n\n  @ViewBuilder\n  private func modelLabelWithProvider(model: String) -> some View {\n    HStack(spacing: 6) {\n      if let providerId = providerId, let catalog = providerCatalog {\n        modelLabelProviderInfo(model: model, providerId: providerId, catalog: catalog)\n      }\n      Text(ModelNameSanitizer.sanitizeSingle(model))\n    }\n  }\n\n  @ViewBuilder\n  private func modelLabelProviderInfo(model: String, providerId: String, catalog: UnifiedProviderCatalogModel) -> some View {\n    // When providerId is autoProxy, infer provider from model ID\n    if providerId == UnifiedProviderID.autoProxyId {\n      // Infer provider from model ID\n      if let title = catalog.inferProviderFromModel(model) {\n        if let icon = providerIcon(for: nil, title: title, modelId: model) {\n          icon\n            .resizable()\n            .interpolation(.high)\n            .aspectRatio(contentMode: .fit)\n            .frame(width: 14, height: 14)\n        } else {\n          Text(title)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(Color(nsColor: .separatorColor).opacity(0.5))\n            .cornerRadius(3)\n        }\n      }\n    } else {\n      // Use provider title from catalog\n      if let title = catalog.providerTitle(for: providerId) {\n        if let icon = providerIcon(for: providerId, title: title, modelId: model) {\n          icon\n            .resizable()\n            .interpolation(.high)\n            .aspectRatio(contentMode: .fit)\n            .frame(width: 14, height: 14)\n        } else {\n          Text(title)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(Color(nsColor: .separatorColor).opacity(0.5))\n            .cornerRadius(3)\n        }\n      }\n    }\n  }\n\n  private func providerIcon(for providerId: String?, title: String, modelId: String? = nil) -> Image? {\n    // If providerId is nil (autoProxy mode), infer icon from title (service provider name)\n    if providerId == nil || providerId == UnifiedProviderID.autoProxyId {\n      // Priority 1: Try OAuth provider icon by title\n      if let authProvider = LocalAuthProvider.allCases.first(where: { $0.displayName == title }) {\n        let iconName = iconNameForOAuthProvider(authProvider)\n        if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) {\n          return Image(nsImage: nsImage)\n        }\n      }\n      // Priority 2: Try API key provider icon by title (check customIcon first)\n      // Try to find provider by title to check for customIcon\n      if let provider = findProviderByTitle(title), let customIcon = provider.customIcon {\n        return Image(systemName: customIcon)\n      }\n      // Priority 3: Try preset PNG icon\n      if let iconName = ProviderIconResource.iconName(for: title),\n         let nsImage = ProviderIconResource.processedImage(\n           named: iconName,\n           size: NSSize(width: 14, height: 14),\n           isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n         ) {\n        return Image(nsImage: nsImage)\n      }\n      // No fallback - if title doesn't match any known provider, return nil (shows default circle)\n      return nil\n    }\n\n    let parsed = UnifiedProviderID.parse(providerId ?? \"\")\n    switch parsed {\n    case .oauth(let authProvider, _):\n      let iconName = iconNameForOAuthProvider(authProvider)\n      if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) {\n        return Image(nsImage: nsImage)\n      }\n      return nil\n    case .api(let apiId):\n      // Priority 1: Check for custom SF Symbol icon\n      if let provider = findProviderById(apiId), let customIcon = provider.customIcon {\n        return Image(systemName: customIcon)\n      }\n      // Priority 2: Try preset PNG icon\n      if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: title),\n         let nsImage = ProviderIconResource.processedImage(\n           named: iconName,\n           size: NSSize(width: 14, height: 14),\n           isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n         ) {\n        return Image(nsImage: nsImage)\n      }\n      return nil\n    default:\n      return nil\n    }\n  }\n\n  // Helper to find provider by ID from registry\n  private func findProviderById(_ id: String) -> ProvidersRegistryService.Provider? {\n    let registry = ProvidersRegistryService()\n    // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings\n    let loadedRegistry = registry.load()\n    return loadedRegistry.providers.first(where: { $0.id == id })\n  }\n\n  // Helper to find provider by title/name from registry\n  private func findProviderByTitle(_ title: String) -> ProvidersRegistryService.Provider? {\n    let registry = ProvidersRegistryService()\n    // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings\n    let loadedRegistry = registry.load()\n    return loadedRegistry.providers.first(where: { provider in\n      let displayName = UnifiedProviderID.providerDisplayName(provider)\n      return displayName == title || provider.name == title || provider.id == title\n    })\n  }\n\n  private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String {\n    switch provider {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    case .antigravity: return \"AntigravityIcon\"\n    case .qwen: return \"QwenIcon\"\n    }\n  }\n\n  private func filteredModels(for searchKey: String) -> [String] {\n    let query = (searchText[searchKey] ?? \"\").lowercased()\n    if query.isEmpty {\n      return availableModels\n    }\n    return availableModels.filter { model in\n      let display = ModelNameSanitizer.sanitizeSingle(model).lowercased()\n      return display.contains(query) || model.lowercased().contains(query)\n    }\n  }\n\n  private func aliasBinding(_ key: String) -> Binding<String> {\n    Binding(\n      get: { draftAliases[key] ?? \"\" },\n      set: { draftAliases[key] = $0 }\n    )\n  }\n\n  private func normalized(_ value: String) -> String? {\n    let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n    return trimmed.isEmpty ? nil : trimmed\n  }\n\n  private func sanitizeAliases(_ aliases: [String: String]) -> [String: String] {\n    var out: [String: String] = [:]\n    for (key, value) in aliases {\n      let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)\n      if !trimmed.isEmpty {\n        out[key] = trimmed\n      }\n    }\n    return out\n  }\n}\n\n// MARK: - Model Row Button Style\nprivate struct ClaudeModelRowButtonStyle: ButtonStyle {\n  func makeBody(configuration: Configuration) -> some View {\n    configuration.label\n      .background(\n        configuration.isPressed || configuration.role == .destructive\n          ? Color(nsColor: .controlAccentColor).opacity(0.2)\n          : Color.clear\n      )\n      .contentShape(Rectangle())\n  }\n}\n"
  },
  {
    "path": "views/CodexSettingsView.swift",
    "content": "import SwiftUI\n\nstruct CodexSettingsView: View {\n    @ObservedObject var codexVM: CodexVM\n    @ObservedObject var preferences: SessionPreferencesStore\n    @FocusState private var isEnvSetPairsFocused: Bool\n    @State private var envSetPairsLastValue = \"\"\n    @StateObject private var providerCatalog = UnifiedProviderCatalogModel()\n    @State private var providerModels: [String] = []\n    @State private var lastProviderId: String?\n    @State private var showDisableBlockedAlert = false\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            // Header for visual consistency with other settings pages\n            HStack(alignment: .firstTextBaseline) {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"Codex Settings\")\n                        .font(.title2)\n                        .fontWeight(.bold)\n                    Text(\n                        \"Configure Codex CLI: providers, runtime defaults, features, and privacy.\"\n                    )\n                    .font(.subheadline)\n                    .foregroundColor(.secondary)\n                }\n                Spacer(minLength: 8)\n                Link(\n                    destination: URL(string: \"https://developers.openai.com/codex/cli\")!\n                ) {\n                    Label(\"Docs\", systemImage: \"questionmark.circle\")\n                        .labelStyle(.iconOnly)\n                }\n                .buttonStyle(.plain)\n            }\n\n            GroupBox {\n                HStack(spacing: 12) {\n                    VStack(alignment: .leading, spacing: 2) {\n                        Label(\"Enable Codex CLI\", systemImage: \"power\")\n                            .font(.subheadline).fontWeight(.medium)\n                        Text(\"Turning this off hides Codex UI, stops session scans, and makes settings read-only.\")\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    Spacer()\n                    Toggle(\"\", isOn: codexEnabledBinding)\n                        .labelsHidden()\n                        .toggleStyle(.switch)\n                        .controlSize(.small)\n                }\n                .padding(10)\n            }\n            // Tabs (Remote Hosts is a top-level page, not a Codex sub-tab)\n            Group {\n                if #available(macOS 15.0, *) {\n                    TabView {\n                        Tab(\"Provider\", systemImage: \"server.rack\") { providerPane }\n                        Tab(\"Runtime\", systemImage: \"gearshape.2\") { runtimePane }\n                        Tab(\"Sessions\", systemImage: \"folder.badge.gearshape\") { sessionsPane }\n                        Tab(\"Features\", systemImage: \"wand.and.stars\") { featuresPane }\n                        Tab(\"Privacy\", systemImage: \"lock.shield\") { privacyPane }\n                        Tab(\"Raw Config\", systemImage: \"doc.text\") { rawConfigPane }\n                    }\n                } else {\n                    TabView {\n                        providerPane\n                            .tabItem { Label(\"Provider\", systemImage: \"server.rack\") }\n                        runtimePane\n                            .tabItem { Label(\"Runtime\", systemImage: \"gearshape.2\") }\n                        sessionsPane\n                            .tabItem { Label(\"Sessions\", systemImage: \"folder.badge.gearshape\") }\n                        featuresPane\n                            .tabItem { Label(\"Features\", systemImage: \"wand.and.stars\") }\n                        privacyPane\n                            .tabItem { Label(\"Privacy\", systemImage: \"lock.shield\") }\n                        rawConfigPane\n                            .tabItem { Label(\"Raw Config\", systemImage: \"doc.text\") }\n                    }\n                }\n            }\n            .controlSize(.regular)\n            .padding(.bottom, 16)\n            .disabled(!preferences.cliCodexEnabled)\n            .opacity(preferences.cliCodexEnabled ? 1.0 : 0.6)\n        }\n        .alert(\"At least one CLI must remain enabled.\", isPresented: $showDisableBlockedAlert) {\n            Button(\"OK\", role: .cancel) {}\n        }\n    }\n\n    private var codexEnabledBinding: Binding<Bool> {\n        Binding(\n            get: { preferences.cliCodexEnabled },\n            set: { newValue in\n                if preferences.setCLIEnabled(.codex, enabled: newValue) == false {\n                    showDisableBlockedAlert = true\n                }\n            }\n        )\n    }\n\n    // MARK: - Provider Pane\n    private var providerPane: some View {\n        let content = providerPaneContent\n        return SettingsTabContent {\n            content\n        }\n        .task {\n            await codexVM.loadProxyDefaults(preferences: preferences)\n            await reloadProxyCatalog()\n        }\n        // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode\n        .onChange(of: preferences.oauthProvidersEnabled) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n        .onChange(of: preferences.apiKeyProvidersEnabled) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n        .onChange(of: CLIProxyService.shared.isRunning) { _ in\n            Task { await reloadProxyCatalog() }\n        }\n    }\n\n    private var providerPaneContent: some View {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Active Provider\", systemImage: \"server.rack\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Use built-in provider or route through CLI Proxy API.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                SimpleProviderPicker(providerId: $preferences.codexProxyProviderId)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                    .onChange(of: preferences.codexProxyProviderId) { _ in\n                        normalizeProxySelection()\n                        if preferences.codexProxyProviderId == nil {\n                            Task { await reloadProxyCatalog(forceRefresh: true) }\n                        }\n                        codexVM.scheduleApplyProxySelectionDebounced(\n                            providerId: preferences.codexProxyProviderId,\n                            modelId: preferences.codexProxyModelId,\n                            preferences: preferences\n                        )\n                    }\n            }\n            gridDivider\n            GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Model List\", systemImage: \"list.bullet\")\n                        .font(.subheadline).fontWeight(.medium)\n                    Text(\"Select a default model from the available models.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                }\n                SimpleModelPicker(\n                    models: providerModels,\n                    isDisabled: preferences.codexProxyProviderId == nil\n                        || !providerCatalog.isProviderAvailable(preferences.codexProxyProviderId),\n                    providerId: preferences.codexProxyProviderId,\n                    providerCatalog: providerCatalog,\n                    modelId: $preferences.codexProxyModelId\n                )\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .onChange(of: preferences.codexProxyModelId) { _ in\n                    codexVM.scheduleApplyProxySelectionDebounced(\n                        providerId: preferences.codexProxyProviderId,\n                        modelId: preferences.codexProxyModelId,\n                        preferences: preferences\n                    )\n                }\n            }\n            // Base URL and API Key Env rows are hidden to reduce redundancy\n        }\n    }\n\n    // MARK: - Runtime Pane\n    private var runtimePane: some View {\n        SettingsTabContent {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Reasoning Effort\", systemImage: \"brain\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Controls depth of reasoning for supported models.\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            Picker(\"\", selection: $codexVM.reasoningEffort) {\n                                ForEach(CodexVM.ReasoningEffort.allCases) { Text($0.rawValue).tag($0) }\n                            }\n                            .labelsHidden()\n                            .onChange(of: codexVM.reasoningEffort) { _ in codexVM.scheduleApplyReasoningDebounced() }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Reasoning Summary\", systemImage: \"text.bubble\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Summary verbosity for reasoning-capable models.\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            Picker(\"\", selection: $codexVM.reasoningSummary) {\n                                ForEach(CodexVM.ReasoningSummary.allCases) { Text($0.rawValue).tag($0) }\n                            }\n                            .labelsHidden()\n                            .onChange(of: codexVM.reasoningSummary) { _ in codexVM.scheduleApplyReasoningDebounced() }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Verbosity\", systemImage: \"text.alignleft\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Text output verbosity for GPT‑5 family (Responses API).\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            Picker(\"\", selection: $codexVM.modelVerbosity) {\n                                ForEach(CodexVM.ModelVerbosity.allCases) { Text($0.rawValue).tag($0) }\n                            }\n                            .labelsHidden()\n                            .onChange(of: codexVM.modelVerbosity) { _ in codexVM.scheduleApplyReasoningDebounced() }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Sandbox\", systemImage: \"lock.shield\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Default sandbox for sessions launched from CodMate only.\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            Picker(\"\", selection: $codexVM.sandboxMode) {\n                                ForEach(SandboxMode.allCases) { Text($0.title).tag($0) }\n                            }\n                            .labelsHidden()\n                            .onChange(of: codexVM.sandboxMode) { newValue in\n                                codexVM.scheduleApplySandboxDebounced()\n                                preferences.defaultResumeSandboxMode = newValue\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Approval Policy\", systemImage: \"hand.raised\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"Default approval prompts for sessions launched from CodMate only.\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            Picker(\"\", selection: $codexVM.approvalPolicy) {\n                                ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) }\n                            }\n                            .labelsHidden()\n                            .onChange(of: codexVM.approvalPolicy) { newValue in\n                                codexVM.scheduleApplyApprovalDebounced()\n                                preferences.defaultResumeApprovalPolicy = newValue\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Auto-assign new sessions to same project\", systemImage: \"folder.badge.plus\")\n                                    .font(.subheadline)\n                                    .fontWeight(.medium)\n                                Text(\n                                    \"When starting New from detail, auto-assign the created session to that project.\"\n                                )\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                            }\n                                Toggle(\"\", isOn: $preferences.autoAssignNewToSameProject)\n                                    .labelsHidden()\n                                    .toggleStyle(.switch)\n                                    .controlSize(.small)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                    }\n        }\n    }\n\n    // MARK: - Sessions Pane\n    private var sessionsPane: some View {\n        SettingsTabContent {\n            SessionsPathPane(preferences: preferences, fixedKind: .codex)\n        }\n    }\n\n    // MARK: - Features Pane\n    private var featuresPane: some View {\n        SettingsTabContent {\n            VStack(alignment: .leading, spacing: 16) {\n                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Suppress unstable features warning\", systemImage: \"exclamationmark.triangle\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Hide the Codex CLI unstable-features banner by writing to config.toml.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        Toggle(\"\", isOn: $codexVM.suppressUnstableFeaturesWarning)\n                            .labelsHidden()\n                            .toggleStyle(.switch)\n                            .controlSize(.small)\n                            .onChange(of: codexVM.suppressUnstableFeaturesWarning) { _ in\n                                codexVM.scheduleApplySuppressUnstableWarningDebounced()\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                }\n                Divider()\n                HStack(alignment: .firstTextBaseline) {\n                    VStack(alignment: .leading, spacing: 2) {\n                        Label(\"Feature Flags\", systemImage: \"wand.and.stars\")\n                            .font(.subheadline).fontWeight(.medium)\n                        Text(\"Inspect codex CLI features and override individual flags.\")\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                            .fixedSize(horizontal: false, vertical: true)\n                    }\n                    Spacer(minLength: 8)\n                    HStack(spacing: 8) {\n                        Button {\n                            Task { await codexVM.loadFeatures() }\n                        } label: {\n                            Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                        }\n                        .controlSize(.small)\n                        .disabled(codexVM.featuresLoading)\n                        if codexVM.featuresLoading {\n                            ProgressView().controlSize(.small)\n                        }\n                    }\n                }\n                if let err = codexVM.featureError {\n                    Text(err).font(.caption).foregroundStyle(.red)\n                }\n                if codexVM.featureFlags.isEmpty {\n                    Text(codexVM.featuresLoading ? \"Loading features…\" : \"No features reported by codex CLI.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                } else {\n                    let stageWidth: CGFloat = 120\n                    let overrideWidth: CGFloat = 180\n                    let flags = codexVM.featureFlags\n                    VStack(spacing: 10) {\n                        HStack(alignment: .firstTextBaseline) {\n                            Text(\"Feature\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .frame(maxWidth: .infinity, alignment: .leading)\n                            Text(\"Stage\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .frame(width: stageWidth, alignment: .leading)\n                            Text(\"Override\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .frame(width: overrideWidth, alignment: .trailing)\n                        }\n                        Divider()\n                        ForEach(Array(flags.enumerated()), id: \\.element.id) { index, feature in\n                            HStack(alignment: .center, spacing: 12) {\n                                Text(feature.name)\n                                    .font(.subheadline)\n                                    .fontWeight(.medium)\n                                    .frame(minWidth: 120, maxWidth: .infinity, alignment: .leading)\n                                    .lineLimit(1)\n                                    .truncationMode(.tail)\n                                Text(feature.stage.capitalized)\n                                    .font(.subheadline)\n                                    .frame(width: stageWidth, alignment: .leading)\n                                Toggle(\"\", isOn: overrideToggleBinding(for: feature))\n                                    .labelsHidden()\n                                    .toggleStyle(.switch)\n                                    .controlSize(.small)\n                                    .frame(width: overrideWidth, alignment: .trailing)\n                                    .disabled(codexVM.featuresLoading)\n                            }\n                            if index < flags.count - 1 {\n                                Divider()\n                            }\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    // MARK: - Privacy Pane\n    private var privacyPane: some View {\n        SettingsTabContent {\n            VStack(alignment: .leading, spacing: 16) {\n                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Inherit\", systemImage: \"arrow.down.circle\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Start from full, core, or empty environment.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        Picker(\"\", selection: $codexVM.envInherit) {\n                            ForEach([\"all\", \"core\", \"none\"], id: \\.self) { Text($0).tag($0) }\n                        }\n                        .labelsHidden()\n                        .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                    gridDivider\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Ignore default excludes\", systemImage: \"eye.slash\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Keep vars containing KEY/SECRET/TOKEN unless unchecked.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        Toggle(\"\", isOn: $codexVM.envIgnoreDefaults)\n                            .labelsHidden()\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                    gridDivider\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Include Only\", systemImage: \"checklist\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Whitelist patterns (comma separated). Example: PATH, HOME\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        TextField(\"PATH, HOME\", text: $codexVM.envIncludeOnly)\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                    gridDivider\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Exclude\", systemImage: \"xmark.circle\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Blacklist patterns (comma separated). Example: AWS_*, AZURE_*\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        TextField(\"AWS_*, AZURE_*\", text: $codexVM.envExclude)\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                    gridDivider\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Set Variables\", systemImage: \"key\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"KEY=VALUE per line. These override inherited values.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        ZStack(alignment: .bottomTrailing) {\n                            TextEditor(text: $codexVM.envSetPairs)\n                                .font(.system(.body, design: .monospaced))\n                                .frame(minHeight: 90)\n                                .focused($isEnvSetPairsFocused)\n                            if isEnvSetPairsFocused {\n                                HStack(spacing: 8) {\n                                    if codexVM.lastError != nil {\n                                        Text(codexVM.lastError!)\n                                            .foregroundStyle(.red)\n                                            .font(.caption)\n                                    }\n                                    Button(\"Save Environment Policy\") {\n                                        envSetPairsLastValue = codexVM.envSetPairs\n                                        isEnvSetPairsFocused = false\n                                        Task { await codexVM.applyEnvPolicy() }\n                                    }\n                                    .buttonStyle(.plain)\n                                    .foregroundStyle(.primary)\n                                }\n                                .padding(.horizontal, 8)\n                                .padding(.vertical, 6)\n                            }\n                        }\n                        .frame(maxWidth: .infinity, alignment: .trailing)\n                        .onAppear {\n                            envSetPairsLastValue = codexVM.envSetPairs\n                        }\n                    }\n                }\n\n                Divider()\n\n                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Hide Agent Reasoning\", systemImage: \"eye.slash\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\"Suppress reasoning events in TUI and exec outputs.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                                .fixedSize(horizontal: false, vertical: true)\n                        }\n                        Toggle(\"\", isOn: $codexVM.hideAgentReasoning)\n                            .labelsHidden()\n                            .onChange(of: codexVM.hideAgentReasoning) { _ in codexVM.scheduleApplyHideReasoningDebounced() }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                    gridDivider\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 2) {\n                            Label(\"Show Raw Reasoning\", systemImage: \"eye\")\n                                .font(.subheadline).fontWeight(.medium)\n                            Text(\n                                \"Expose raw chain-of-thought when provider supports it (use with caution).\"\n                            )\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                            .fixedSize(horizontal: false, vertical: true)\n                        }\n                        Toggle(\"\", isOn: $codexVM.showRawAgentReasoning)\n                            .labelsHidden()\n                            .onChange(of: codexVM.showRawAgentReasoning) { _ in codexVM.scheduleApplyShowRawReasoningDebounced() }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                    }\n                }\n            }\n        }\n    }\n\n    // MARK: - Raw Config Pane\n    private var rawConfigPane: some View {\n        SettingsTabContent {\n            ZStack(alignment: .topTrailing) {\n                ScrollView {\n                    Text(\n                        codexVM.rawConfigText.isEmpty\n                            ? \"(empty config.toml)\" : codexVM.rawConfigText\n                    )\n                    .font(.system(.caption, design: .monospaced))\n                    .textSelection(.enabled)\n                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                }\n                HStack(spacing: 8) {\n                    Button {\n                        Task { await codexVM.reloadRawConfig() }\n                    } label: {\n                        Image(systemName: \"arrow.clockwise\")\n                    }\n                    .help(\"Reload\")\n                    .buttonStyle(.borderless)\n                    Button {\n                        codexVM.openConfigInEditor()\n                    } label: {\n                        Image(systemName: \"square.and.pencil\")\n                    }\n                    .help(\"Open in default editor\")\n                    .buttonStyle(.borderless)\n                }\n            }\n            .task { await codexVM.reloadRawConfig() }\n        }\n    }\n\n    // MARK: - Helper Views\n\n    // codexTabContent has been replaced by the shared SettingsTabContent component\n\n    private func reloadProxyCatalog(forceRefresh: Bool = false) async {\n        await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh)\n        normalizeProxySelection()\n    }\n\n    private func normalizeProxySelection() {\n        let normalized = providerCatalog.normalizeProviderId(preferences.codexProxyProviderId)\n        if normalized != preferences.codexProxyProviderId {\n            preferences.codexProxyProviderId = normalized\n        }\n        let providerChanged = lastProviderId != nil && lastProviderId != preferences.codexProxyProviderId\n        lastProviderId = preferences.codexProxyProviderId\n        guard let providerId = preferences.codexProxyProviderId else {\n            providerModels = []\n            preferences.codexProxyModelId = nil\n            return\n        }\n        providerModels = providerCatalog.models(for: providerId)\n        if providerChanged {\n            preferences.codexProxyModelId = nil\n            return\n        }\n        guard !providerModels.isEmpty else {\n            return\n        }\n    }\n\n    @ViewBuilder\n    private var gridDivider: some View {\n        Divider()\n    }\n\n    private func overrideToggleBinding(for feature: CodexVM.FeatureFlag) -> Binding<Bool> {\n        Binding(\n            get: {\n                guard let live = codexVM.featureFlags.first(where: { $0.id == feature.id }) else {\n                    return feature.defaultEnabled\n                }\n                switch live.overrideState {\n                case .inherit: return live.defaultEnabled\n                case .forceOn: return true\n                case .forceOff: return false\n                }\n            },\n            set: { newValue in\n                guard let live = codexVM.featureFlags.first(where: { $0.id == feature.id }) else {\n                    codexVM.setFeatureOverride(\n                        name: feature.name,\n                        state: newValue == feature.defaultEnabled ? .inherit : (newValue ? .forceOn : .forceOff)\n                    )\n                    return\n                }\n                let desired: CodexVM.FeatureOverrideState\n                if newValue == live.defaultEnabled {\n                    desired = .inherit\n                } else {\n                    desired = newValue ? .forceOn : .forceOff\n                }\n                codexVM.setFeatureOverride(name: live.name, state: desired)\n            }\n        )\n    }\n}\n"
  },
  {
    "path": "views/CommandsSettingsView.swift",
    "content": "import SwiftUI\n\nstruct CommandsSettingsView: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  @StateObject private var vm = CommandsViewModel()\n  @State private var searchFocused = false\n  @State private var pendingAction: PendingCommandAction?\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      headerRow\n      contentRow\n    }\n    .sheet(isPresented: $vm.showAddSheet) {\n      CommandEditSheet(\n        preferences: preferences,\n        command: nil,\n        onSave: { command in\n          Task {\n            await vm.addCommand(command)\n            vm.showAddSheet = false\n          }\n        },\n        onCancel: { vm.showAddSheet = false }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .sheet(isPresented: $vm.showImportSheet) {\n      CommandsImportSheet(\n        candidates: $vm.importCandidates,\n        isImporting: vm.isImporting,\n        statusMessage: vm.importStatusMessage,\n        title: \"Import Commands\",\n        subtitle: \"Scan Home for existing Codex/Claude/Gemini commands and import into CodMate.\",\n        onCancel: { vm.cancelImport() },\n        onImport: { Task { await vm.importSelectedCommands() } }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .sheet(item: $vm.editingCommand) { command in\n      CommandEditSheet(\n        preferences: preferences,\n        command: command,\n        onSave: { updated in\n          Task {\n            await vm.updateCommand(updated)\n            vm.editingCommand = nil\n          }\n        },\n        onCancel: { vm.editingCommand = nil }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .alert(item: $pendingAction) { action in\n      Alert(\n        title: Text(\"Delete Command?\"),\n        message: Text(\"Remove \\\"\\(action.command.name)\\\" from the commands list?\"),\n        primaryButton: .destructive(Text(\"Delete\")) {\n          Task {\n            await vm.deleteCommand(id: action.command.id)\n            pendingAction = nil\n          }\n        },\n        secondaryButton: .cancel { pendingAction = nil }\n      )\n    }\n    .task { await vm.load() }\n  }\n\n  private var headerRow: some View {\n    HStack(spacing: 8) {\n      Spacer(minLength: 0)\n      ToolbarSearchField(\n        placeholder: \"Search commands\",\n        text: $vm.searchText,\n        onFocusChange: { focused in searchFocused = focused },\n        onSubmit: {}\n      )\n      .frame(width: 240)\n\n      Button {\n        vm.showAddSheet = true\n      } label: {\n        Label(\"Add\", systemImage: \"plus\")\n      }\n      Button {\n        vm.beginImportFromHome()\n      } label: {\n        Label(\"Import\", systemImage: \"tray.and.arrow.down\")\n      }\n    }\n  }\n\n  private var contentRow: some View {\n    HStack(alignment: .top, spacing: 12) {\n      commandsList\n        .frame(minWidth: 260, maxWidth: 320)\n      detailPanel\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n\n  private var commandsList: some View {\n    Group {\n      if vm.isLoading {\n        VStack(spacing: 8) {\n          ProgressView()\n          Text(\"Loading commands…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if vm.filteredCommands.isEmpty {\n        VStack(spacing: 10) {\n          Image(systemName: \"command\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"No Commands\")\n            .font(.title3)\n            .fontWeight(.medium)\n          Text(\"Add a command to get started.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n            .multilineTextAlignment(.center)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        List(selection: $vm.selectedCommandId) {\n          ForEach(vm.filteredCommands) { command in\n            HStack(alignment: .center, spacing: 8) {\n              Toggle(\n                \"\",\n                isOn: Binding(\n                  get: { command.isEnabled },\n                  set: { value in\n                    vm.updateCommandEnabled(id: command.id, value: value)\n                  }\n                )\n              )\n              .labelsHidden()\n              .controlSize(.small)\n\n              VStack(alignment: .leading, spacing: 4) {\n                HStack(spacing: 4) {\n                  Text(command.name)\n                    .font(.body.weight(.medium))\n                }\n                Text(command.description)\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .lineLimit(2)\n              }\n              Spacer(minLength: 8)\n              HStack(spacing: 6) {\n                MCPServerTargetToggle(\n                  provider: .codex,\n                  isOn: Binding(\n                    get: { vm.isCommandTargetEnabled(id: command.id, target: .codex) },\n                    set: { value in\n                      vm.updateCommandTarget(id: command.id, target: .codex, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.codex)\n                )\n                MCPServerTargetToggle(\n                  provider: .claude,\n                  isOn: Binding(\n                    get: { vm.isCommandTargetEnabled(id: command.id, target: .claude) },\n                    set: { value in\n                      vm.updateCommandTarget(id: command.id, target: .claude, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.claude)\n                )\n                MCPServerTargetToggle(\n                  provider: .gemini,\n                  isOn: Binding(\n                    get: { vm.isCommandTargetEnabled(id: command.id, target: .gemini) },\n                    set: { value in\n                      vm.updateCommandTarget(id: command.id, target: .gemini, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.gemini)\n                )\n              }\n            }\n            .padding(.vertical, 4)\n            .contentShape(Rectangle())\n            .onTapGesture { vm.selectedCommandId = command.id }\n            .tag(command.id as String?)\n            .contextMenu {\n              Button(\"Edit\") { vm.editingCommand = command }\n              let editors = EditorApp.installedEditors\n              openInEditorMenu(editors: editors) { editor in\n                vm.openInEditor(command, using: editor)\n              }\n              .disabled(command.path.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n              if !editors.isEmpty {\n                Divider()\n              }\n              Button(\"Reveal in Finder\") {\n                revealInFinder(path: command.path)\n              }\n              Button(\"Delete\", role: .destructive) { confirmDelete(command) }\n            }\n          }\n        }\n        .listStyle(.inset)\n        .scrollContentBackground(.hidden)\n      }\n    }\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private var detailPanel: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      if let command = vm.selectedCommand {\n        CommandDetailExplorerView(\n          command: command,\n          onEdit: { vm.editingCommand = command },\n          onDelete: { confirmDelete(command) },\n          onSync: { Task { await vm.manualSync() } }\n        )\n        .id(command.id)\n      } else {\n        VStack(spacing: 12) {\n          Image(systemName: \"command\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"Select a command to view details\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      }\n    }\n    .padding(12)\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private func confirmDelete(_ command: CommandRecord) {\n    pendingAction = PendingCommandAction(command: command)\n  }\n\n  private func revealInFinder(path: String) {\n    guard !path.isEmpty else { return }\n    let url = URL(fileURLWithPath: path)\n    NSWorkspace.shared.activateFileViewerSelecting([url])\n  }\n}\n\nprivate struct PendingCommandAction: Identifiable {\n  let id = UUID()\n  let command: CommandRecord\n}\n\n// MARK: - Command Detail Explorer View\nstruct CommandDetailExplorerView: View {\n  let command: CommandRecord\n  let onEdit: () -> Void\n  let onDelete: () -> Void\n  let onSync: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n      ScrollView {\n        VStack(alignment: .leading, spacing: 16) {\n          promptSection\n          if hasMetadata {\n            metadataSection\n          }\n          if !command.metadata.tags.isEmpty {\n            tagsSection\n          }\n          infoSection\n        }\n      }\n    }\n  }\n\n  private var header: some View {\n    HStack(alignment: .top, spacing: 12) {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(command.name)\n          .font(.title3.weight(.semibold))\n        Text(command.description)\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n      }\n      Spacer()\n      HStack(spacing: 8) {\n        Button {\n          onSync()\n        } label: {\n          Image(systemName: \"arrow.triangle.2.circlepath\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Sync commands to AI CLI providers\")\n\n        Button {\n          onEdit()\n        } label: {\n          Image(systemName: \"pencil\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Edit\")\n\n        Button(role: .destructive) {\n          onDelete()\n        } label: {\n          Image(systemName: \"trash\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Delete\")\n      }\n    }\n  }\n\n  private var promptSection: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Prompt\")\n        .font(.headline)\n      Text(command.prompt)\n        .font(.system(size: 13))\n        .textSelection(.enabled)\n        .padding(10)\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .background(\n          RoundedRectangle(cornerRadius: 6, style: .continuous)\n            .fill(Color(nsColor: .controlBackgroundColor))\n        )\n    }\n  }\n\n  private var hasMetadata: Bool {\n    command.metadata.argumentHint != nil ||\n    command.metadata.model != nil ||\n    (command.metadata.allowedTools?.isEmpty == false)\n  }\n\n  private var metadataSection: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Metadata\")\n        .font(.headline)\n\n      VStack(alignment: .leading, spacing: 6) {\n        if let hint = command.metadata.argumentHint {\n          metadataRow(label: \"Argument Hint\", value: hint)\n        }\n        if let model = command.metadata.model {\n          metadataRow(label: \"Model\", value: model)\n        }\n        if let tools = command.metadata.allowedTools, !tools.isEmpty {\n          metadataRow(label: \"Allowed Tools\", value: tools.joined(separator: \", \"))\n        }\n      }\n    }\n  }\n\n  private func metadataRow(label: String, value: String) -> some View {\n    HStack(alignment: .top, spacing: 8) {\n      Text(label)\n        .font(.caption)\n        .foregroundStyle(.secondary)\n        .frame(width: 100, alignment: .leading)\n      Text(value)\n        .font(.caption)\n        .textSelection(.enabled)\n      Spacer()\n    }\n  }\n\n  private var tagsSection: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Tags\")\n        .font(.headline)\n      HStack(spacing: 6) {\n        ForEach(command.metadata.tags, id: \\.self) { tag in\n          Text(tag)\n            .font(.caption)\n            .padding(.horizontal, 8)\n            .padding(.vertical, 4)\n            .background(Color.accentColor.opacity(0.12))\n            .clipShape(RoundedRectangle(cornerRadius: 4))\n        }\n      }\n    }\n  }\n\n  private var infoSection: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      Divider()\n      HStack(spacing: 12) {\n        VStack(alignment: .leading, spacing: 4) {\n          Text(\"Source\")\n            .font(.caption2)\n            .foregroundStyle(.tertiary)\n          Text(command.source)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        Spacer()\n        VStack(alignment: .trailing, spacing: 4) {\n          Text(\"Installed\")\n            .font(.caption2)\n            .foregroundStyle(.tertiary)\n          Text(command.installedAt.formatted(date: .abbreviated, time: .omitted))\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n      }\n    }\n  }\n}\n\n// MARK: - Command Edit Sheet\nstruct CommandEditSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  let command: CommandRecord?\n  let onSave: (CommandRecord) -> Void\n  let onCancel: () -> Void\n\n  @State private var id: String = \"\"\n  @State private var name: String = \"\"\n  @State private var description: String = \"\"\n  @State private var prompt: String = \"\"\n  @State private var argumentHint: String = \"\"\n  @State private var model: String = \"\"\n  @State private var allowedTools: String = \"\"\n  @State private var tags: String = \"\"\n  @State private var codexEnabled = true\n  @State private var claudeEnabled = true\n  @State private var geminiEnabled = false\n  @State private var selectedTab: Int = 0\n  @State private var wizardActive: Bool = false\n  @State private var didHydrate: Bool = false\n  @FocusState private var focusedField: FocusField?\n\n  private enum FocusField {\n    case name\n  }\n\n  var body: some View {\n    if wizardActive {\n      CommandWizardSheet(preferences: preferences, onApply: { draft in\n        applyDraft(draft)\n        wizardActive = false\n      }, onCancel: {\n        wizardActive = false\n      })\n    } else {\n      formBody\n    }\n  }\n\n  @ViewBuilder private var formBody: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      // Header (title only)\n      HStack(alignment: .firstTextBaseline) {\n        Text(command == nil ? \"New Command\" : \"Edit Command\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        Spacer()\n        Button {\n          wizardActive = true\n        } label: {\n          Image(systemName: \"sparkles\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"AI Wizard\")\n      }\n\n      // Tabs\n      if #available(macOS 15.0, *) {\n        TabView(selection: $selectedTab) {\n          Tab(\"General\", systemImage: \"slider.horizontal.3\", value: 0) {\n            SettingsTabContent { generalTab }\n          }\n          Tab(\"Metadata\", systemImage: \"info.circle\", value: 1) {\n            SettingsTabContent { metadataTab }\n          }\n        }\n      } else {\n        TabView(selection: $selectedTab) {\n          SettingsTabContent { generalTab }\n            .tabItem { Label(\"General\", systemImage: \"slider.horizontal.3\") }\n            .tag(0)\n          SettingsTabContent { metadataTab }\n            .tabItem { Label(\"Metadata\", systemImage: \"info.circle\") }\n            .tag(1)\n        }\n      }\n\n      // Bottom buttons\n      HStack {\n        Spacer()\n        Button(\"Cancel\") { onCancel() }\n        Button(command == nil ? \"Create\" : \"Save\") { saveCommand() }\n          .buttonStyle(.borderedProminent)\n          .disabled(name.isEmpty || description.isEmpty || prompt.isEmpty)\n      }\n    }\n    .padding(16)\n    .onAppear {\n      if didHydrate { return }\n      if let cmd = command {\n        id = cmd.id\n        name = cmd.name\n        description = cmd.description\n        prompt = cmd.prompt\n        argumentHint = cmd.metadata.argumentHint ?? \"\"\n        model = cmd.metadata.model ?? \"\"\n        allowedTools = cmd.metadata.allowedTools?.joined(separator: \", \") ?? \"\"\n        tags = cmd.metadata.tags.joined(separator: \", \")\n        codexEnabled = cmd.targets.codex\n        claudeEnabled = cmd.targets.claude\n        geminiEnabled = cmd.targets.gemini\n      }\n      didHydrate = true\n      if command == nil {\n        DispatchQueue.main.async {\n          focusedField = .name\n        }\n      }\n    }\n  }\n\n  private func applyDraft(_ draft: CommandWizardDraft) {\n    name = draft.name\n    description = draft.description\n    prompt = draft.prompt\n    argumentHint = draft.argumentHint ?? \"\"\n    model = draft.model ?? \"\"\n    allowedTools = (draft.allowedTools ?? []).joined(separator: \", \")\n    tags = draft.tags.joined(separator: \", \")\n    if let targets = draft.targets {\n      codexEnabled = targets.codex\n      claudeEnabled = targets.claude\n      geminiEnabled = targets.gemini\n    }\n  }\n\n  @ViewBuilder private var generalTab: some View {\n    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n      GridRow {\n        Text(\"Name\").font(.subheadline).fontWeight(.medium)\n        TextField(\"command-name\", text: $name)\n          .focused($focusedField, equals: .name)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Description\").font(.subheadline).fontWeight(.medium)\n        TextField(\"Short description\", text: $description)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Prompt\").font(.subheadline).fontWeight(.medium)\n        TextEditor(text: $prompt)\n          .font(.system(.caption, design: .monospaced))\n          .frame(height: 180)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Targets\").font(.subheadline).fontWeight(.medium)\n        HStack(spacing: 12) {\n          Toggle(\"Codex\", isOn: $codexEnabled)\n            .toggleStyle(.switch)\n            .controlSize(.small)\n          Toggle(\"Claude Code\", isOn: $claudeEnabled)\n            .toggleStyle(.switch)\n            .controlSize(.small)\n          Toggle(\"Gemini\", isOn: $geminiEnabled)\n            .toggleStyle(.switch)\n            .controlSize(.small)\n        }\n        .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n    }\n  }\n\n  @ViewBuilder private var metadataTab: some View {\n    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n      GridRow {\n        Text(\"Argument Hint\").font(.subheadline).fontWeight(.medium)\n        TextField(\"[file-path]\", text: $argumentHint)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Model\").font(.subheadline).fontWeight(.medium)\n        TextField(\"claude-opus-4-5\", text: $model)\n          .help(\"Claude Code only\")\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Allowed Tools\").font(.subheadline).fontWeight(.medium)\n        TextField(\"Read, Grep\", text: $allowedTools)\n          .help(\"Comma-separated list (Claude Code only)\")\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n      GridRow {\n        Text(\"Tags\").font(.subheadline).fontWeight(.medium)\n        TextField(\"tag1, tag2\", text: $tags)\n          .help(\"Comma-separated list\")\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n    }\n  }\n\n  private func saveCommand() {\n    let finalId = command?.id ?? name.lowercased().replacingOccurrences(of: \" \", with: \"-\")\n    let metadata = CommandMetadata(\n      argumentHint: argumentHint.isEmpty ? nil : argumentHint,\n      model: model.isEmpty ? nil : model,\n      allowedTools: allowedTools.isEmpty ? nil : allowedTools.split(separator: \",\").map { $0.trimmingCharacters(in: .whitespaces) },\n      tags: tags.isEmpty ? [] : tags.split(separator: \",\").map { String($0.trimmingCharacters(in: .whitespaces)) }\n    )\n    let targets = CommandTargets(codex: codexEnabled, claude: claudeEnabled, gemini: geminiEnabled)\n\n    let record = CommandRecord(\n      id: finalId,\n      name: name,\n      description: description,\n      prompt: prompt,\n      metadata: metadata,\n      targets: targets,\n      isEnabled: command?.isEnabled ?? true,\n      source: command?.source ?? \"user\",\n      installedAt: command?.installedAt ?? Date()\n    )\n\n    onSave(record)\n  }\n}\n"
  },
  {
    "path": "views/Content/AllOverviewView.swift",
    "content": "import SwiftUI\n\nstruct AllOverviewView: View {\n  @ObservedObject var viewModel: AllOverviewViewModel\n  var preferences: SessionPreferencesStore\n  var onSelectSession: (SessionSummary) -> Void\n  var onResumeSession: (SessionSummary) -> Void\n  var onFocusToday: () -> Void\n  var onSelectDate: (Date) -> Void\n  var onSelectProject: (String) -> Void\n\n  private func columns(for width: CGFloat) -> [GridItem] {\n    let minWidth: CGFloat = 220\n    let spacing: CGFloat = 16\n    let availableWidth = width - 48  // 24 horizontal padding * 2\n    let count = max(1, Int((availableWidth + spacing) / (minWidth + spacing)))\n    // Cap at 4 columns to match the max number of items per section (4)\n    var targetCount = min(4, count)\n    \n    // Optimization: Avoid 3 columns for 4-item grids to prevent \"3 on top, 1 on bottom\" layout.\n    // Since we mostly have sets of 4 items (Hero, Projects), a 2x2 grid looks better than 3+1.\n    if targetCount == 3 {\n      targetCount = 2\n    }\n    \n    return Array(repeating: GridItem(.flexible(), spacing: spacing), count: targetCount)\n  }\n\n  var body: some View {\n    GeometryReader { geometry in\n      let cols = columns(for: geometry.size.width)\n      ScrollView {\n        VStack(alignment: .leading, spacing: 20) {\n          headerSection\n          if viewModel.isLoading && snapshot.totalSessions == 0 {\n            OverviewLoadingPlaceholder()\n          } else {\n            if !snapshot.activityChartData.points.isEmpty {\n               OverviewActivityChart(\n                 data: snapshot.activityChartData,\n                 enabledSources: enabledSources,\n                 onSelectDate: onSelectDate\n               )\n            }\n            heroSection(columns: cols)\n            efficiencySection(columns: cols)\n            recentSection\n          }\n        }\n        .padding(.horizontal, 24)\n        .padding(.vertical, 24)\n        .frame(maxWidth: .infinity, alignment: .center)\n      }\n    }\n  }\n\n  private var snapshot: AllOverviewSnapshot { viewModel.snapshot }\n  private var enabledSources: Set<SessionSource.Kind> {\n    Set([\n      preferences.isCLIEnabled(.codex) ? SessionSource.Kind.codex : nil,\n      preferences.isCLIEnabled(.claude) ? SessionSource.Kind.claude : nil,\n      preferences.isCLIEnabled(.gemini) ? SessionSource.Kind.gemini : nil,\n    ].compactMap { $0 })\n  }\n\n  private var headerSection: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      Text(\"Workspace Overview\")\n        .font(.largeTitle.weight(.semibold))\n      Text(metadataLine)\n        .font(.caption)\n        .foregroundStyle(.secondary)\n    }\n  }\n\n  private var metadataLine: String {\n    var parts: [String] = []\n    let updated = \"Updated \\(snapshot.lastUpdated.formatted(date: .abbreviated, time: .shortened))\"\n    parts.append(updated)\n    if let coverage = coverageLine {\n      parts.append(coverage)\n    }\n    return parts.joined(separator: \" • \")\n  }\n\n  private var coverageLine: String? {\n    guard let coverage = viewModel.cacheCoverage else { return nil }\n    let sources = coverage.sources.isEmpty\n      ? \"Cache building…\"\n      : coverage.sources.map { $0.displayName }.joined(separator: \", \")\n    let datePart: String\n    if let dt = coverage.lastFullIndexAt {\n      datePart = \"indexed \\(dt.formatted(date: .abbreviated, time: .shortened))\"\n    } else {\n      datePart = \"indexed n/a\"\n    }\n    return \"Cache: \\(coverage.sessionCount) entries • \\(sources) • \\(datePart)\"\n  }\n\n  private func heroSection(columns: [GridItem]) -> some View {\n    VStack(alignment: .leading, spacing: 16) {\n      LazyVGrid(columns: columns, spacing: 16) {\n        heroMetric(\n          title: \"Sessions\",\n          value: snapshot.totalSessions.formatted(),\n          detail: \"In selected range\"\n        )\n        heroMetric(\n          title: \"Messages\",\n          value: (snapshot.userMessages + snapshot.assistantMessages).formatted(),\n          detail: \"\\(snapshot.userMessages) user · \\(snapshot.assistantMessages) assistant\"\n        )\n        heroMetric(\n          title: \"Active Time\",\n          value: Self.durationFormatter.string(from: snapshot.totalDuration) ?? \"—\",\n          detail: \"Tokens \\(TokenFormatter.short(snapshot.totalTokens))\"\n        )\n        heroMetric(\n          title: \"Projects\",\n          value: snapshot.projectCount.formatted(),\n          detail: \"Tracked projects\"\n        )\n      }\n      .frame(maxWidth: .infinity, alignment: .leading)\n    }\n  }\n\n  private func heroMetric(title: String, value: String, detail: String) -> some View {\n    OverviewCard {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(title).font(.subheadline).foregroundStyle(.secondary)\n        Text(value).font(.title2.monospacedDigit()).fontWeight(.semibold)\n        Text(detail).font(.caption).foregroundStyle(.secondary)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func efficiencySection(columns: [GridItem]) -> some View {\n    if !snapshot.sourceStats.isEmpty {\n      VStack(alignment: .leading, spacing: 10) {\n        LazyVGrid(columns: columns, spacing: 16) {\n          ForEach(snapshot.sourceStats) { stat in\n            OverviewCard {\n              VStack(alignment: .leading, spacing: 8) {\n                HStack(alignment: .firstTextBaseline) {\n                  Text(stat.displayName).font(.headline)\n                  Spacer()\n                  Text(\"\\(stat.sessionCount) sessions\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                }\n                \n                VStack(alignment: .leading, spacing: 4) {\n                  Label {\n                    Text(\"Total \\(TokenFormatter.short(stat.totalTokens)) tokens\")\n                  } icon: {\n                    Image(systemName: \"text.quote\")\n                  }\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                    \n                    Label {\n                        Text(\"Avg \\(Self.durationFormatter.string(from: stat.avgDuration) ?? \"—\")\")\n                    } icon: {\n                        Image(systemName: \"clock\")\n                    }\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                }\n                .padding(.top, 4)\n              }\n            }\n          }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private var recentSection: some View {\n    RecentSessionsListView(\n      sessions: snapshot.recentSessions,\n      emptyMessage: \"Start a new Codex or Claude session to populate your history.\",\n      projectInfoProvider: { session in viewModel.resolveProject(for: session) },\n      projectColumnWidth: 120,\n      onSelectSession: onSelectSession,\n      onSelectProject: onSelectProject\n    )\n  }\n\n  private static let durationFormatter: DateComponentsFormatter = {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = [.hour, .minute]\n    formatter.unitsStyle = .abbreviated\n    formatter.zeroFormattingBehavior = .dropLeading\n    return formatter\n  }()\n}\n\nprivate struct OverviewLoadingPlaceholder: View {\n  var body: some View {\n    VStack(alignment: .center, spacing: 24) {\n      // Loading indicator with message\n      VStack(spacing: 12) {\n        ProgressView()\n          .scaleEffect(1.2)\n          .progressViewStyle(.circular)\n\n        VStack(spacing: 4) {\n          Text(\"Building Session Index\")\n            .font(.headline)\n            .foregroundStyle(.primary)\n          Text(\"Scanning and caching session files for fast access…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .multilineTextAlignment(.center)\n        }\n      }\n      .padding(.top, 40)\n\n      // Skeleton preview\n      VStack(alignment: .leading, spacing: 16) {\n        RoundedRectangle(cornerRadius: 12)\n          .fill(Color.secondary.opacity(0.1))\n          .frame(height: 160)\n          .overlay(\n            VStack(alignment: .leading, spacing: 8) {\n              HStack {\n                RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.2)).frame(width: 80, height: 10)\n                Spacer()\n              }\n              RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.2)).frame(width: 140, height: 10)\n              RoundedRectangle(cornerRadius: 4).fill(Color.secondary.opacity(0.15)).frame(height: 80)\n            }\n            .padding()\n          )\n        HStack(spacing: 12) {\n          ForEach(0..<4) { _ in\n            RoundedRectangle(cornerRadius: 12)\n              .fill(Color.secondary.opacity(0.08))\n              .frame(height: 110)\n          }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n        RoundedRectangle(cornerRadius: 12)\n          .fill(Color.secondary.opacity(0.08))\n          .frame(height: 120)\n      }\n      .redacted(reason: .placeholder)\n    }\n  }\n}\n"
  },
  {
    "path": "views/Content/ContentView+Detail.swift",
    "content": "import SwiftUI\n\nextension ContentView {\n  var detailColumn: some View {\n    VStack(spacing: 0) {\n      if viewModel.projectWorkspaceMode == .review, let project = currentSelectedProject(), let dir = project.directory, !dir.isEmpty {\n        // Project-level Review: detail renders right-only (header + diff/preview)\n        reviewRightColumn(project: project, directory: dir)\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if viewModel.projectWorkspaceMode == .overview {\n        projectOverviewContent()\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if viewModel.projectWorkspaceMode == .agents {\n        projectAgentsContent()\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if viewModel.projectWorkspaceMode == .memory {\n        placeholderSurface(title: \"Memory\", systemImage: \"bookmark\")\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if viewModel.projectWorkspaceMode == .settings {\n        projectOverviewContent() // Now Project Settings shows the Overview\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        // Tasks or Sessions mode (both show session-focused UI)\n        detailActionBar\n          .padding(.horizontal, 16)\n          .padding(.vertical, 12)\n\n        Divider()\n\n        mainDetailContent\n          .animation(nil, value: isListHidden)\n      }\n    }\n    .padding(.bottom, statusBarReservedHeight)\n    .frame(minWidth: 640)\n    .onChange(of: selectedDetailTab) { newVal in\n      // Coerce legacy .review to .timeline in Tasks mode (session-level Git Review removed)\n      if newVal == .review { selectedDetailTab = .timeline; return }\n      if let focused = focusedSummary {\n        sessionDetailTabs[focused.id] = newVal\n      }\n    }\n    .onChange(of: viewModel.projectWorkspaceMode) { newMode in\n      // When switching into project Review, ensure repository authorization\n      if newMode == .review, let p = currentSelectedProject(), let dir = p.directory, !dir.isEmpty {\n        ensureRepoAccessForProjectReview(directory: dir)\n      }\n    }\n    .onChange(of: focusedSummary?.id) { newId in\n      if let newId = newId {\n        selectedDetailTab = sessionDetailTabs[newId] ?? .timeline\n      } else {\n        selectedDetailTab = .timeline\n      }\n      if selectedDetailTab == .review { selectedDetailTab = .timeline }\n      normalizeDetailTabForTerminalAvailability()\n    }\n    .onChange(of: runningSessionIDs) { _ in\n      normalizeDetailTabForTerminalAvailability()\n      synchronizeSelectedTerminalKey()\n    }\n  }\n}\n\nextension ContentView {\n  func currentSelectedProject() -> Project? {\n    guard let pid = viewModel.selectedProjectIDs.first else { return nil }\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  @ViewBuilder\n  func projectReviewContent(project: Project, directory: String) -> some View {\n    let ws = directory\n    let stateBinding = Binding<ReviewPanelState>(\n      get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() },\n      set: { viewModel.projectReviewPanelStates[project.id] = $0 }\n    )\n    EquatableGitChangesContainer(\n      key: .init(\n        workingDirectoryPath: ws,\n        projectDirectoryPath: ws,\n        state: stateBinding.wrappedValue,\n        refreshToken: reviewRefreshToken\n      ),\n      workingDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n      projectDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n      presentation: .full,\n      preferences: viewModel.preferences,\n      onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) },\n      refreshToken: reviewRefreshToken,\n      savedState: stateBinding\n    )\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    // Match Tasks detail layout: no extra outer padding; header/content provide their own\n  }\n\n  @ViewBuilder\n  func reviewRightColumn(project: Project, directory: String) -> some View {\n    let ws = directory\n    let stateBinding = Binding<ReviewPanelState>(\n      get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() },\n      set: { viewModel.projectReviewPanelStates[project.id] = $0 }\n    )\n    let vm = projectReviewVM(for: project.id)\n    EquatableGitChangesContainer(\n      key: .init(\n        workingDirectoryPath: ws,\n        projectDirectoryPath: ws,\n        state: stateBinding.wrappedValue,\n        refreshToken: reviewRefreshToken\n      ),\n      workingDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n      projectDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n      presentation: .full,\n      regionLayout: .rightOnly,\n      preferences: viewModel.preferences,\n      onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) },\n      externalVM: vm,\n      refreshToken: reviewRefreshToken,\n      savedState: stateBinding\n    )\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    // Match Tasks detail layout: no extra outer padding; header/content provide their own\n  }\n\n  @ViewBuilder\n  func projectOverviewContent() -> some View {\n    if viewModel.selectedProjectIDs.isEmpty {\n      // Global overview when no specific project is selected\n      AllOverviewView(\n        viewModel: overviewViewModel,\n        preferences: viewModel.preferences,\n        onSelectSession: { focusSessionFromOverview($0) },\n        onResumeSession: { resumeFromList($0) },\n        onFocusToday: { focusTodayFromOverview() }, // No longer visible but still part of API\n        onSelectDate: { focusDateFromOverview($0) },\n        onSelectProject: { focusProjectFromOverview(id: $0) }\n      )\n    } else if let project = currentSelectedProject() {\n      // Project-specific overview\n      ProjectSpecificOverviewContainerView(\n          sessionListViewModel: viewModel,\n          project: project,\n          preferences: viewModel.preferences,\n          refreshToken: projectOverviewRefreshToken,\n          onSelectSession: { focusSessionFromOverview($0) },\n          onResumeSession: { resumeFromList($0) },\n          onFocusToday: { focusTodayFromOverview() },\n          onEditProject: { presentProjectEditor(for: $0) }\n      )\n      .id(project.id)\n    } else {\n      // Fallback placeholder if no project and no global overview\n      placeholderSurface(title: \"Select a Project\", systemImage: \"folder.badge.questionmark\")\n    }\n  }\n\n  @ViewBuilder\n  func projectAgentsContent() -> some View {\n    if let project = currentSelectedProject(), let directory = project.directory {\n      ProjectAgentsView(\n        projectDirectory: directory,\n        preferences: viewModel.preferences,\n        refreshToken: agentsRefreshToken\n      )\n    } else {\n      placeholderSurface(title: \"No Project Selected\", systemImage: \"folder.badge.questionmark\")\n    }\n  }\n\n\n  @ViewBuilder\n  func placeholderSurface(title: String, systemImage: String) -> some View {\n    VStack(alignment: .center, spacing: 8) {\n      Spacer(minLength: 0)\n      Image(systemName: systemImage)\n        .font(.system(size: 32, weight: .regular))\n        .foregroundStyle(.secondary)\n      Text(title)\n        .font(.title3.weight(.semibold))\n        .foregroundStyle(.secondary)\n      Spacer(minLength: 0)\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n\n  func focusSessionFromOverview(_ summary: SessionSummary) {\n    let sessionProjectId = viewModel.projectIdForSession(summary.id)\n    if sessionProjectId == nil {\n      focusOnSession(\n        summary,\n        explicitProjectId: SessionListViewModel.otherProjectId,\n        searchTerm: nil,\n        filterConversation: false\n      )\n      viewModel.setSelectedDay(nil)\n    } else {\n      focusOnSession(\n        summary,\n        explicitProjectId: sessionProjectId,\n        searchTerm: nil,\n        filterConversation: false\n      )\n    }\n  }\n\n  func focusTodayFromOverview() {\n    let calendar = Calendar.current\n    let today = calendar.startOfDay(for: Date())\n    viewModel.selectedDay = today\n    viewModel.selectedDays = [today]\n    viewModel.setSidebarMonthStart(today)\n    isListHidden = false\n  }\n\n  func focusDateFromOverview(_ date: Date) {\n      let calendar = Calendar.current\n      let day = calendar.startOfDay(for: date)\n      viewModel.selectedDay = day\n      viewModel.selectedDays = [day]\n      viewModel.setSidebarMonthStart(day)\n      isListHidden = false\n  }\n\n  func focusProjectFromOverview(id: String) {\n    viewModel.setSelectedProject(id)\n    isListHidden = false\n    if id == SessionListViewModel.otherProjectId {\n      viewModel.projectWorkspaceMode = .sessions\n    } else {\n      viewModel.projectWorkspaceMode = .settings\n    }\n  }\n\n  func presentProjectEditor(for project: Project) {\n    guard project.id != SessionListViewModel.otherProjectId else { return }\n    projectEditorTarget = project\n  }\n}\n"
  },
  {
    "path": "views/Content/ContentView+DetailActionBar.swift",
    "content": "import AppKit\nimport SwiftUI\n\nextension ContentView {\n  // Sticky detail action bar at the top of the detail column\n  var detailActionBar: some View {\n    HStack(spacing: 12) {\n      // Left: view mode segmented (Timeline | Terminal)\n      Group {\n        if viewModel.preferences.defaultResumeUseEmbeddedTerminal {\n          let items: [SegmentedIconPicker<ContentView.DetailTab>.Item] = [\n            .init(title: \"Timeline\", systemImage: \"clock\", tag: .timeline),\n            .init(title: \"Terminal\", systemImage: \"terminal\", tag: .terminal),\n          ]\n          let selection = Binding<ContentView.DetailTab>(\n            get: { selectedDetailTab },\n            set: { newValue in\n              if newValue == .terminal {\n                if hasAvailableEmbeddedTerminal() {\n                  if let focused = focusedSummary, runningSessionIDs.contains(focused.id) {\n                    selectedTerminalKey = focused.id\n                  } else if let anchorId = fallbackRunningAnchorId() {\n                    selectedTerminalKey = anchorId\n                  } else {\n                    selectedTerminalKey = runningSessionIDs.first\n                  }\n                  selectedDetailTab = .terminal\n                } else if let focused = focusedSummary {\n                  pendingTerminalLaunch = PendingTerminalLaunch(session: focused)\n                }\n              } else {\n                selectedDetailTab = newValue\n              }\n            }\n          )\n          SegmentedIconPicker(items: items, selection: selection)\n        } else {\n          let items: [SegmentedIconPicker<ContentView.DetailTab>.Item] = [\n            .init(title: \"Timeline\", systemImage: \"clock\", tag: .timeline)\n          ]\n          SegmentedIconPicker(items: items, selection: $selectedDetailTab)\n        }\n      }\n\n      Spacer(minLength: 12)\n\n      // Right: New…, Resume…, Reveal, Prompts, Export/Return, Max\n      if let focused = focusedSummary {\n        // New split control: hidden in Terminal tab\n        if selectedDetailTab != .terminal {\n          let embeddedPreferredNew =\n            viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {\n              if embeddedPreferredNew {\n                startEmbeddedNew(for: focused)\n              } else {\n                // default: external terminal flow\n                startNewSession(for: focused)\n              }\n            },\n            items: {\n              let allowed = Set(viewModel.allowedSources(for: focused))\n              let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini]\n              let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted()\n              let embeddedEnabled = viewModel.preferences.isEmbeddedTerminalEnabled\n\n              func sourceKey(_ source: SessionSource) -> String {\n                switch source {\n                case .codexLocal: return \"codex-local\"\n                case .codexRemote(let host): return \"codex-\\(host)\"\n                case .claudeLocal: return \"claude-local\"\n                case .claudeRemote(let host): return \"claude-\\(host)\"\n                case .geminiLocal: return \"gemini-local\"\n                case .geminiRemote(let host): return \"gemini-\\(host)\"\n                }\n              }\n\n              func launchItems(for source: SessionSource) -> [SplitMenuItem] {\n                let key = sourceKey(source)\n                var items = externalTerminalMenuItems(idPrefix: key) { profile in\n                  launchNewSession(for: focused, using: source, profile: profile)\n                }\n                if embeddedEnabled {\n                  let embedded = embeddedTerminalProfile()\n                  items.insert(\n                    .init(\n                      id: \"\\(key)-\\(embedded.id)\",\n                      kind: .action(\n                        title: embedded.displayTitle,\n                        systemImage: \"macwindow\",\n                        run: { launchNewSession(for: focused, using: source, profile: embedded) }\n                      )\n                    ),\n                    at: 0\n                  )\n                }\n                return items\n              }\n\n              func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource {\n                switch base {\n                case .codex: return .codexRemote(host: host)\n                case .claude: return .claudeRemote(host: host)\n                case .gemini: return .geminiRemote(host: host)\n                }\n              }\n\n              func providerAssetIcon(_ source: ProjectSessionSource) -> String {\n                switch source {\n                case .codex: return \"ChatGPTIcon\"\n                case .claude: return \"ClaudeIcon\"\n                case .gemini: return \"GeminiIcon\"\n                }\n              }\n\n              func assetIconForSessionSource(_ source: SessionSource) -> String {\n                switch source.baseKind {\n                case .codex: return \"ChatGPTIcon\"\n                case .claude: return \"ClaudeIcon\"\n                case .gemini: return \"GeminiIcon\"\n                }\n              }\n\n              var menuItems: [SplitMenuItem] = []\n\n              for base in requestedOrder where allowed.contains(base) {\n                var providerItems = launchItems(for: base.sessionSource)\n                if !enabledRemoteHosts.isEmpty {\n                  providerItems.append(.init(kind: .separator))\n                  for host in enabledRemoteHosts {\n                    let remote = remoteSource(for: base, host: host)\n                    providerItems.append(\n                      .init(kind: .submenu(title: host, systemImage: \"network\", items: launchItems(for: remote)))\n                    )\n                  }\n                }\n                menuItems.append(\n                  .init(\n                    kind: .submenu(\n                      title: base.displayName,\n                      assetImage: providerAssetIcon(base),\n                      items: providerItems\n                    )\n                  )\n                )\n              }\n\n              if menuItems.isEmpty {\n                let fallbackSource = focused.source\n                menuItems.append(\n                  .init(\n                    kind: .submenu(\n                      title: fallbackSource.branding.displayName,\n                      assetImage: assetIconForSessionSource(fallbackSource),\n                      items: launchItems(for: fallbackSource)\n                    )\n                  )\n                )\n              }\n              return menuItems\n            }()\n          )\n        }\n\n        // Resume split control: hidden in Terminal tab\n        if selectedDetailTab != .terminal {\n          let embeddedPreferred =\n            viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled\n          SplitPrimaryMenuButton(\n            title: \"Resume\",\n            systemImage: \"play.fill\",\n            primary: {\n              if embeddedPreferred {\n                startEmbedded(for: focused)\n              } else {\n                openPreferredExternal(for: focused)\n              }\n            },\n            items: {\n              var items: [SplitMenuItem] = []\n              let embeddedEnabled = viewModel.preferences.isEmbeddedTerminalEnabled\n              func sourceKey(_ source: SessionSource) -> String {\n                switch source {\n                case .codexLocal: return \"codex-local\"\n                case .codexRemote(let host): return \"codex-\\(host)\"\n                case .claudeLocal: return \"claude-local\"\n                case .claudeRemote(let host): return \"claude-\\(host)\"\n                case .geminiLocal: return \"gemini-local\"\n                case .geminiRemote(let host): return \"gemini-\\(host)\"\n                }\n              }\n\n              if embeddedEnabled {\n                items.append(\n                  .init(\n                    id: \"resume-embedded-\\(focused.id)\",\n                    kind: .action(\n                      title: \"CodMate\",\n                      systemImage: \"macwindow\",\n                      run: { startEmbedded(for: focused) }\n                    )\n                  )\n                )\n              }\n\n              items.append(\n                contentsOf: externalTerminalMenuItems(idPrefix: \"resume-\\(sourceKey(focused.source))\") {\n                  profile in\n                  launchResume(for: focused, using: focused.source, profile: profile)\n                })\n              let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts\n              if !enabledRemoteHosts.isEmpty {\n                items.append(.init(kind: .separator))\n                let currentKind = focused.source.projectSource\n                for host in enabledRemoteHosts.sorted() {\n                  let remoteSrc: SessionSource =\n                    (currentKind == .codex)\n                    ? .codexRemote(host: host)\n                    : .claudeRemote(host: host)\n                  let remoteName = remoteSrc.branding.displayName\n                  items.append(\n                    contentsOf: externalTerminalMenuItems(\n                      idPrefix: \"resume-\\(sourceKey(remoteSrc))\",\n                      titlePrefix: \"\\(remoteName) with \"\n                    ) { profile in\n                      launchResume(for: focused, using: remoteSrc, profile: profile)\n                    })\n                }\n              }\n              return items\n            }()\n          )\n        }\n\n        // Reveal in Finder (chromed icon)\n        ChromedIconButton(systemImage: \"finder\", help: \"Reveal in Finder\") {\n          viewModel.reveal(session: focused)\n        }\n\n        // Prompts (insert into embedded terminal when available, fallback to clipboard copy)\n        let promptsMode: PromptsPopover.Mode? = {\n          if selectedDetailTab == .terminal {\n            guard runningSessionIDs.contains(focused.id) else { return nil }\n            return .insert(terminalKey: focused.id)\n          }\n          return .copy\n        }()\n\n        if let promptsMode {\n          ChromedIconButton(systemImage: \"text.insert\", help: \"Prompts\") {\n            showPromptPicker.toggle()\n          }\n          .popover(isPresented: $showPromptPicker) {\n            PromptsPopover(\n              workingDirectory: workingDirectory(for: focused),\n              mode: promptsMode,\n              builtin: builtinPrompts(),\n              query: $promptQuery,\n              loaded: $loadedPrompts,\n              hovered: $hoveredPromptKey,\n              pendingDelete: $pendingDelete,\n              onDismiss: { showPromptPicker = false }\n            )\n          }\n        }\n\n        // Sync from Task (when focused session is part of a Task and local)\n        if let workspace = viewModel.workspaceVM,\n           !focused.isRemote,\n           workspace.tasks.contains(where: { $0.sessionIds.contains(focused.id) }) {\n          ChromedIconButton(systemImage: \"arrow.triangle.2.circlepath\", help: \"Sync from Task\") {\n            syncFromTask(for: focused)\n          }\n        }\n\n        // Export Markdown or Return to History\n        if selectedDetailTab != .terminal {\n          ChromedIconButton(\n            systemImage: \"square.and.arrow.up\", help: \"Export conversation as Markdown\"\n          ) {\n            exportMarkdownForFocused()\n          }\n        } else {\n          ChromedIconButton(systemImage: \"arrow.uturn.backward\", help: \"Return to History\") {\n            // Close the terminal currently displayed in the Terminal tab.\n            let id = visibleTerminalKeyInDetail() ?? focused.id\n            softReturnPending = true\n            requestStopEmbedded(forKey: id)\n          }\n        }\n      } else if let project = selectedProjectForDetailNew() {\n        // When there is no focused session but a single real project\n        // is selected, still offer project-scoped New entry so users\n        // can start Codex/Claude sessions directly from the detail bar.\n        if selectedDetailTab != .terminal {\n          let embeddedPreferredNew =\n            viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {\n              if embeddedPreferredNew {\n                // Defer to shared embedded flow for project-level New\n                viewModel.newSession(project: project)\n              } else {\n                startExternalNewForProject(project)\n              }\n            },\n            items: buildProjectNewMenuItems(for: project)\n          )\n        }\n      }\n\n    }\n  }\n}\n\n// MARK: - Project-level New helpers (detail toolbar)\n\nprivate extension ContentView {\n  /// Single selected real project for project-scoped New.\n  func selectedProjectForDetailNew() -> Project? {\n    guard viewModel.selectedProjectIDs.count == 1,\n      let pid = viewModel.selectedProjectIDs.first\n    else { return nil }\n    // Exclude synthetic \"Other\" bucket\n    if pid == SessionListViewModel.otherProjectId { return nil }\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  // Minimal shell path escaper for cd commands in clipboard\n  func shellEscapedPath(_ path: String) -> String {\n    if path.isEmpty { return \"''\" }\n    let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: \"/.-_\"))\n    let needsQuotes = path.rangeOfCharacter(from: allowed.inverted) != nil\n    var output = path.replacingOccurrences(of: \"'\", with: \"'\\\\''\")\n    if needsQuotes { output = \"'\\(output)'\" }\n    return output\n  }\n\n  // Build split menu items for project-level New actions\n  func buildProjectNewMenuItems(for project: Project) -> [SplitMenuItem] {\n    var items: [SplitMenuItem] = []\n    let profiles = externalTerminalMenuProfiles()\n    func runCodex(for profile: ExternalTerminalProfile) {\n      let dir =\n        (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n          $0.isEmpty ? nil : $0\n        } ?? NSHomeDirectory()\n      let fallbackCommand = simpleProjectNewCommands(project: project)\n      let cmd = viewModel.buildNewProjectCLIInvocation(project: project)\n      let shouldCopy = viewModel.shouldCopyCommandsToClipboard\n      let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled\n      if profile.usesWarpCommands {\n        guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile)\n        else {\n          return\n        }\n        viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n        if shouldCopy && shouldNotify {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in \\(profile.displayTitle).\")\n          }\n        }\n        return\n      }\n      if profile.isTerminal {\n        if shouldCopy {\n          let pb = NSPasteboard.general\n          pb.clearContents()\n          pb.setString(fallbackCommand + \"\\n\", forType: .string)\n        }\n        _ = viewModel.openAppleTerminal(at: dir)\n        if shouldCopy && shouldNotify {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in Terminal.\")\n          }\n        }\n        return\n      }\n\n      if !profile.supportsCommandResolved, shouldCopy {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(fallbackCommand + \"\\n\", forType: .string)\n      }\n      let runCommand = profile.supportsDirectoryResolved ? cmd : fallbackCommand\n      let inline = profile.supportsCommandResolved ? runCommand : nil\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline)\n      if !profile.supportsCommandResolved, shouldCopy, shouldNotify {\n        Task {\n          await SystemNotifier.shared.notify(\n            title: \"CodMate\", body: \"Command copied. Paste it in \\(profile.displayTitle).\")\n        }\n      }\n    }\n\n    // Project-level Claude invocation\n    func runClaude(for profile: ExternalTerminalProfile) {\n      let dir =\n        (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n          $0.isEmpty ? nil : $0\n        } ?? NSHomeDirectory()\n      let cmd = buildClaudeProjectInvocation(for: project)\n      let cdCommand = \"cd \" + shellEscapedPath(dir) + \"\\n\" + cmd\n      let shouldCopy = viewModel.shouldCopyCommandsToClipboard\n      let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled\n      if profile.isTerminal {\n        if shouldCopy {\n          let pb = NSPasteboard.general\n          pb.clearContents()\n          pb.setString(cdCommand + \"\\n\", forType: .string)\n        }\n        _ = viewModel.openAppleTerminal(at: dir)\n        if shouldCopy && shouldNotify {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in Terminal.\")\n          }\n        }\n        return\n      }\n\n      if !profile.supportsCommandResolved, shouldCopy {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(cdCommand + \"\\n\", forType: .string)\n      }\n      let runCommand = profile.supportsDirectoryResolved ? cmd : cdCommand\n      let inline = profile.supportsCommandResolved ? runCommand : nil\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline)\n      if !profile.supportsCommandResolved, shouldCopy, shouldNotify {\n        Task {\n          await SystemNotifier.shared.notify(\n            title: \"CodMate\", body: \"Command copied. Paste it in \\(profile.displayTitle).\")\n        }\n      }\n    }\n    func runGemini(for profile: ExternalTerminalProfile) {\n      let dir =\n        (project.directory?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n          $0.isEmpty ? nil : $0\n        } ?? NSHomeDirectory()\n      let cmd = buildGeminiProjectInvocation(for: project)\n      let cdCommand = \"cd \" + shellEscapedPath(dir) + \"\\n\" + cmd\n      let shouldCopy = viewModel.shouldCopyCommandsToClipboard\n      let shouldNotify = viewModel.preferences.commandCopyNotificationsEnabled\n\n      if profile.isTerminal {\n        if shouldCopy {\n          let pb = NSPasteboard.general\n          pb.clearContents()\n          pb.setString(cdCommand + \"\\n\", forType: .string)\n        }\n        _ = viewModel.openAppleTerminal(at: dir)\n        if shouldCopy && shouldNotify {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in Terminal.\")\n          }\n        }\n        return\n      }\n\n      if !profile.supportsCommandResolved, shouldCopy {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(cdCommand + \"\\n\", forType: .string)\n      }\n      let runCommand = profile.supportsDirectoryResolved ? cmd : cdCommand\n      let inline = profile.supportsCommandResolved ? runCommand : nil\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: inline)\n      if !profile.supportsCommandResolved, shouldCopy, shouldNotify {\n        Task {\n          await SystemNotifier.shared.notify(\n            title: \"CodMate\", body: \"Command copied. Paste it in \\(profile.displayTitle).\")\n        }\n      }\n    }\n\n    // Two-level menu: provider -> terminals\n    items.append(\n      .init(\n        id: \"provider-codex\",\n        kind: .submenu(\n          title: \"Codex\",\n          assetImage: \"ChatGPTIcon\",\n          items: externalTerminalMenuItems(idPrefix: \"project-codex\", profiles: profiles) {\n            profile in\n            runCodex(for: profile)\n          }\n        )\n      )\n    )\n    items.append(\n      .init(\n        id: \"provider-claude\",\n        kind: .submenu(\n          title: \"Claude\",\n          assetImage: \"ClaudeIcon\",\n          items: externalTerminalMenuItems(idPrefix: \"project-claude\", profiles: profiles) {\n            profile in\n            runClaude(for: profile)\n          }\n        )\n      )\n    )\n    items.append(\n      .init(\n        id: \"provider-gemini\",\n        kind: .submenu(\n          title: \"Gemini\",\n          assetImage: \"GeminiIcon\",\n          items: externalTerminalMenuItems(idPrefix: \"project-gemini\", profiles: profiles) {\n            profile in\n            runGemini(for: profile)\n          }\n        )\n      )\n    )\n    return items\n  }\n\n  // Build external Terminal flow exactly like SessionListColumnView's project New\n  // external branch, but scoped to the detail toolbar.\n  func startExternalNewForProject(_ project: Project) {\n    guard let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n      id: viewModel.preferences.defaultResumeExternalAppId\n    ) else { return }\n    let dir: String = {\n      let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n      return d.isEmpty ? NSHomeDirectory() : d\n    }()\n    if profile.isNone {\n      _ = viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile)\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n    if profile.usesWarpCommands {\n      guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile) else {\n        return\n      }\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n    } else if profile.isTerminal {\n      if viewModel.shouldCopyCommandsToClipboard {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(simpleProjectNewCommands(project: project) + \"\\n\", forType: .string)\n      }\n      _ = viewModel.openAppleTerminal(at: dir)\n    } else if !profile.isNone {\n      let cmd = profile.supportsCommandResolved\n        ? viewModel.buildNewProjectCLIInvocation(project: project)\n        : nil\n      if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(simpleProjectNewCommands(project: project) + \"\\n\", forType: .string)\n      }\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n    }\n    if viewModel.shouldCopyCommandsToClipboard\n      && viewModel.preferences.commandCopyNotificationsEnabled\n    {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n    // Hint + targeted refresh aligns with viewModel.newSession external path\n    viewModel.setIncrementalHintForCodexToday()\n    Task { await viewModel.refreshIncrementalForNewCodexToday() }\n  }\n\n  func simpleProjectNewCommands(project: Project) -> String {\n    let dir: String = {\n      let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n      return d.isEmpty ? NSHomeDirectory() : d\n    }()\n    let cd = \"cd \" + shellEscapedPath(dir)\n    let cmd = viewModel.buildNewProjectCLIInvocation(project: project)\n    return cd + \"\\n\" + cmd\n  }\n\n  /// Sync shared Task context for the focused session and expose it to the running CLI.\n  /// - In Timeline tab: regenerates the context file and copies a prompt with path hint.\n  /// - In Terminal tab (embedded): regenerates the context file and inserts the prompt\n  ///   into the embedded terminal input for this session.\n  func syncFromTask(for focused: SessionSummary) {\n    guard !focused.isRemote else { return }\n    guard let workspace = viewModel.workspaceVM else { return }\n    guard let task = workspace.tasks.first(where: { $0.sessionIds.contains(focused.id) }) else {\n      return\n    }\n\n    Task { @MainActor in\n      _ = await workspace.syncTaskContext(taskId: task.id)\n      let taskIdString = task.id.uuidString\n      let pathHint = \"~/.codmate/tasks/context-\\(taskIdString).md\"\n      let promptLines: [String] = [\n        \"The shared context for the current Task has been updated and saved to a local file:\",\n        pathHint,\n        \"\",\n        \"Before answering the next question, if needed, please read this file first to understand the task history and related constraints.\"\n      ]\n      let text = promptLines.joined(separator: \"\\n\")\n\n      if selectedDetailTab == .terminal, runningSessionIDs.contains(focused.id) {\n        // Send text directly to the Ghostty terminal\n        if let scrollView = GhosttySessionManager.shared.getScrollView(for: focused.id) {\n          scrollView.surfaceView.sendText(text + \"\\n\")\n        } else {\n          // Fallback to clipboard if terminal not found\n          let pb = NSPasteboard.general\n          pb.clearContents()\n          pb.setString(text + \"\\n\", forType: .string)\n        }\n      } else {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(text + \"\\n\", forType: .string)\n      }\n\n      await SystemNotifier.shared.notify(\n        title: \"CodMate\",\n        body: \"Task context synced. Prompt is ready for use.\")\n    }\n  }\n\n  // Build a Claude invocation honoring project/default model and runtime flags\n  func buildClaudeProjectInvocation(for project: Project) -> String {\n    viewModel.buildClaudeProjectInvocation(project: project)\n  }\n\n  // Build a Gemini invocation honoring resume options.\n  func buildGeminiProjectInvocation(for project: Project) -> String {\n    viewModel.buildGeminiProjectInvocation()\n  }\n}\n\n// MARK: - SegmentedIconPicker (AppKit-backed)\nstruct SegmentedIconPicker<Selection: Hashable>: NSViewRepresentable {\n  struct Item {\n    let title: String\n    let systemImage: String\n    let tag: Selection\n    let isEnabled: Bool\n\n    init(title: String, systemImage: String, tag: Selection, isEnabled: Bool = true) {\n      self.title = title\n      self.systemImage = systemImage\n      self.tag = tag\n      self.isEnabled = isEnabled\n    }\n  }\n\n  let items: [Item]\n  @Binding var selection: Selection\n  var isInteractive: Bool = true\n  var iconScale: CGFloat = 1\n\n  func makeCoordinator() -> Coordinator {\n    Coordinator(selection: $selection, items: items, iconScale: iconScale)\n  }\n\n  func makeNSView(context: Context) -> NSSegmentedControl {\n    let control = NSSegmentedControl()\n    control.translatesAutoresizingMaskIntoConstraints = true\n    control.segmentStyle = .automatic\n    control.controlSize = .regular\n    control.trackingMode = .selectOne\n    control.target = context.coordinator\n    control.action = #selector(Coordinator.changed(_:))\n    control.setContentHuggingPriority(.required, for: .horizontal)\n    control.setContentCompressionResistancePriority(.required, for: .horizontal)\n    rebuild(control)\n    context.coordinator.control = control\n    context.coordinator.isInteractive = isInteractive\n    return control\n  }\n\n  func updateNSView(_ control: NSSegmentedControl, context: Context) {\n    // Update coordinator's items to ensure it has the latest data\n    context.coordinator.items = items\n    context.coordinator.iconScale = iconScale\n\n    if control.segmentCount != items.count { rebuild(control) }\n    for (i, it) in items.enumerated() {\n      control.setLabel(it.title, forSegment: i)\n      if let img = NSImage(systemSymbolName: it.systemImage, accessibilityDescription: nil) {\n        // Use template mode to allow proper tinting in selected state\n        img.isTemplate = true\n\n        // Apply icon scaling\n        let scaledImg = scaleImage(img, scale: iconScale)\n        control.setImage(scaledImg, forSegment: i)\n        control.setImageScaling(.scaleNone, forSegment: i)\n      }\n      control.setEnabled(it.isEnabled, forSegment: i)\n    }\n    if let idx = items.firstIndex(where: { $0.tag == selection }) {\n      control.selectedSegment = idx\n    } else {\n      control.selectedSegment = -1\n    }\n    context.coordinator.isInteractive = isInteractive\n  }\n\n  private func scaleImage(_ image: NSImage, scale: CGFloat) -> NSImage {\n    let originalSize = image.size\n    let scaledSize = NSSize(width: originalSize.width * scale, height: originalSize.height * scale)\n\n    // Add left padding to the icon\n    let leftPadding: CGFloat = 4\n    let newSize = NSSize(width: scaledSize.width + leftPadding, height: scaledSize.height)\n\n    let scaledImage = NSImage(size: newSize)\n    scaledImage.isTemplate = true  // Preserve template mode for proper tinting\n    scaledImage.lockFocus()\n    image.draw(\n      in: NSRect(x: leftPadding, y: 0, width: scaledSize.width, height: scaledSize.height),\n      from: NSRect(origin: .zero, size: originalSize),\n      operation: .copy,\n      fraction: 1.0)\n    scaledImage.unlockFocus()\n    return scaledImage\n  }\n\n  private func rebuild(_ control: NSSegmentedControl) {\n    control.segmentCount = items.count\n    for (i, it) in items.enumerated() {\n      control.setLabel(it.title, forSegment: i)\n      if let img = NSImage(systemSymbolName: it.systemImage, accessibilityDescription: nil) {\n        // Use template mode to allow proper tinting in selected state\n        img.isTemplate = true\n        let scaledImg = scaleImage(img, scale: iconScale)\n        control.setImage(scaledImg, forSegment: i)\n        control.setImageScaling(.scaleNone, forSegment: i)\n      }\n      control.setEnabled(it.isEnabled, forSegment: i)\n    }\n  }\n\n  final class Coordinator: NSObject {\n    weak var control: NSSegmentedControl?\n    var selection: Binding<Selection>\n    var items: [Item]\n    var isInteractive: Bool = true\n    var iconScale: CGFloat = 1.0\n\n    init(selection: Binding<Selection>, items: [Item], iconScale: CGFloat = 1.0) {\n      self.selection = selection\n      self.items = items\n      self.iconScale = iconScale\n    }\n\n    @objc func changed(_ sender: NSSegmentedControl) {\n      guard isInteractive else { return }\n      let idx = sender.selectedSegment\n      guard idx >= 0 && idx < items.count else { return }\n      // Directly update the binding\n      selection.wrappedValue = items[idx].tag\n    }\n  }\n}\n\n// MARK: - Chromed icon button to match split buttons\nprivate struct ChromedIconButton: View {\n  let systemImage: String\n  var help: String? = nil\n  let action: () -> Void\n  var body: some View {\n    let h: CGFloat = 24\n    Button(action: action) {\n      Image(systemName: systemImage)\n        .font(.system(size: 13, weight: .semibold))\n        .foregroundStyle(.primary)\n        .padding(.horizontal, 8)\n        .frame(height: h)\n        .frame(minWidth: h)  // keep a minimum square feel when padding is small\n        .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))\n    }\n    .buttonStyle(.plain)\n    .background(Color(nsColor: .controlBackgroundColor))\n    .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))\n    .overlay(\n      RoundedRectangle(cornerRadius: 6, style: .continuous)\n        .stroke(Color.secondary.opacity(0.25), lineWidth: 1)\n    )\n    .help(help ?? \"\")\n  }\n}\n\n// MARK: - Prompts popover content\nprivate struct PromptsPopover: View {\n  enum Mode {\n    case insert(terminalKey: String)\n    case copy\n\n    var hint: String {\n      switch self {\n      case .insert:\n        return \"Selecting a prompt inserts it into the embedded terminal.\"\n      case .copy:\n        return \"Selecting a prompt copies it to the clipboard.\"\n      }\n    }\n\n  }\n\n  let workingDirectory: String\n  let mode: Mode\n  let builtin: [PresetPromptsStore.Prompt]\n  @Binding var query: String\n  @Binding var loaded: [ContentView.SourcedPrompt]\n  @Binding var hovered: String?\n  @Binding var pendingDelete: ContentView.SourcedPrompt?\n  let onDismiss: () -> Void\n  @FocusState private var searchFocused: Bool\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      HStack {\n        Text(\"Preset Prompts\").font(.headline)\n        Spacer()\n        Button {\n          Task {\n            await PresetPromptsStore.shared.openOrCreatePreferredFile(\n              for: workingDirectory, withTemplate: builtin)\n          }\n        } label: {\n          Image(systemName: \"wrench.and.screwdriver\")\n        }\n        .buttonStyle(.plain)\n        .help(\"Open prompts file\")\n      }\n      Text(mode.hint)\n        .font(.footnote)\n        .foregroundStyle(.secondary)\n        .fixedSize(horizontal: false, vertical: true)\n      TextField(\"Search or type a new command\", text: $query)\n        .textFieldStyle(.roundedBorder)\n        .frame(width: 320)\n        .focused($searchFocused)\n        .onChange(of: query) { _ in reload() }\n\n      ScrollView {\n        VStack(alignment: .leading, spacing: 0) {\n          let rows = filtered()\n          ForEach(rows.indices, id: \\.self) { idx in\n            let sp = rows[idx]\n            let rowKey = sp.command\n            HStack(spacing: 8) {\n              if hovered == rowKey {\n                Button {\n                  Task {\n                    await PresetPromptsStore.shared.delete(\n                      prompt: sp.prompt, location: location(of: sp),\n                      workingDirectory: workingDirectory)\n                  }\n                  reload()\n                } label: {\n                  Image(systemName: \"minus.circle\")\n                }\n                .buttonStyle(.plain)\n                .help(\"Remove\")\n              }\n              Text(sp.label)\n                .font(.system(size: 13, weight: .regular))\n                .frame(maxWidth: .infinity, alignment: .leading)\n            }\n            .padding(.leading, 8)\n            .padding(.trailing, 24)\n            .frame(height: 32)\n            .frame(maxWidth: .infinity, alignment: .leading)\n            .background(idx % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear)\n            .contentShape(Rectangle())\n            .onHover { inside in\n              if inside { hovered = rowKey } else if hovered == rowKey { hovered = nil }\n            }\n            .onTapGesture {\n              handleSelection(sp.command)\n              // Auto-dismiss popover after selecting a preset\n              onDismiss()\n            }\n          }\n          if shouldOfferAdd() {\n            Button {\n              let p = PresetPromptsStore.Prompt(label: query, command: query)\n              Task {\n                _ = await PresetPromptsStore.shared.add(prompt: p, for: workingDirectory)\n                reload()\n              }\n            } label: {\n              Label(\"Add \\(query)\", systemImage: \"plus\")\n            }\n            .buttonStyle(.borderless)\n            .padding(.top, 6)\n            .padding(.trailing, 24)\n          }\n        }\n      }\n      .frame(height: 160)\n    }\n    .padding(12)\n    .onAppear {\n      reload()\n      // Focus search field by default for quick keyboard input\n      DispatchQueue.main.async { self.searchFocused = true }\n    }\n  }\n\n  private func location(of sp: ContentView.SourcedPrompt) -> PresetPromptsStore.PromptLocation {\n    switch sp.source {\n    case .project: return .project\n    case .user: return .user\n    case .builtin: return .builtin\n    }\n  }\n\n  private func handleSelection(_ value: String) {\n    switch mode {\n    case .insert(let terminalKey):\n      // Send text directly to the Ghostty terminal\n      if let scrollView = GhosttySessionManager.shared.getScrollView(for: terminalKey) {\n        scrollView.surfaceView.sendText(value)\n      } else {\n        // Fallback to clipboard if terminal not found\n        copyToClipboard(value)\n      }\n    case .copy:\n      copyToClipboard(value)\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\",\n          body: \"Prompt copied. Paste it into your terminal.\")\n      }\n    }\n  }\n\n  private func copyToClipboard(_ value: String) {\n    let pb = NSPasteboard.general\n    pb.clearContents()\n    pb.setString(value, forType: .string)\n  }\n\n  private func filtered() -> [ContentView.SourcedPrompt] {\n    if query.trimmingCharacters(in: .whitespaces).isEmpty { return loaded }\n    let q = query.lowercased()\n    return loaded.filter {\n      $0.label.lowercased().contains(q) || $0.command.lowercased().contains(q)\n    }\n  }\n\n  private func shouldOfferAdd() -> Bool {\n    let q = query.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !q.isEmpty else { return false }\n    return !loaded.contains(where: { $0.command == q })\n  }\n\n  private func reload() {\n    Task {\n      let store = PresetPromptsStore.shared\n      let project = await store.loadProjectOnly(for: workingDirectory)\n      let user = await store.loadUserOnly()\n      let hidden = await store.loadHidden(for: workingDirectory)\n      var seen = Set<String>()\n      var out: [ContentView.SourcedPrompt] = []\n      func push(_ p: PresetPromptsStore.Prompt, _ src: ContentView.SourcedPrompt.Source) {\n        if hidden.contains(p.command) { return }\n        if seen.insert(p.command).inserted {\n          out.append(ContentView.SourcedPrompt(prompt: p, source: src))\n        }\n      }\n      project.forEach { push($0, .project) }\n      user.forEach { push($0, .user) }\n      builtin.forEach { push($0, .builtin) }\n      await MainActor.run { loaded = out }\n    }\n  }\n}\n"
  },
  {
    "path": "views/Content/ContentView+Helpers.swift",
    "content": "import SwiftUI\nimport AppKit\nimport UniformTypeIdentifiers\n\nextension ContentView {\n    // Split helpers to keep ContentView.swift lean\n\n    var focusedSummary: SessionSummary? {\n        guard !selection.isEmpty else {\n            return viewModel.sections.first?.sessions.first\n        }\n        let all = summaryLookup\n        if let pid = selectionPrimaryId, selection.contains(pid), let s = all[pid] {\n            return s\n        }\n        return selection\n            .compactMap { all[$0] }\n            .sorted { lhs, rhs in\n                (lhs.lastUpdatedAt ?? lhs.startedAt) > (rhs.lastUpdatedAt ?? rhs.startedAt)\n            }\n            .first\n    }\n\n    var summaryLookup: [SessionSummary.ID: SessionSummary] {\n        Dictionary(\n            uniqueKeysWithValues: viewModel.sections\n                .flatMap(\\.sessions)\n                .map { ($0.id, $0) }\n        )\n    }\n\n    func fallbackRunningAnchorId() -> String? {\n        let realIds = Set(summaryLookup.keys)\n        if let id = runningSessionIDs.first(where: { $0.hasPrefix(\"new-anchor:\") }) { return id }\n        return runningSessionIDs.first(where: { !realIds.contains($0) })\n    }\n\n    func synchronizeSelectedTerminalKey() {\n        #if APPSTORE\n            selectedTerminalKey = nil\n        #else\n            if let key = selectedTerminalKey, runningSessionIDs.contains(key) { return }\n            selectedTerminalKey = runningSessionIDs.first\n        #endif\n    }\n\n    func activeTerminalKey() -> String? {\n        #if APPSTORE\n            return nil\n        #else\n            if let key = selectedTerminalKey, runningSessionIDs.contains(key) {\n                return key\n            }\n            return runningSessionIDs.first\n        #endif\n    }\n\n    func syncRunningSessionIDsFromManager() {\n        // Ghostty embeds are driven by view state; no external manager sync required.\n    }\n\n    func hasAvailableEmbeddedTerminal() -> Bool {\n        #if APPSTORE\n            return false\n        #else\n            // Check if there's a terminal available for the focused session\n            guard let focused = focusedSummary else {\n                // No focused session, check if there are any anchor terminals (new sessions)\n                return fallbackRunningAnchorId() != nil\n            }\n            // Check if focused session has a running terminal\n            return runningSessionIDs.contains(focused.id)\n        #endif\n    }\n\n    func visibleTerminalKeyInDetail() -> String? {\n        #if APPSTORE\n            return nil\n        #else\n            guard selectedDetailTab == .terminal else { return nil }\n            if let selected = selectedTerminalKey, runningSessionIDs.contains(selected) {\n                return selected\n            }\n            if let focused = focusedSummary, runningSessionIDs.contains(focused.id) {\n                return focused.id\n            }\n            return fallbackRunningAnchorId()\n        #endif\n    }\n\n    func normalizeDetailTabForTerminalAvailability() {\n        #if APPSTORE\n            if selectedDetailTab == .terminal {\n                selectedDetailTab = .timeline\n            }\n        #else\n            if selectedDetailTab == .terminal && activeTerminalKey() == nil {\n                selectedDetailTab = .timeline\n            }\n        #endif\n    }\n\n    func terminalHostInitialCommands(for key: String) -> String {\n        if let stored = embeddedInitialCommands[key] { return stored }\n        if let summary = summaryLookup[key] {\n            return viewModel.buildResumeCommands(session: summary)\n        }\n        return \"\"\n    }\n\n    // DISABLED: SwiftTerm specific method, not needed for Ghostty\n    /*\n    func consoleSpecForTerminalKey(_ key: String) -> TerminalHostView.ConsoleSpec? {\n        #if canImport(SwiftTerm) && !APPSTORE\n            if key.hasPrefix(\"new-anchor:\"), let spec = consoleSpecForAnchor(key) {\n                return spec\n            }\n            if let summary = summaryLookup[key] {\n                return consoleSpecForResume(summary)\n            }\n        #endif\n        return nil\n    }\n    */\n\n    func canonicalizePath(_ path: String) -> String {\n        let expanded = (path as NSString).expandingTildeInPath\n        var standardized = URL(fileURLWithPath: expanded).standardizedFileURL.path\n        if standardized.count > 1 && standardized.hasSuffix(\"/\") { standardized.removeLast() }\n        return standardized\n    }\n\n    func exportMarkdownForFocused() {\n        guard let focused = focusedSummary else { return }\n        exportMarkdownForSession(focused)\n    }\n\n    func exportMarkdownForSession(_ session: SessionSummary) {\n        Task {\n            let loader = SessionTimelineLoader()\n            let allTurns = await loadConversationTurnsForExport(\n                session: session,\n                loader: loader\n            )\n            await MainActor.run {\n                presentMarkdownExport(for: session, allTurns: allTurns)\n            }\n        }\n    }\n    \n    private func loadConversationTurnsForExport(\n        session: SessionSummary,\n        loader: SessionTimelineLoader\n    ) async -> [ConversationTurn] {\n        if session.source.baseKind == .claude {\n            if let parsed = ClaudeSessionParser().parse(at: session.fileURL) {\n                return loader.turns(from: parsed.rows)\n            }\n            return []\n        } else if session.source.baseKind == .gemini {\n            return await viewModel.timeline(for: session)\n        } else {\n            return (try? loader.load(url: session.fileURL)) ?? []\n        }\n    }\n    \n    @MainActor\n    private func presentMarkdownExport(for session: SessionSummary, allTurns: [ConversationTurn]) {\n        let kinds = viewModel.preferences.markdownVisibleKinds\n        let turns: [ConversationTurn] = allTurns.compactMap { turn in\n            let userAllowed = turn.userMessage.flatMap { kinds.contains(event: $0) } ?? false\n            let keptOutputs = turn.outputs.filter { kinds.contains(event: $0) }\n            if !userAllowed && keptOutputs.isEmpty { return nil }\n            return ConversationTurn(\n                id: turn.id,\n                timestamp: turn.timestamp,\n                userMessage: userAllowed ? turn.userMessage : nil,\n                outputs: keptOutputs\n            )\n        }\n        // Fallback: if Claude session produced non-empty turns but all filtered out by current preferences,\n        // relax filter to include assistant messages to avoid empty exports.\n        let finalTurns: [ConversationTurn]\n        let builderKinds: Set<MessageVisibilityKind>\n        if turns.isEmpty, session.source.baseKind == .claude, !allTurns.isEmpty {\n            let relaxed: Set<MessageVisibilityKind> = [.user, .assistant]\n            finalTurns = allTurns.compactMap { turn in\n                let userAllowed = turn.userMessage.flatMap { relaxed.contains(event: $0) } ?? false\n                let keptOutputs = turn.outputs.filter { relaxed.contains(event: $0) }\n                if !userAllowed && keptOutputs.isEmpty { return nil }\n                return ConversationTurn(id: turn.id, timestamp: turn.timestamp, userMessage: userAllowed ? turn.userMessage : nil, outputs: keptOutputs)\n            }\n            builderKinds = relaxed\n        } else {\n            finalTurns = turns\n            builderKinds = kinds\n        }\n        let panel = NSSavePanel()\n        panel.title = \"Export Markdown\"\n        panel.allowedContentTypes = [.plainText]\n        let base = sanitizedExportFileName(session.effectiveTitle, fallback: session.displayName)\n        panel.nameFieldStringValue = base + \".md\"\n        if panel.runModal() == .OK, let url = panel.url {\n            let md = MarkdownExportBuilder.build(\n                session: session,\n                turns: finalTurns,\n                visibleKinds: builderKinds,\n                exportURL: url\n            )\n            try? md.data(using: String.Encoding.utf8)?.write(to: url)\n        }\n    }\n\n    func sanitizedExportFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String {\n        var text = s.trimmingCharacters(in: .whitespacesAndNewlines)\n        if text.isEmpty { return fallback }\n        let disallowed = CharacterSet(charactersIn: \"/:\")\n            .union(.newlines)\n            .union(.controlCharacters)\n        text = text.unicodeScalars.map { disallowed.contains($0) ? Character(\" \") : Character($0) }\n            .reduce(into: String(), { $0.append($1) })\n        while text.contains(\"  \") { text = text.replacingOccurrences(of: \"  \", with: \" \") }\n        text = text.trimmingCharacters(in: .whitespacesAndNewlines)\n        if text.isEmpty { text = fallback }\n        if text.count > maxLength {\n            let idx = text.index(text.startIndex, offsetBy: maxLength)\n            text = String(text[..<idx])\n        }\n        return text\n    }\n    \n    func applyIncrementalHint(for source: SessionSource, directory: String?) {\n        switch source.baseKind {\n        case .codex:\n            viewModel.setIncrementalHintForCodexToday()\n        case .gemini:\n            viewModel.setIncrementalHintForGeminiToday()\n        case .claude:\n            if let directory {\n                viewModel.setIncrementalHintForClaudeProject(directory: directory)\n            }\n        }\n    }\n    \n    func scheduleIncrementalRefresh(for source: SessionSource, directory: String?) {\n        guard let action = incrementalRefreshAction(for: source, directory: directory) else { return }\n        let schedule = incrementalRefreshSchedule(for: source.baseKind)\n        Task {\n            for delay in schedule {\n                if delay > 0 {\n                    try? await Task.sleep(nanoseconds: delay)\n                }\n                await action()\n            }\n        }\n    }\n    \n    private func incrementalRefreshAction(\n        for source: SessionSource,\n        directory: String?\n    ) -> (() async -> Void)? {\n        switch source.baseKind {\n        case .codex:\n            return { await viewModel.refreshIncrementalForNewCodexToday() }\n        case .gemini:\n            return { await viewModel.refreshIncrementalForGeminiToday() }\n        case .claude:\n            guard let directory else { return nil }\n            return { await viewModel.refreshIncrementalForClaudeProject(directory: directory) }\n        }\n    }\n    \n    private func incrementalRefreshSchedule(for kind: SessionSource.Kind) -> [UInt64] {\n        switch kind {\n        case .claude:\n            return [\n                0,\n                600_000_000,\n                1_500_000_000,\n                3_000_000_000,\n                5_000_000_000,\n                10_000_000_000,\n            ]\n        case .codex, .gemini:\n            return [0, 600_000_000, 1_500_000_000]\n        }\n    }\n\n    func sourceButtonLabel(title: String, source: SessionSource) -> some View {\n        Text(title)\n    }\n\n    func providerMenuLabel(prefix: String, source: SessionSource) -> some View {\n        Text(\"\\(prefix) \\(source.branding.displayName)\")\n    }\n}\n"
  },
  {
    "path": "views/Content/ContentView+MainDetail.swift",
    "content": "import SwiftUI\nimport GhosttyKit\n\nextension ContentView {\n    // Extracted to reduce ContentView.swift size\n    var mainDetailContent: some View {\n        Group {\n            // Session-level Git Review is removed from Tasks mode. Show Terminal or Conversation only.\n            // Non-review paths: either Terminal tab or Timeline\n            if selectedDetailTab == .terminal {\n                if let terminalKey = visibleTerminalKeyInDetail() {\n                    if let summary = summaryLookup[terminalKey] {\n                        EmbeddedTerminalView(\n                            sessionID: terminalKey,\n                            initialCommands: terminalHostInitialCommands(for: terminalKey),\n                            worktreePath: workingDirectory(for: summary)\n                        )\n                        .id(terminalKey)  // Use session ID directly for stability\n                        .frame(maxWidth: .infinity, maxHeight: .infinity)\n                        .padding(16)\n                    } else if let anchorData = pendingEmbeddedRekeys.first(where: { $0.anchorId == terminalKey }) {\n                        EmbeddedTerminalView(\n                            sessionID: terminalKey,\n                            initialCommands: terminalHostInitialCommands(for: terminalKey),\n                            worktreePath: anchorData.expectedCwd\n                        )\n                        .id(terminalKey)  // Use anchor ID directly for stability\n                        .frame(maxWidth: .infinity, maxHeight: .infinity)\n                        .padding(16)\n                    } else {\n                        VStack {\n                            Text(\"No terminal session available\")\n                                .foregroundStyle(.secondary)\n                            if let focused = focusedSummary {\n                                Button(\"Start Terminal\") {\n                                    startEmbedded(for: focused)\n                                }\n                            }\n                        }\n                        .frame(maxWidth: .infinity, maxHeight: .infinity)\n                    }\n                } else if let focused = focusedSummary {\n                    // Terminal tab is selected but no terminal is available\n                    VStack {\n                        Text(\"No terminal session available\")\n                            .foregroundStyle(.secondary)\n                        Button(\"Start Terminal\") {\n                            startEmbedded(for: focused)\n                        }\n                    }\n                    .frame(maxWidth: .infinity, maxHeight: .infinity)\n                } else {\n                    // No focused session\n                    VStack {\n                        Text(\"No session selected\")\n                            .foregroundStyle(.secondary)\n                    }\n                    .frame(maxWidth: .infinity, maxHeight: .infinity)\n                }\n            } else if let focused = focusedSummary {\n                SessionDetailView(\n                    summary: focused,\n                    isProcessing: isPerformingAction,\n                    onResume: {\n                        guard let current = focusedSummary else { return }\n                        #if APPSTORE\n                        openPreferredExternal(for: current)\n                        #else\n                        if viewModel.preferences.defaultResumeUseEmbeddedTerminal {\n                            startEmbedded(for: current)\n                        } else {\n                            openPreferredExternal(for: current)\n                        }\n                        #endif\n                    },\n                    onReveal: {\n                        guard let current = focusedSummary else { return }\n                        viewModel.reveal(session: current)\n                    },\n                    onDelete: presentDeleteConfirmation,\n                    columnVisibility: $columnVisibility,\n                    preferences: preferences\n                )\n                .environmentObject(viewModel)\n                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n            } else {\n                placeholder\n            }\n        }\n        .onReceive(NotificationCenter.default.publisher(for: .codMateTerminalExited)) { note in\n            guard let info = note.userInfo as? [String: Any],\n                  let key = info[\"sessionID\"] as? String,\n                  !key.isEmpty else { return }\n            let exitCode = info[\"exitCode\"] as? Int32\n            print(\"[EmbeddedTerminal] Process for \\(key) terminated, exitCode=\\(exitCode.map(String.init) ?? \"nil\")\")\n            if runningSessionIDs.contains(key) {\n                stopEmbedded(forID: key)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/Content/ContentView+Modifiers.swift",
    "content": "import SwiftUI\nimport UniformTypeIdentifiers\n\nextension ContentView {\n  fileprivate func canProjectWorkspaceReview() -> Bool {\n    guard viewModel.selectedProjectIDs.count == 1,\n          let pid = viewModel.selectedProjectIDs.first,\n          let p = viewModel.projects.first(where: { $0.id == pid }),\n          let dir = p.directory?.trimmingCharacters(in: .whitespacesAndNewlines), !dir.isEmpty\n    else { return false }\n    return true\n  }\n\n  fileprivate func syncListHiddenForWorkspaceMode() {\n    // Do not auto-hide the session list based on workspace mode.\n    // Respect the user's manual toggle (storeListHidden) and leave isListHidden unchanged.\n  }\n  fileprivate func navigationTitleForSelection() -> String {\n    if isAllSelection() {\n      return \"Overview\"\n    } else if isOtherSelection() {\n      return \"Sessions\"\n    } else {\n      return \"\"\n    }\n  }\n\n  fileprivate func isAllSelection() -> Bool {\n    return viewModel.selectedProjectIDs.isEmpty\n  }\n\n  fileprivate func isOtherSelection() -> Bool {\n    if viewModel.selectedProjectIDs.count == 1,\n       let pid = viewModel.selectedProjectIDs.first,\n       pid == SessionListViewModel.otherProjectId {\n      return true\n    }\n    return false\n  }\n\n  fileprivate func enforceWorkspaceModeForSelection() {\n    // \"All\" is forced to Overview\n    if isAllSelection() {\n      if viewModel.projectWorkspaceMode != .overview {\n        viewModel.projectWorkspaceMode = .overview\n      }\n      return\n    }\n    // \"Other\" is forced to Sessions mode (for managing unassigned sessions)\n    if isOtherSelection() {\n      if viewModel.projectWorkspaceMode != .sessions {\n        viewModel.projectWorkspaceMode = .sessions\n      }\n      return\n    }\n    // Single real project: default to Overview (settings surface) when coming from global/other modes\n    if viewModel.selectedProjectIDs.count == 1,\n       let pid = viewModel.selectedProjectIDs.first,\n       let project = viewModel.projects.first(where: { $0.id == pid }),\n       let dir = project.directory, !dir.isEmpty {\n      guard pendingSelectionID == nil else { return }\n      // If we are already in .tasks mode (e.g. explicitly navigated to a session), respect it.\n      if viewModel.projectWorkspaceMode == .tasks { return }\n\n      if viewModel.projectWorkspaceMode != .settings {\n        viewModel.projectWorkspaceMode = .settings\n      }\n    }\n  }\n\n  fileprivate func applyCalendarDefaults(\n    previousMode: ProjectWorkspaceMode?,\n    newMode: ProjectWorkspaceMode,\n    force: Bool = false\n  ) {\n    switch newMode {\n    case .overview:\n      if force || previousMode != .overview {\n        clearCalendarSelection()\n      }\n    case .settings:\n      if viewModel.selectedProjectIDs.count == 1,\n         (force || previousMode != .settings) {\n        clearCalendarSelection()\n      }\n    case .tasks:\n      guard pendingSelectionID == nil else { return }\n      if force || previousMode != .tasks {\n        ensureCalendarShowsToday()\n      }\n    default:\n      break\n    }\n  }\n\n  private func clearCalendarSelection() {\n    if viewModel.selectedDay != nil || !viewModel.selectedDays.isEmpty {\n      viewModel.setSelectedDay(nil)\n    }\n  }\n\n  private func ensureCalendarShowsToday() {\n    let cal = Calendar.current\n    let today = cal.startOfDay(for: Date())\n    var needsUpdate = false\n    if let current = viewModel.selectedDay {\n      if !cal.isDate(current, inSameDayAs: today) {\n        needsUpdate = true\n      }\n    } else {\n      needsUpdate = true\n    }\n    if viewModel.selectedDays.count != 1 || !viewModel.selectedDays.contains(today) {\n      needsUpdate = true\n    }\n    if needsUpdate {\n      viewModel.setSelectedDay(today)\n    }\n    let normalizedMonth = SessionListViewModel.normalizeMonthStart(today)\n    if normalizedMonth != viewModel.sidebarMonthStart {\n      viewModel.setSidebarMonthStart(today)\n    }\n  }\n  func navigationSplitView(geometry: GeometryProxy) -> some View {\n    let sidebarMaxWidth = geometry.size.width * 0.25\n    _ = storeSidebarHidden\n    _ = storeListHidden\n\n    let isSingleContentMode: Bool = {\n      switch viewModel.projectWorkspaceMode {\n      case .overview, .agents, .memory, .settings:\n        return true\n      default:\n        return false\n      }\n    }()\n\n    let splitView: AnyView = {\n      if isSingleContentMode {\n        let v = NavigationSplitView(columnVisibility: $columnVisibility) {\n          sidebarContent(sidebarMaxWidth: sidebarMaxWidth)\n        } detail: {\n          detailColumn\n        }\n        .navigationSplitViewStyle(.prominentDetail)\n        return AnyView(v)\n      } else {\n        let v = NavigationSplitView(columnVisibility: $columnVisibility) {\n          sidebarContent(sidebarMaxWidth: sidebarMaxWidth)\n        } content: {\n          contentColumn\n        } detail: {\n          detailColumn\n        }\n        .navigationSplitViewStyle(.prominentDetail)\n        return AnyView(v)\n      }\n    }()\n\n    let baseView = splitView\n      .navigationTitle(navigationTitleForSelection())\n      .onPreferenceChange(ContentView.SidebarWidthPreferenceKey.self) { width in\n        sidebarWidth = width\n      }\n      .onAppear {\n        applyVisibilityFromStorage(animated: false)\n        permissionsManager.restoreAccess()\n        SecurityScopedBookmarks.shared.restoreAllDynamicBookmarks()\n        Task { await permissionsManager.ensureCriticalDirectoriesAccess() }\n        // Restore preferred content column width (sessions list / review tree)\n        if let w = viewModel.windowStateStore.restoreContentColumnWidth() {\n          let clamped = max(360, min(480, w))\n          if contentColumnIdealWidth != clamped { contentColumnIdealWidth = clamped }\n        }\n\n        // Restore session selection from previous launch\n        let restored = viewModel.windowStateStore.restoreSessionSelection()\n        if !restored.selectedIDs.isEmpty {\n          selection = restored.selectedIDs\n          selectionPrimaryId = restored.primaryId\n          viewModel.updateSelection(restored.selectedIDs)\n        }\n\n        // On initial launch, ensure workspace mode matches the current selection.\n        // We dispatch to next runloop to avoid racing with view initialization.\n        DispatchQueue.main.async {\n          enforceWorkspaceModeForSelection()\n          syncListHiddenForWorkspaceMode()\n          applyCalendarDefaults(\n            previousMode: lastWorkspaceMode,\n            newMode: viewModel.projectWorkspaceMode,\n            force: true\n          )\n          lastWorkspaceMode = viewModel.projectWorkspaceMode\n        }\n      }\n      .onChange(of: selection) { newSelection in\n        // Save session selection whenever it changes\n        viewModel.windowStateStore.saveSessionSelection(selectedIDs: newSelection, primaryId: selectionPrimaryId)\n        viewModel.updateSelection(newSelection)\n        viewModel.scheduleSelectedSessionsRefresh(sessionIds: newSelection)\n      }\n      .onChange(of: selectionPrimaryId) { newPrimaryId in\n        // Save primary ID whenever it changes\n        viewModel.windowStateStore.saveSessionSelection(selectedIDs: selection, primaryId: newPrimaryId)\n      }\n    let viewWithTasks = applyTaskAndChangeModifiers(to: baseView)\n    let viewWithNotifications = applyNotificationModifiers(to: viewWithTasks)\n    let viewWithDialogs = applyDialogsAndAlerts(to: viewWithNotifications)\n    return applyGlobalSearchOverlay(to: viewWithDialogs, geometry: geometry)\n      .background(\n        GlobalFindKeyInterceptor {\n          NotificationCenter.default.post(name: .codMateFocusGlobalSearch, object: nil)\n        }\n      )\n      .onChange(of: preferences.searchPanelStyle) { newStyle in\n        handleSearchPanelStyleChange(newStyle)\n      }\n      .onChange(of: viewModel.projectWorkspaceMode) { newMode in\n        applyCalendarDefaults(previousMode: lastWorkspaceMode, newMode: newMode)\n        lastWorkspaceMode = newMode\n        syncListHiddenForWorkspaceMode()\n      }\n      .onChange(of: viewModel.selectedProjectIDs) { _ in\n        // Enforce Overview only when the selection truly is All/Other.\n        // Dispatching to the next run loop avoids racing with List(selection:)\n        // rebinds that momentarily emit an empty selection while re-rendering.\n        DispatchQueue.main.async {\n          enforceWorkspaceModeForSelection()\n          syncListHiddenForWorkspaceMode()\n        }\n      }\n  }\n\n  func applyTaskAndChangeModifiers<V: View>(to view: V) -> some View {\n    let v1 = view.task { await viewModel.hydrateFromCacheOnLaunch() }\n    let v2 = v1.onChange(of: viewModel.sections) { _ in\n      // Avoid mutating selection while search popover is opening/active to prevent focus loss/auto-dismiss\n      if !shouldBlockAutoSelection {\n        applyPendingSelectionIfNeeded()\n        normalizeSelection()\n      }\n      reconcilePendingEmbeddedRekeys()\n    }\n    let v3 = v2.onChange(of: selection) { newSel in\n      // 当搜索弹出开启时，立即释放并回拉焦点；否则不要在选择变化时强制归一化，\n      // 以免点击空白导致又被选中首项。\n      if shouldBlockAutoSelection && preferences.searchPanelStyle == .popover {\n        releasePrimaryFirstResponder()\n        DispatchQueue.main.async { [weak globalSearchViewModel] in\n          if isSearchPopoverPresented { globalSearchViewModel?.setFocus(true) }\n        }\n      }\n      let added = newSel.subtracting(lastSelectionSnapshot)\n      if let justAdded = added.first { selectionPrimaryId = justAdded }\n      if let primary = selectionPrimaryId, !newSel.contains(primary) {\n        selectionPrimaryId = newSel.first\n      }\n      lastSelectionSnapshot = newSel\n    }\n    let v4 = v3.onChange(of: viewModel.errorMessage) { message in\n      guard let message else { return }\n      alertState = AlertState(title: \"Operation Failed\", message: message)\n      viewModel.errorMessage = nil\n    }\n    let v5 = v4.onChange(of: viewModel.pendingEmbeddedProjectNew) { project in\n      guard let project else { return }\n      startEmbeddedNewForProject(project)\n      viewModel.pendingEmbeddedProjectNew = nil\n    }\n    let v6 = v5.toolbar {\n      // Project workspace mode segmented (toolbar leading) — AppKit-backed for icon+text in one segment\n      ToolbarItem(placement: .navigation) {\n        // Only show segmented control for specific projects (not All/Other)\n        if viewModel.selectedProjectIDs.count == 1 && !isAllSelection() && !isOtherSelection() {\n          let items: [SegmentedIconPicker<ProjectWorkspaceMode>.Item] = [\n            .init(title: \"Overview\", systemImage: \"chart.bar\", tag: .settings),\n            .init(title: \"Tasks\", systemImage: \"checklist\", tag: .tasks),\n            .init(title: \"Review\", systemImage: \"doc.text.magnifyingglass\", tag: .review),\n            .init(title: \"Agents\", systemImage: \"book.pages\", tag: .agents)\n          ]\n          SegmentedIconPicker(items: items, selection: $viewModel.projectWorkspaceMode)\n            .help(\"Project workspace mode\")\n        } else {\n          EmptyView()\n        }\n      }\n\n      ToolbarItem(placement: .primaryAction) {\n        refreshToolbarContent\n      }\n    }\n    return AnyView(v6)\n  }\n\n  func applyNotificationModifiers<V: View>(to view: V) -> some View {\n    view\n      .onReceive(NotificationCenter.default.publisher(for: .codMateResumeSession)) { note in\n        guard let sessionId = note.userInfo?[\"sessionId\"] as? String else { return }\n        let forceEmbedded = note.userInfo?[\"forceEmbedded\"] as? Bool ?? false\n        let profileId = note.userInfo?[\"profileId\"] as? String\n        if let summary = summaryLookup[sessionId] ?? viewModel.sessionSummary(for: sessionId) {\n          resumeFromList(summary, forceEmbedded: forceEmbedded, profileId: profileId)\n        }\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateTerminalSessionsUpdated)) { _ in\n        syncRunningSessionIDsFromManager()\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateFocusSessionSummary)) { note in\n        guard let summary = note.userInfo?[\"summary\"] as? SessionSummary else { return }\n        if let pid = viewModel.projectId(for: summary) {\n          viewModel.setSelectedProject(pid)\n        } else {\n          viewModel.setSelectedProject(SessionListViewModel.otherProjectId)\n        }\n        viewModel.projectWorkspaceMode = .tasks\n        selection = [summary.id]\n        selectionPrimaryId = summary.id\n        runningSessionIDs.insert(summary.id)\n        selectedTerminalKey = summary.id\n        selectedDetailTab = .terminal\n        sessionDetailTabs[summary.id] = .terminal\n        viewModel.clearAwaitingFollowup(summary.id)\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateStartEmbeddedNewProject)) {\n        note in\n        NSLog(\"📌 [ContentView] Received codMateStartEmbeddedNewProject: %@\", note.userInfo ?? [:])\n        if let pid = note.userInfo?[\"projectId\"] as? String,\n          let project = viewModel.projects.first(where: { $0.id == pid })\n        {\n          NSLog(\"📌 [ContentView] Starting embedded New for project id=%@\", pid)\n          startEmbeddedNewForProject(project)\n        } else {\n          NSLog(\"⚠️ [ContentView] Project for embedded New not found; id=%@\",\n                note.userInfo?[\"projectId\"] as? String ?? \"<nil>\")\n        }\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateStartEmbeddedNewSession)) { note in\n        guard let sessionId = note.userInfo?[EmbeddedSessionNotification.sessionIdKey] as? String else { return }\n        let source = EmbeddedSessionNotification.decodeSource(from: note.userInfo)\n        if let summary = summaryLookup[sessionId] ?? viewModel.sessionSummary(for: sessionId) {\n          startEmbeddedNew(for: summary, using: source)\n        }\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateOpenNewProject)) { note in\n        handleDockNewProjectRequest(userInfo: note.userInfo)\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateToggleSidebar)) { _ in\n        toggleSidebarVisibility()\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateToggleList)) { _ in\n        toggleListVisibility()\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateFocusGlobalSearch)) { _ in\n        focusGlobalSearchPanel()\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateRefreshRequested)) { note in\n        let kind = RefreshRequest.kind(from: note.userInfo)\n        handleRefreshRequest(kind)\n      }\n      .onReceive(NotificationCenter.default.publisher(for: .codMateGlobalRefresh)) { _ in\n        // Legacy hook: treat as full refresh.\n        handleRefreshRequest(.global)\n      }\n      .onAppear {\n        // Mark ContentView as ready first, so any queued requests can be processed\n        DockOpenCoordinator.shared.markContentViewReady()\n        // Then consume any pending new project request from initial launch\n        applyPendingDockNewProjectIfNeeded()\n      }\n  }\n\n  private func applyPendingDockNewProjectIfNeeded() {\n    guard let pending = DockOpenCoordinator.shared.consumePendingNewProject() else { return }\n    presentDockNewProject(directory: pending.directory, name: pending.name)\n  }\n\n  private func handleDockNewProjectRequest(userInfo: [AnyHashable: Any]?) {\n    if let pending = DockOpenCoordinator.shared.consumePendingNewProject() {\n      presentDockNewProject(directory: pending.directory, name: pending.name)\n      return\n    }\n    guard let directory = userInfo?[\"directory\"] as? String else { return }\n    let name = userInfo?[\"name\"] as? String\n    presentDockNewProject(directory: directory, name: name)\n  }\n\n  private func presentDockNewProject(directory: String, name: String?) {\n    let trimmedDirectory = directory.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmedDirectory.isEmpty else { return }\n    let trimmedName = name?.trimmingCharacters(in: .whitespacesAndNewlines)\n    // Using .sheet(item:) so setting prefill directly triggers the sheet with correct data\n    sidebarNewProjectPrefill = ProjectEditorSheet.Prefill(\n      name: (trimmedName?.isEmpty == false) ? trimmedName : nil,\n      directory: trimmedDirectory,\n      trustLevel: nil,\n      overview: nil,\n      profileId: nil,\n      parentId: nil\n    )\n  }\n\n  private func handleRefreshRequest(_ kind: RefreshRequestKind) {\n    Task { await viewModel.refreshSidebarStats() }\n\n    let mode = viewModel.projectWorkspaceMode\n    let shouldRefreshSessions = kind == .global || mode == .tasks || mode == .sessions\n    if shouldRefreshSessions {\n      Task { await viewModel.refreshSessions(force: true) }\n    }\n\n    switch mode {\n    case .tasks, .sessions:\n      break\n    case .review:\n      reviewRefreshToken &+= 1\n    case .overview, .settings:\n      if currentSelectedProject() == nil {\n        overviewViewModel.forceRefresh()\n      } else {\n        projectOverviewRefreshToken &+= 1\n      }\n    case .agents:\n      agentsRefreshToken &+= 1\n    case .memory:\n      break\n    }\n  }\n\n  func applyDialogsAndAlerts<V: View>(to view: V) -> some View {\n    view\n      .confirmationDialog(\n        \"Stop running session?\",\n        isPresented: Binding<Bool>(\n          get: { confirmStopState != nil }, set: { if !$0 { confirmStopState = nil } }),\n        titleVisibility: .visible\n      ) {\n        Button(\"Stop\", role: .destructive) {\n          if let st = confirmStopState {\n            stopEmbedded(forKey: st.terminalKey)\n            confirmStopState = nil\n          }\n        }\n        Button(\"Cancel\", role: .cancel) { confirmStopState = nil }\n      } message: {\n        Text(\n          \"The embedded terminal appears to be running. Stopping now will terminate the current Codex/Claude task.\"\n        )\n      }\n      .confirmationDialog(\n        \"Resume in embedded terminal?\",\n        isPresented: Binding<Bool>(\n          get: { pendingTerminalLaunch != nil }, set: { if !$0 { pendingTerminalLaunch = nil } }),\n        presenting: pendingTerminalLaunch?.session\n      ) { session in\n        Button(\"Resume\", role: .none) {\n          startEmbedded(for: session)\n          pendingTerminalLaunch = nil\n        }\n        Button(\"Cancel\", role: .cancel) {\n          pendingTerminalLaunch = nil\n        }\n      } message: { session in\n        Text(\n          \"CodMate will launch \\(session.source.branding.displayName) inside the built-in terminal to resume “\\(session.displayName)”.\"\n        )\n      }\n      .alert(item: $alertState) { state in\n        Alert(\n          title: Text(state.title), message: Text(state.message),\n          dismissButton: .default(Text(\"OK\")))\n      }\n      .alert(\n        \"Delete selected sessions?\", isPresented: $deleteConfirmationPresented,\n        presenting: Array(selection)\n      ) { ids in\n        Button(\"Cancel\", role: .cancel) {}\n        Button(\"Move to Trash\", role: .destructive) { deleteSelections(ids: ids) }\n      } message: { _ in\n        Text(\"Session files will be moved to Trash and can be restored in Finder.\")\n      }\n      .fileImporter(\n        isPresented: $selectingSessionsRoot, allowedContentTypes: [.folder],\n        allowsMultipleSelection: false\n      ) { result in\n        handleFolderSelection(result: result, update: viewModel.updateSessionsRoot)\n      }\n  }\n\n  private func handleSearchPanelStyleChange(_ newStyle: GlobalSearchPanelStyle) {\n    switch newStyle {\n    case .popover:\n      clampSearchPopoverSizeIfNeeded()\n      if globalSearchViewModel.shouldShowPanel {\n        isSearchPopoverPresented = true\n      }\n    case .floating:\n      if isSearchPopoverPresented {\n        isSearchPopoverPresented = false\n      }\n    }\n  }\n}\n\n#if os(macOS)\nimport AppKit\n\n// Intercepts Command+F at the window level and routes it to global search,\n// swallowing the event so focused text fields don't consume it first.\nprivate struct GlobalFindKeyInterceptor: NSViewRepresentable {\n  var onFind: () -> Void\n\n  func makeCoordinator() -> Coordinator { Coordinator(onFind: onFind) }\n\n  func makeNSView(context: Context) -> NSView {\n    let view = NSView(frame: .zero)\n    context.coordinator.installMonitor()\n    return view\n  }\n\n  func updateNSView(_ nsView: NSView, context: Context) {}\n\n  final class Coordinator {\n    let onFind: () -> Void\n    var monitor: Any?\n\n    init(onFind: @escaping () -> Void) { self.onFind = onFind }\n\n    func installMonitor() {\n      if monitor != nil { return }\n      monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown]) { [weak self] event in\n        guard let self, event.modifierFlags.contains(.command) else { return event }\n        if let chars = event.charactersIgnoringModifiers?.lowercased(), chars == \"f\" {\n          self.onFind()\n          return nil // swallow so first responder doesn't handle it\n        }\n        return event\n      }\n    }\n\n    deinit {\n      if let monitor { NSEvent.removeMonitor(monitor) }\n    }\n  }\n}\n#endif\n"
  },
  {
    "path": "views/Content/ContentView+Search.swift",
    "content": "import SwiftUI\n#if os(macOS)\n  import AppKit\n#endif\n\nextension ContentView {\n  func applyGlobalSearchOverlay<V: View>(to view: V, geometry: GeometryProxy) -> some View {\n    view.overlay(alignment: .top) {\n      if preferences.searchPanelStyle == .floating && globalSearchViewModel.shouldShowPanel {\n        let panelWidth = max(360, min(geometry.size.width * 0.55, 640))\n        GlobalSearchPanel(\n          viewModel: globalSearchViewModel,\n          maxWidth: panelWidth,\n          onSelect: { handleGlobalSearchSelection($0) },\n          onClose: { dismissGlobalSearchPanel() }\n        )\n        .frame(maxWidth: .infinity)\n        .padding(.top, 24)\n        .padding(.horizontal, max(24, (geometry.size.width - panelWidth) / 2))\n        .transition(.move(edge: .top).combined(with: .opacity))\n        .zIndex(20)\n      }\n    }\n  }\n\n  func focusGlobalSearchPanel() {\n    if preferences.searchPanelStyle == .popover {\n      // In popover mode, open the popover and set focus with minimal side-effects\n      if !isSearchPopoverPresented {\n        // Block auto-selection during open to prevent list interference\n        shouldBlockAutoSelection = true\n        popoverDismissDisabled = true\n\n        // Open the popover first so refusal/inactive states can apply before releasing focus\n        isSearchPopoverPresented = true\n\n        // Next runloop: release current first responder, then focus the popover field\n        DispatchQueue.main.async { [weak globalSearchViewModel] in\n          releasePrimaryFirstResponder()\n          // Delay focus slightly to allow window hierarchy to settle\n          DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\n            globalSearchViewModel?.setFocus(true)\n          }\n          DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) {\n            globalSearchViewModel?.setFocus(true)\n          }\n        }\n        // Keep interactive dismissal disabled for the entire lifetime; re-enable on close only\n      }\n      return\n    }\n\n    // Floating mode: handle focus and notifications directly\n    releasePrimaryFirstResponder()\n    globalSearchViewModel.setFocus(true)\n  }\n\n  func dismissGlobalSearchPanel() {\n    // Re-enable auto-selection when closing\n    shouldBlockAutoSelection = false\n\n    // In popover mode, state is managed by binding\n    if preferences.searchPanelStyle == .popover {\n      // Just close the popover; the binding setter will handle cleanup\n      if isSearchPopoverPresented {\n        isSearchPopoverPresented = false\n      }\n      return\n    }\n\n    // Floating mode: handle cleanup directly\n    globalSearchViewModel.dismissPanel()\n  }\n\n  func handleGlobalSearchSelection(_ result: GlobalSearchResult) {\n    defer { dismissGlobalSearchPanel() }\n    let trimmedTerm = globalSearchViewModel.query.trimmingCharacters(in: .whitespacesAndNewlines)\n    switch result.kind {\n    case .project:\n      guard let project = result.project else { return }\n      highlightProject(project)\n    case .note:\n      guard let note = result.note else { return }\n\n      // Try to find the session in current view, or by file URL\n      let summary = viewModel.sessionSummary(withId: note.id)\n        ?? viewModel.sessionSummary(forFileURL: result.fileURL)\n\n      guard let summary else {\n        // If session not found, just highlight the project\n        if let pid = note.projectId, let project = viewModel.projects.first(where: { $0.id == pid }) {\n          highlightProject(project)\n        }\n        return\n      }\n\n      focusOnSession(\n        summary,\n        explicitProjectId: note.projectId,\n        searchTerm: nil,\n        filterConversation: false\n      )\n    case .session:\n      guard let summary = result.sessionSummary ?? viewModel.sessionSummary(forFileURL: result.fileURL) else { return }\n      let projectId = viewModel.projectIdForSession(summary.id)\n      focusOnSession(\n        summary,\n        explicitProjectId: projectId,\n        searchTerm: trimmedTerm.isEmpty ? nil : trimmedTerm,\n        filterConversation: true\n      )\n    case .task:\n      guard let task = result.task else { return }\n      focusOnTask(task)\n    }\n  }\n\n  private func highlightProject(_ project: Project) {\n    viewModel.clearScopeFilters()\n    viewModel.setSelectedProject(project.id)\n    viewModel.requestProjectExpansion(for: project.id)\n    isListHidden = false\n  }\n\n  private func focusOnTask(_ task: CodMateTask) {\n    viewModel.clearScopeFilters()\n    viewModel.setSelectedProject(task.projectId)\n    viewModel.requestProjectExpansion(for: task.projectId)\n    if viewModel.projectWorkspaceMode != .tasks {\n      viewModel.projectWorkspaceMode = .tasks\n    }\n\n    // Set calendar to task's updated date\n    let referenceDate = task.updatedAt\n    let day = Calendar.current.startOfDay(for: referenceDate)\n    viewModel.selectedDay = day\n    viewModel.selectedDays = Set([day])\n\n    // Select first session in the task to auto-expand it\n    if let firstSessionId = task.sessionIds.first {\n      pendingSelectionID = firstSessionId\n      applyPendingSelectionIfNeeded()\n    }\n\n    isListHidden = false\n    selectedDetailTab = .timeline\n  }\n\n  func focusOnSession(\n    _ summary: SessionSummary,\n    explicitProjectId: String?,\n    searchTerm: String?,\n    filterConversation: Bool\n  ) {\n    viewModel.clearScopeFilters()\n    let projectToApply = explicitProjectId ?? viewModel.projectIdForSession(summary.id)\n    if let pid = projectToApply {\n      viewModel.setSelectedProject(pid)\n      viewModel.requestProjectExpansion(for: pid)\n      if pid == SessionListViewModel.otherProjectId {\n        if viewModel.projectWorkspaceMode != .sessions {\n          viewModel.projectWorkspaceMode = .sessions\n        }\n      } else if viewModel.projectWorkspaceMode != .tasks {\n        viewModel.projectWorkspaceMode = .tasks\n      }\n    }\n    let referenceDate = summary.lastUpdatedAt ?? summary.startedAt\n    let day = Calendar.current.startOfDay(for: referenceDate)\n    viewModel.selectedDay = day\n    viewModel.selectedDays = Set([day])\n    pendingSelectionID = summary.id\n    applyPendingSelectionIfNeeded()\n    selectedDetailTab = .timeline\n    isListHidden = false\n\n    if filterConversation, let term = searchTerm, !term.isEmpty {\n      if selectionPrimaryId == summary.id {\n        notifyConversationFilter(sessionId: summary.id, term: term)\n      } else {\n        pendingConversationFilter = (summary.id, term)\n      }\n    } else {\n      pendingConversationFilter = nil\n    }\n  }\n\n  private func notifyConversationFilter(sessionId: String, term: String) {\n    let info: [String: Any] = [\"sessionId\": sessionId, \"term\": term]\n    DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {\n      NotificationCenter.default.post(\n        name: .codMateConversationFilter,\n        object: nil,\n        userInfo: info\n      )\n    }\n  }\n\n  func applyPendingSelectionIfNeeded() {\n    if shouldBlockAutoSelection { return }\n    guard let pending = pendingSelectionID else { return }\n    let visibleIDs = viewModel.sections.flatMap { $0.sessions.map(\\.id) }\n    guard visibleIDs.contains(pending) else { return }\n    selection = [pending]\n    selectionPrimaryId = pending\n    pendingSelectionID = nil\n    if let filter = pendingConversationFilter, filter.id == pending {\n      notifyConversationFilter(sessionId: filter.id, term: filter.term)\n      pendingConversationFilter = nil\n    }\n  }\n\n  func clampSearchPopoverSizeIfNeeded() {\n    let clamped = clampedSearchPopoverSize(searchPopoverSize)\n    if abs(clamped.width - searchPopoverSize.width) > .ulpOfOne\n      || abs(clamped.height - searchPopoverSize.height) > .ulpOfOne\n    {\n      searchPopoverSize = clamped\n    }\n  }\n\n  func clampedSearchPopoverSize(_ size: CGSize) -> CGSize {\n    CGSize(\n      width: min(max(size.width, ContentView.searchPopoverMinSize.width), ContentView.searchPopoverMaxSize.width),\n      height: min(max(size.height, ContentView.searchPopoverMinSize.height), ContentView.searchPopoverMaxSize.height)\n    )\n  }\n\n  @MainActor func releasePrimaryFirstResponder() {\n    #if os(macOS)\n      if let window = NSApplication.shared.keyWindow {\n        window.makeFirstResponder(nil)\n      }\n    #endif\n  }\n}\n"
  },
  {
    "path": "views/Content/ContentView+Sidebar.swift",
    "content": "import SwiftUI\nimport AppKit\n\nextension ContentView {\n  // Clamp and persist content column width (sessions list / review tree)\n  fileprivate func captureContentColumnWidth(_ width: CGFloat) {\n    guard width.isFinite, width > 0 else { return }\n    // Ignore when hidden (width may report 0 or tiny values)\n    if isListHidden { return }\n    let clamped = max(360, min(480, width))\n    if abs(Double(clamped - contentColumnIdealWidth)) > 0.5 {\n      contentColumnIdealWidth = clamped\n      viewModel.windowStateStore.saveContentColumnWidth(clamped)\n    }\n  }\n  func sidebarContent(sidebarMaxWidth: CGFloat) -> some View {\n    let state = viewModel.sidebarStateSnapshot\n    let digest = makeSidebarDigest(for: state)\n    let isAllSelected = viewModel.selectedProjectIDs.isEmpty\n    let isOtherSelected = viewModel.selectedProjectIDs.count == 1\n      && viewModel.selectedProjectIDs.first == SessionListViewModel.otherProjectId\n    return EquatableSidebarContainer(key: digest) {\n      SessionNavigationView(\n        state: state,\n        actions: makeSidebarActions(),\n        projectWorkspaceMode: viewModel.projectWorkspaceMode,\n        isAllOrOtherSelected: isAllSelected || isOtherSelected\n      ) {\n        ProjectsListView(onEditProject: { project in\n          presentProjectEditor(for: project)\n        })\n          .environmentObject(viewModel)\n      }\n      .navigationSplitViewColumnWidth(min: 260, ideal: 260, max: 260)\n      .background(\n        GeometryReader { gr in\n          Color.clear.preference(key: ContentView.SidebarWidthPreferenceKey.self, value: gr.size.width)\n        }\n      )\n    }\n    .sheet(item: $sidebarNewProjectPrefill) { prefill in\n      ProjectEditorSheet(\n        isPresented: Binding(\n          get: { sidebarNewProjectPrefill != nil },\n          set: { if !$0 { sidebarNewProjectPrefill = nil } }\n        ),\n        mode: .new,\n        prefill: prefill\n      )\n        .environmentObject(viewModel)\n    }\n  }\n\n  // Preference key to read current content column width\n  private struct ContentColumnWidthPreferenceKey: PreferenceKey {\n    static var defaultValue: CGFloat = 0\n    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }\n  }\n\n  var listContent: some View {\n    SessionListColumnView(\n      sections: viewModel.sections,\n      selection: guardedListSelectionBinding,\n      sortOrder: $viewModel.sortOrder,\n      isLoading: viewModel.isLoading,\n      isEnriching: viewModel.isEnriching,\n      enrichmentProgress: viewModel.enrichmentProgress,\n      enrichmentTotal: viewModel.enrichmentTotal,\n      onResume: { resumeFromList($0) },\n      onReveal: { viewModel.reveal(session: $0) },\n      onDeleteRequest: handleDeleteRequest,\n      onExportMarkdown: exportMarkdownForSession,\n      isRunning: { runningSessionIDs.contains($0.id) },\n      isUpdating: { viewModel.isActivelyUpdating($0.id) },\n      isAwaitingFollowup: { viewModel.isAwaitingFollowup($0.id) },\n      onPrimarySelect: { s in\n        selectionPrimaryId = s.id\n        // If the selected session has a running embedded terminal, switch to it\n        if runningSessionIDs.contains(s.id) {\n          selectedTerminalKey = s.id\n          selectedDetailTab = .terminal\n          sessionDetailTabs[s.id] = .terminal\n        }\n      },\n      onNewSessionWithTaskContext: newSessionWithTaskContext\n    )\n    .id(isListHidden ? \"list-hidden\" : \"list-shown\")\n    .environmentObject(viewModel)\n    .navigationSplitViewColumnWidth(\n      min: isListHidden ? 0 : 360,\n      ideal: isListHidden ? 0 : contentColumnIdealWidth,\n      max: isListHidden ? 0 : 480\n    )\n    .background(\n      GeometryReader { gr in\n        Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width)\n      }\n    )\n    .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in\n      captureContentColumnWidth(w)\n    }\n    .allowsHitTesting(listAllowsHitTesting)\n    .refuseFirstResponder(when: isSearchPopoverPresented || shouldBlockAutoSelection || selection.isEmpty)\n    .environment(\\.controlActiveState,\n                ((preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection)) || selection.isEmpty) ? .inactive : .active)\n    .padding(.bottom, statusBarReservedHeight)\n    .sheet(item: $viewModel.editingSession, onDismiss: { viewModel.cancelEdits() }) { _ in\n      EditSessionMetaView(viewModel: viewModel)\n    }\n  }\n\n  // Content column that switches between the session list and a simple placeholder\n  // depending on the current project workspace mode. Use AnyView to unify types.\n  var contentColumn: some View {\n    switch viewModel.projectWorkspaceMode {\n    case .tasks, .sessions:\n      AnyView(listContent)\n    case .review:\n      AnyView(reviewLeftColumn)\n    case .overview, .agents, .memory, .settings:\n      AnyView(listPlaceholderContent)\n    }\n  }\n\n  // Neutral placeholder for non-session modes to replace the list column.\n  private var listPlaceholderContent: some View {\n    let (title, icon) = placeholderTitleAndIcon(for: viewModel.projectWorkspaceMode)\n    return placeholderSurface(title: title, systemImage: icon)\n      .frame(maxWidth: .infinity, maxHeight: .infinity)\n      .navigationSplitViewColumnWidth(\n        min: isListHidden ? 0 : 360,\n        ideal: isListHidden ? 0 : contentColumnIdealWidth,\n        max: isListHidden ? 0 : 480\n      )\n      .background(\n        GeometryReader { gr in\n          Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width)\n        }\n      )\n      .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in\n        captureContentColumnWidth(w)\n      }\n      .allowsHitTesting(false)\n      .id(isListHidden ? \"list-placeholder-hidden\" : \"list-placeholder-shown\")\n      .padding(.bottom, statusBarReservedHeight)\n  }\n\n  // Left column for Project Review: Git changes tree/search/commit\n  private var reviewLeftColumn: some View {\n    Group {\n      if let project = currentSelectedProject(), let dir = project.directory, !dir.isEmpty {\n        let ws = dir\n        let stateBinding = Binding<ReviewPanelState>(\n          get: { viewModel.projectReviewPanelStates[project.id] ?? ReviewPanelState() },\n          set: { viewModel.projectReviewPanelStates[project.id] = $0 }\n        )\n        let vm = projectReviewVM(for: project.id)\n        EquatableGitChangesContainer(\n          key: .init(\n            workingDirectoryPath: ws,\n            projectDirectoryPath: ws,\n            state: stateBinding.wrappedValue,\n            refreshToken: reviewRefreshToken\n          ),\n          workingDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n          projectDirectory: URL(fileURLWithPath: ws, isDirectory: true),\n          presentation: .full,\n          regionLayout: .leftOnly,\n          preferences: viewModel.preferences,\n          onRequestAuthorization: { ensureRepoAccessForProjectReview(directory: ws) },\n          externalVM: vm,\n          refreshToken: reviewRefreshToken,\n          savedState: stateBinding\n        )\n      } else {\n        placeholderSurface(title: \"Review\", systemImage: \"doc.text.magnifyingglass\")\n      }\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    .navigationSplitViewColumnWidth(\n      min: isListHidden ? 0 : 360,\n      ideal: isListHidden ? 0 : contentColumnIdealWidth,\n      max: isListHidden ? 0 : 480\n    )\n    .background(\n      GeometryReader { gr in\n        Color.clear.preference(key: ContentColumnWidthPreferenceKey.self, value: gr.size.width)\n      }\n    )\n    .onPreferenceChange(ContentColumnWidthPreferenceKey.self) { w in\n      captureContentColumnWidth(w)\n    }\n    .padding(.bottom, statusBarReservedHeight)\n  }\n\n  private func placeholderTitleAndIcon(for mode: ProjectWorkspaceMode) -> (String, String) {\n    switch mode {\n    case .overview: return (\"Overview\", \"square.grid.2x2\")\n    case .agents: return (\"Agents\", \"book.pages\")\n    case .memory: return (\"Memory\", \"bookmark\")\n    case .settings: return (\"Project Settings\", \"gearshape\")\n    case .tasks: return (\"\", \"\") // never used\n    case .sessions: return (\"\", \"\") // never used\n    case .review: return (\"\", \"\") // never used\n    }\n  }\n\n  private var guardedListSelectionBinding: Binding<Set<SessionSummary.ID>> {\n    Binding(\n      get: { selection },\n      set: { newSel in\n        // Swallow selection sets while search popover is opening/active\n        if preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection) {\n          return\n        }\n        selection = newSel\n      }\n    )\n  }\n\n  private func makeSidebarDigest(for state: SidebarState) -> SidebarDigest {\n    func hashInt<S: Sequence>(_ seq: S) -> Int where S.Element == String {\n      var h = Hasher()\n      for s in seq { h.combine(s) }\n      return h.finalize()\n    }\n    func hashIntDates<S: Sequence>(_ seq: S) -> Int where S.Element == Date {\n      var h = Hasher()\n      for d in seq { h.combine(d.timeIntervalSinceReferenceDate.bitPattern) }\n      return h.finalize()\n    }\n    let projectsIdsHash = hashInt(viewModel.projects.map { $0.id + (\"|\" + ($0.parentId ?? \"\")) })\n    func hashCounts(_ counts: [Int: Int]) -> Int {\n      var h = Hasher()\n      for key in counts.keys.sorted() {\n        h.combine(key)\n        h.combine(counts[key] ?? 0)\n      }\n      return h.finalize()\n    }\n    func hashEnabled(_ set: Set<Int>?) -> Int {\n      guard let set else { return -1 }\n      var h = Hasher()\n      for value in set.sorted() { h.combine(value) }\n      return h.finalize()\n    }\n    let selectedProjectsHash = hashInt(state.selectedProjectIDs.sorted())\n    let selectedDaysHash = hashIntDates(state.selectedDays.sorted())\n    return SidebarDigest(\n      projectsCount: viewModel.projects.count,\n      projectsIdsHash: projectsIdsHash,\n      totalSessionCount: state.totalSessionCount,\n      selectedProjectsHash: selectedProjectsHash,\n      selectedDaysHash: selectedDaysHash,\n      dateDimensionRaw: state.dateDimension == .created ? 1 : 2,\n      monthStartInterval: state.monthStart.timeIntervalSinceReferenceDate,\n      calendarCountsHash: hashCounts(state.calendarCounts),\n      enabledDaysHash: hashEnabled(state.enabledProjectDays),\n      visibleAllCount: state.visibleAllCount,\n      projectWorkspaceMode: viewModel.projectWorkspaceMode\n    )\n  }\n\n  var refreshToolbarContent: some View {\n    HStack(spacing: 12) {\n      if permissionsManager.needsAuthorization {\n        Button {\n          openWindow(id: \"settings\")\n        } label: {\n          HStack(spacing: 6) {\n            Image(systemName: \"exclamationmark.triangle.fill\").font(\n              .system(size: 14, weight: .medium)\n            ).foregroundStyle(.orange)\n            Text(\"Grant Access\").font(.system(size: 12, weight: .medium))\n          }\n        }\n        .buttonStyle(.borderedProminent)\n        .tint(.orange)\n        .controlSize(.small)\n        .help(\"Some directories need authorization to access sessions data\")\n      }\n\n      let enabledSnapshots = viewModel.usageSnapshots.filter {\n        viewModel.preferences.isCLIEnabled($0.key.baseKind)\n      }\n      if enabledSnapshots.isEmpty == false {\n        EquatableUsageContainer(\n          snapshots: enabledSnapshots,\n          preferences: viewModel.preferences,\n          selectedProvider: Binding(\n            get: { selectedUsageProvider },\n            set: { newValue in\n              if viewModel.preferences.isCLIEnabled(newValue.baseKind) {\n                selectedUsageProvider = newValue\n              } else if let fallback = enabledSnapshots.keys.sorted(by: { $0.rawValue < $1.rawValue }).first {\n                selectedUsageProvider = fallback\n              }\n            }\n          ),\n          onRequestRefresh: { viewModel.requestUsageStatusRefreshThrottled(for: $0) }\n        )\n      }\n\n      #if !APPSTORE\n        if viewModel.preferences.isEmbeddedTerminalEnabled {\n          ActiveTerminalSessionsControl(\n            viewModel: viewModel,\n            runningSessionIDs: runningSessionIDs\n          )\n        }\n      #endif\n\n      searchToolbarButton\n\n      ToolbarCircleButton(\n        systemImage: \"arrow.clockwise\",\n        isActive: viewModel.isEnriching,\n        showProgress: viewModel.isEnriching || viewModel.isLoading,\n        help: \"Refresh\"\n      ) {\n        NotificationCenter.default.post(\n          name: .codMateRefreshRequested,\n          object: nil,\n          userInfo: RefreshRequest.userInfo(for: .context)\n        )\n      }\n      .disabled(viewModel.isEnriching || viewModel.isLoading)\n    }\n    .padding(.horizontal, 3)\n    .padding(.vertical, 2)\n  }\n\n  @ViewBuilder\n  private var searchToolbarButton: some View {\n    let button = ToolbarCircleButton(\n      systemImage: \"magnifyingglass\",\n      isActive: searchPanelIsActive,\n      activeColor: Color.primary.opacity(0.8),\n      help: \"Open global search (⌘F)\"\n    ) {\n      // In popover mode, route through dedicated open/close to ensure focus guards\n      if preferences.searchPanelStyle == .popover {\n        if isSearchPopoverPresented {\n          dismissGlobalSearchPanel()\n        } else {\n          focusGlobalSearchPanel()\n        }\n      } else {\n        focusGlobalSearchPanel()\n      }\n    }\n\n    if preferences.searchPanelStyle == .popover {\n      button.popover(isPresented: searchPopoverBinding, arrowEdge: .top) {\n        GlobalSearchPopoverPanel(\n          viewModel: globalSearchViewModel,\n          size: $searchPopoverSize,\n          minSize: ContentView.searchPopoverMinSize,\n          maxSize: ContentView.searchPopoverMaxSize,\n          onSelect: { handleGlobalSearchSelection($0) },\n          onClose: { dismissGlobalSearchPanel() }\n        )\n        .interactiveDismissDisabled(popoverDismissDisabled)\n      }\n    } else {\n      button\n    }\n  }\n\n  private var searchPopoverBinding: Binding<Bool> {\n    Binding(\n      get: { isSearchPopoverPresented },\n      set: { isPresented in\n        isSearchPopoverPresented = isPresented\n\n        if isPresented {\n          // Opening: clamp size (focus is handled in focusGlobalSearchPanel)\n          clampSearchPopoverSizeIfNeeded()\n        } else {\n          // Closing popover: clean up and re-enable auto-selection\n          shouldBlockAutoSelection = false\n          popoverDismissDisabled = false\n          globalSearchViewModel.dismissPanel()\n        }\n      }\n    )\n  }\n\n  private var searchPanelIsActive: Bool {\n    if preferences.searchPanelStyle == .popover {\n      return isSearchPopoverPresented\n    }\n    return globalSearchViewModel.shouldShowPanel\n  }\n\n  private var listAllowsHitTesting: Bool {\n    guard !isListHidden else { return false }\n    // Block hit testing when popover is presented OR about to be presented\n    if preferences.searchPanelStyle == .popover && (isSearchPopoverPresented || shouldBlockAutoSelection) {\n      return false\n    }\n    return true\n  }\n}\n\nprivate struct ActiveTerminalSessionsControl: View {\n  let viewModel: SessionListViewModel\n  let runningSessionIDs: Set<String>\n  @State private var showPopover = false\n  @State private var isHovering = false\n\n  var body: some View {\n    let count = runningSessionIDs.count\n\n    Button {\n      showPopover.toggle()\n    } label: {\n      ZStack {\n        Image(systemName: \"chevron.forward.2\")\n          .font(.system(size: 14, weight: .medium))\n          .foregroundStyle(iconColor)\n          .offset(x: 1)\n\n        if count > 0 {\n          Text(\"\\(count)\")\n            .font(.system(size: 9, weight: .bold, design: .rounded))\n            .foregroundStyle(.white)\n            .frame(minWidth: 14, minHeight: 14)\n            .background(\n              Circle()\n                .fill(count > 0 ? Color.accentColor : Color.secondary)\n            )\n            .offset(x: 8, y: -8)\n        }\n      }\n      .frame(width: 14, height: 14)\n      .padding(8)\n      .background(\n        Circle()\n          .fill(backgroundColor)\n      )\n      .overlay(\n        Circle()\n          .stroke(borderColor, lineWidth: 1)\n      )\n      .contentShape(Circle())\n    }\n    .buttonStyle(.plain)\n    .help(count == 0 ? \"No active terminal sessions\" : \"\\(count) active terminal session\\(count == 1 ? \"\" : \"s\")\")\n    .onHover { hovering in\n      withAnimation(.easeInOut(duration: 0.15)) {\n        isHovering = hovering\n      }\n    }\n    .popover(isPresented: $showPopover, arrowEdge: .top) {\n      ActiveTerminalSessionsPopover(\n        runningSessionIDs: runningSessionIDs,\n        viewModel: viewModel,\n        isPresented: $showPopover\n      )\n    }\n  }\n\n  private var iconColor: Color {\n    return isHovering ? Color.primary : Color.primary.opacity(0.55)\n  }\n\n  private var backgroundColor: Color {\n    return (isHovering ? Color.primary.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.18))\n  }\n\n  private var borderColor: Color {\n    return Color(nsColor: .separatorColor).opacity(isHovering ? 0.65 : 0.45)\n  }\n}\n\nprivate struct ActiveTerminalSessionsPopover: View {\n  let runningSessionIDs: Set<String>\n  let viewModel: SessionListViewModel\n  @Binding var isPresented: Bool\n  @Environment(\\.colorScheme) private var colorScheme\n\n  private static let timeFormatter: DateFormatter = {\n    let formatter = DateFormatter()\n    formatter.setLocalizedDateFormatFromTemplate(\"HH:mm:ss\")\n    return formatter\n  }()\n\n  private var summaryLookup: [SessionSummary.ID: SessionSummary] {\n    Dictionary(\n      uniqueKeysWithValues: viewModel.sections\n        .flatMap(\\.sessions)\n        .map { ($0.id, $0) }\n    )\n  }\n\n  private var sessions: [(id: String, summary: SessionSummary?)] {\n    runningSessionIDs.map { id in\n      (id: id, summary: summaryLookup[id])\n    }\n    .sorted { lhs, rhs in\n      let lhsDate = lhs.summary?.lastUpdatedAt ?? lhs.summary?.startedAt ?? Date.distantPast\n      let rhsDate = rhs.summary?.lastUpdatedAt ?? rhs.summary?.startedAt ?? Date.distantPast\n      return lhsDate > rhsDate\n    }\n  }\n\n  private var listHeight: CGFloat {\n    let rowHeight: CGFloat = 42\n    let dividerHeight: CGFloat = 1\n    let count = CGFloat(sessions.count)\n    let dividers = max(count - 1, 0)\n    return count * rowHeight + dividers * dividerHeight + 6\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 0) {\n      // Sessions list\n      if sessions.isEmpty {\n        VStack(spacing: 12) {\n          Image(systemName: \"terminal\")\n            .font(.system(size: 32, weight: .light))\n            .foregroundStyle(.tertiary)\n\n          Text(\"No active terminal sessions\")\n            .font(.footnote)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity)\n        .padding(.vertical, 32)\n      } else {\n        ScrollView {\n          VStack(spacing: 0) {\n            ForEach(Array(sessions.enumerated()), id: \\.element.id) { index, session in\n              SessionRowView(\n                sessionId: session.id,\n                summary: session.summary,\n                viewModel: viewModel,\n                colorScheme: colorScheme,\n                onSelect: { handleSessionSelect(session.id, summary: session.summary) }\n              )\n              if index < sessions.count - 1 {\n                Divider()\n                  .padding(.leading, 28)\n              }\n            }\n          }\n        }\n        .frame(height: listHeight)\n        .padding(.top, 6)\n      }\n    }\n    .frame(width: 320)\n    .padding(16)\n  }\n\n  private func handleSessionSelect(_ sessionId: String, summary: SessionSummary?) {\n    if let summary = summary {\n      focusSession(summary)\n    } else {\n      NotificationCenter.default.post(\n        name: .codMateResumeSession,\n        object: nil,\n        userInfo: [\"sessionId\": sessionId]\n      )\n    }\n    isPresented = false\n  }\n\n  private func focusSession(_ summary: SessionSummary) {\n    NotificationCenter.default.post(\n      name: .codMateFocusSessionSummary,\n      object: nil,\n      userInfo: [\"summary\": summary]\n    )\n  }\n\n  private struct SessionRowView: View {\n    let sessionId: String\n    let summary: SessionSummary?\n    let viewModel: SessionListViewModel\n    let colorScheme: ColorScheme\n    let onSelect: () -> Void\n    @State private var isHovering = false\n\n    var body: some View {\n      Button(action: onSelect) {\n        HStack(spacing: 10) {\n          iconView\n          VStack(alignment: .leading, spacing: 2) {\n            Text(displayName)\n              .font(.subheadline.weight(.medium))\n              .foregroundStyle(.primary)\n              .lineLimit(1)\n            Text(\"Started \\(startTimeText)\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n          }\n          Spacer()\n          Image(systemName: \"chevron.right\")\n            .font(.system(size: 10, weight: .semibold))\n            .foregroundStyle(.tertiary)\n            .opacity(isHovering ? 1 : 0)\n        }\n        .padding(.horizontal, 8)\n        .padding(.vertical, 8)\n        .contentShape(Rectangle())\n        .background(\n          RoundedRectangle(cornerRadius: 6)\n            .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear)\n        )\n      }\n      .buttonStyle(.plain)\n      .focusable(false)\n      .onHover { hovering in\n        withAnimation(.easeInOut(duration: 0.1)) {\n          isHovering = hovering\n        }\n      }\n    }\n\n    private var iconView: some View {\n      Group {\n        if let summary = summary {\n          let branding = summary.source.branding\n          if let asset = branding.badgeAssetName {\n            let shouldInvertCodexDark = summary.source.baseKind == .codex && colorScheme == .dark\n            Image(asset)\n              .resizable()\n              .renderingMode(.original)\n              .aspectRatio(contentMode: .fit)\n              .frame(width: 18, height: 18)\n              .modifier(DarkModeInvertModifier(active: shouldInvertCodexDark))\n          } else {\n            Image(systemName: branding.symbolName)\n              .font(.system(size: 13, weight: .semibold))\n              .foregroundStyle(branding.iconColor)\n              .frame(width: 18, height: 18)\n          }\n        } else {\n          Image(systemName: \"terminal.fill\")\n            .font(.system(size: 13, weight: .medium))\n            .foregroundStyle(Color.secondary)\n            .frame(width: 18, height: 18)\n        }\n      }\n    }\n\n    private var displayName: String {\n      if let summary = summary {\n        return summary.effectiveTitle\n      }\n      if sessionId.hasPrefix(\"new-anchor:\") {\n        return \"New session\"\n      }\n      return sessionId\n    }\n\n    private var startTimeText: String {\n      if let summary = summary {\n        return ActiveTerminalSessionsPopover.timeFormatter.string(from: summary.startedAt)\n      }\n      return \"--:--:--\"\n    }\n  }\n}\n\n\nprivate struct ToolbarCircleButton: View {\n  let systemImage: String\n  var isActive: Bool = false\n  var activeColor: Color? = nil\n  var showProgress: Bool = false\n  var help: String?\n  var action: () -> Void\n  @State private var hovering = false\n\n  var body: some View {\n    Button(action: action) {\n      ZStack {\n        if showProgress {\n          ProgressView()\n            .progressViewStyle(.circular)\n            .controlSize(.small)\n        } else {\n          Image(systemName: systemImage)\n            .font(.system(size: 16, weight: .medium))\n            .foregroundStyle(iconColor)\n        }\n      }\n      .frame(width: 14, height: 14)\n      .padding(8)\n      .background(\n        Circle()\n          .fill(backgroundColor)\n      )\n      .overlay(\n        Circle()\n          .stroke(borderColor, lineWidth: 1)\n      )\n      .contentShape(Circle())\n    }\n    .buttonStyle(.plain)\n    .help(help ?? \"\")\n    .onHover { hover in\n      withAnimation(.easeInOut(duration: 0.15)) {\n        hovering = hover\n      }\n    }\n  }\n\n  private var iconColor: Color {\n    if isActive, let activeColor {\n      return activeColor\n    }\n    return hovering ? Color.primary : Color.primary.opacity(0.55)\n  }\n\n  private var backgroundColor: Color {\n    if isActive || showProgress {\n      return Color.primary.opacity(0.08)\n    }\n    return (hovering ? Color.primary.opacity(0.12) : Color(nsColor: .separatorColor).opacity(0.18))\n  }\n\n  private var borderColor: Color {\n    return Color(nsColor: .separatorColor).opacity(hovering ? 0.65 : 0.45)\n  }\n}\n\n#if os(macOS)\nimport AppKit\n\n// Custom ViewModifier to prevent a view hierarchy from accepting first responder\nprivate struct RefuseFirstResponderModifier: ViewModifier {\n  let shouldRefuse: Bool\n\n  func body(content: Content) -> some View {\n    content.background(\n      RefuseFirstResponderHelper(shouldRefuse: shouldRefuse)\n    )\n  }\n}\n\nprivate struct RefuseFirstResponderHelper: NSViewRepresentable {\n  let shouldRefuse: Bool\n\n  func makeNSView(context: Context) -> RefuseFirstResponderView {\n    let view = RefuseFirstResponderView()\n    view.shouldRefuse = shouldRefuse\n    return view\n  }\n\n  func updateNSView(_ nsView: RefuseFirstResponderView, context: Context) {\n    nsView.shouldRefuse = shouldRefuse\n  }\n}\n\nprivate class RefuseFirstResponderView: NSView {\n  var shouldRefuse: Bool = false {\n    didSet {\n      if shouldRefuse != oldValue {\n        // Traverse the view hierarchy and apply refusal to all subviews\n        applyRefusalToHierarchy(shouldRefuse)\n      }\n    }\n  }\n\n  override var acceptsFirstResponder: Bool {\n    if shouldRefuse {\n      return false\n    }\n    return super.acceptsFirstResponder\n  }\n\n  private func applyRefusalToHierarchy(_ refuse: Bool) {\n    // Walk up to find the root of the list content\n    var current: NSView? = self.superview\n    while let view = current {\n      if let outlineView = view as? NSOutlineView {\n        outlineView.refusesFirstResponder = refuse\n        if refuse, let window = outlineView.window, window.firstResponder === outlineView {\n          window.makeFirstResponder(nil)\n        }\n        return\n      }\n      // Also check for NSTableView (in case List uses it)\n      if let tableView = view as? NSTableView {\n        tableView.refusesFirstResponder = refuse\n        if refuse, let window = tableView.window, window.firstResponder === tableView {\n          window.makeFirstResponder(nil)\n        }\n        return\n      }\n      current = view.superview\n    }\n  }\n}\n\nextension View {\n  func refuseFirstResponder(when condition: Bool) -> some View {\n    self.modifier(RefuseFirstResponderModifier(shouldRefuse: condition))\n  }\n}\n#endif\n"
  },
  {
    "path": "views/Content/ContentView.swift",
    "content": "import AppKit\nimport SwiftUI\nimport UniformTypeIdentifiers\n\nstruct ContentView: View {\n  @ObservedObject var viewModel: SessionListViewModel\n  @ObservedObject var preferences: SessionPreferencesStore\n  @StateObject var permissionsManager = SandboxPermissionsManager.shared\n  @StateObject var statusBarStore = StatusBarLogStore.shared\n  @Environment(\\.colorScheme) var colorScheme\n  @Environment(\\.openWindow) var openWindow\n  // Stable shared cache for project Review VMs to avoid ephemeral lifetimes\n  // that can lead to ObservedObject referencing deallocated instances during\n  // split-view construction. Using a static store prevents state mutations\n  // during body evaluation and keeps a single VM per project across columns.\n  private static var sharedProjectReviewVMs: [String: GitChangesViewModel] = [:]\n\n  @State var columnVisibility: NavigationSplitViewVisibility = .all\n  @State var selection = Set<SessionSummary.ID>()\n  @State var selectionPrimaryId: SessionSummary.ID? = nil\n  @State var lastSelectionSnapshot = Set<SessionSummary.ID>()\n  @State var isPerformingAction = false\n  @State var deleteConfirmationPresented = false\n  @State var alertState: AlertState?\n  @State var selectingSessionsRoot = false\n  // Track which sessions are running in embedded terminal\n  @State var runningSessionIDs = Set<SessionSummary.ID>()\n  @State var selectedTerminalKey: SessionSummary.ID? = nil\n  @State var isDetailMaximized = false\n  @State var isListHidden = false\n  @SceneStorage(\"cm.sidebarHidden\") var storeSidebarHidden: Bool = false\n  @SceneStorage(\"cm.listHidden\") var storeListHidden: Bool = false\n  // Persist content column (sessions list / review left pane) preferred width\n  @State var contentColumnIdealWidth: CGFloat = 420\n  @State var sidebarNewProjectPrefill: ProjectEditorSheet.Prefill? = nil\n  @State var projectEditorTarget: Project? = nil\n  @State var showNewTaskSheet: Bool = false\n  // When starting embedded sessions, record the initial command lines per-session\n  @State var embeddedInitialCommands: [SessionSummary.ID: String] = [:]\n  // Soft-return flag: when true, stopping embedded terminal should not change\n  // sidebar/list expand/collapse; keep overall layout stable.\n  @State var softReturnPending: Bool = false\n  // Confirm stopping a running embedded terminal\n  struct ConfirmStopState: Identifiable {\n    let id = UUID()\n    let sessionId: String\n    let terminalKey: String\n  }\n  @State var confirmStopState: ConfirmStopState? = nil\n  struct PendingTerminalLaunch: Identifiable {\n    let id = UUID()\n    let session: SessionSummary\n  }\n  @State var pendingTerminalLaunch: PendingTerminalLaunch? = nil\n  // Prompt picker state for embedded terminal quick-insert\n  @State var showPromptPicker = false\n  @State var promptQuery = \"\"\n  // Debounced query to keep filtering cheap on main thread\n  @State var throttledPromptQuery = \"\"\n  @State var promptDebounceTask: Task<Void, Never>? = nil\n  @StateObject var globalSearchViewModel: GlobalSearchViewModel\n  @State var selectedUsageProvider: UsageProviderKind = .codex\n  @State var pendingSelectionID: String? = nil\n  @State var pendingConversationFilter: (id: String, term: String)? = nil\n  @State var isSearchPopoverPresented = false\n  @State var searchPopoverSize: CGSize = ContentView.defaultSearchPopoverSize\n  @State var shouldBlockAutoSelection = false\n  @State var popoverDismissDisabled = false\n  @State var lastWorkspaceMode: ProjectWorkspaceMode? = nil\n  @StateObject var overviewViewModel: AllOverviewViewModel\n  @State var reviewRefreshToken: Int = 0\n  @State var agentsRefreshToken: Int = 0\n  @State var projectOverviewRefreshToken: Int = 0\n  @State var sidebarWidth: CGFloat = 0\n  // Preference key to read sidebar width\n  struct SidebarWidthPreferenceKey: PreferenceKey {\n    static var defaultValue: CGFloat = 0\n    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { value = nextValue() }\n  }\n  static let defaultSearchPopoverSize = CGSize(width: 440, height: 320)\n  static let searchPopoverMinSize = CGSize(width: 380, height: 220)\n  static let searchPopoverMaxSize = CGSize(width: 640, height: 520)\n  // Deprecated: keep for future removal; no longer used for retention.\n  // @State private var projectReviewVMs: [String: GitChangesViewModel] = [:]\n  struct SourcedPrompt: Identifiable, Hashable {\n    let id = UUID()\n    enum Source: Hashable { case project, user, builtin }\n    var prompt: PresetPromptsStore.Prompt\n    var source: Source\n    var label: String { prompt.label }\n    var command: String { prompt.command }\n\n    // Custom Hashable implementation to hash based on content, not UUID\n    func hash(into hasher: inout Hasher) {\n      hasher.combine(prompt)\n      hasher.combine(source)\n    }\n\n    static func == (lhs: SourcedPrompt, rhs: SourcedPrompt) -> Bool {\n      lhs.prompt == rhs.prompt && lhs.source == rhs.source\n    }\n  }\n  @State var loadedPrompts: [SourcedPrompt] = []\n  @State var hoveredPromptKey: String? = nil\n  func promptKey(_ p: SourcedPrompt) -> String { p.command }\n  func canDelete(_ p: SourcedPrompt) -> Bool { true }\n  @State var pendingDelete: SourcedPrompt? = nil\n  // Build highlighted text where matches of `query` are tinted; non-matches use the provided base color\n  func highlightedText(_ text: String, query: String, base: Color = .primary) -> Text {\n    guard !query.isEmpty else {\n      let baseText = Text(text).foregroundColor(base)\n      return baseText\n    }\n\n    var result = Text(\"\")\n    var searchStart = text.startIndex\n    let end = text.endIndex\n\n    while searchStart < end,\n      let r = text.range(\n        of: query, options: [.caseInsensitive, .diacriticInsensitive], range: searchStart..<end)\n    {\n      if r.lowerBound > searchStart {\n        let prefix = String(text[searchStart..<r.lowerBound])\n        let prefixText = Text(prefix).foregroundColor(base)\n        result = result + prefixText\n      }\n\n      let match = String(text[r])\n      let matchText = Text(match).foregroundColor(.accentColor)\n      result = result + matchText\n\n      searchStart = r.upperBound\n    }\n\n    if searchStart < end {\n      let tail = String(text[searchStart..<end])\n      let tailText = Text(tail).foregroundColor(base)\n      result = result + tailText\n    }\n\n    return result\n  }\n  func builtinPrompts() -> [PresetPromptsStore.Prompt] {\n    []\n  }\n  func makeSidebarActions() -> SidebarActions {\n    SidebarActions(\n      selectAllProjects: { viewModel.setSelectedProject(nil) },\n      requestNewProject: {\n        // Using .sheet(item:) - set empty prefill to trigger sheet without pre-filled data\n        sidebarNewProjectPrefill = ProjectEditorSheet.Prefill()\n      },\n      requestNewTask: {\n        showNewTaskSheet = true\n      },\n      setDateDimension: { viewModel.dateDimension = $0 },\n      setMonthStart: { viewModel.setSidebarMonthStart($0) },\n      setSelectedDay: { viewModel.setSelectedDay($0) },\n      toggleSelectedDay: { viewModel.toggleSelectedDay($0) }\n    )\n  }\n  enum DetailTab: Hashable { case timeline, review, terminal }\n  // Per-session detail tab state: tracks which tab (timeline/review/terminal) each session is viewing\n  @State var sessionDetailTabs: [SessionSummary.ID: DetailTab] = [:]\n  // Current displayed tab (synced with focused session's state)\n  @State var selectedDetailTab: DetailTab = .timeline\n  // Track pending rekey for embedded New so we can move the PTY to the real new session id\n  struct PendingEmbeddedRekey {\n    let anchorId: String\n    let expectedCwd: String\n    let t0: Date\n    let selectOnSuccess: Bool\n    let projectId: String?\n  }\n  @State var pendingEmbeddedRekeys: [PendingEmbeddedRekey] = []\n  func makeTerminalFont() -> NSFont {\n    TerminalFontResolver.resolvedFont(\n      name: viewModel.preferences.terminalFontName,\n      size: viewModel.preferences.clampedTerminalFontSize\n    )\n  }\n\n  init(viewModel: SessionListViewModel) {\n    self.viewModel = viewModel\n    _preferences = ObservedObject(wrappedValue: viewModel.preferences)\n    _globalSearchViewModel = StateObject(\n      wrappedValue: GlobalSearchViewModel(\n        preferences: viewModel.preferences,\n        sessionListViewModel: viewModel\n      )\n    )\n    _overviewViewModel = StateObject(\n      wrappedValue: AllOverviewViewModel(sessionListViewModel: viewModel)\n    )\n  }\n\n  var body: some View {\n    GeometryReader { geometry in\n      ZStack {\n        WindowConfigurator { window in\n          MainWindowCoordinator.shared.attach(window)\n          window.identifier = NSUserInterfaceItemIdentifier(\"CodMateMainWindow\")\n          MenuBarController.shared.reapplyVisibilityFromPreferences()\n        }\n        .frame(width: 0, height: 0)\n\n        ZStack(alignment: .bottomLeading) {\n          navigationSplitView(geometry: geometry)\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n\n          // Status bar overlay - positioned to cover only the main content area (content+detail columns)\n          // It should start after the sidebar and span to the right edge\n          if preferences.statusBarVisibility != .hidden {\n            StatusBarOverlayView(\n              store: statusBarStore,\n              preferences: preferences,\n              sidebarInset: 0\n            )\n            .frame(width: geometry.size.width - statusBarSidebarInset)\n            .offset(x: statusBarSidebarInset)\n            .frame(height: statusBarReservedHeight, alignment: .bottom)\n          }\n        }\n        .sheet(item: $projectEditorTarget) { target in\n          ProjectEditorSheet(\n            isPresented: Binding(\n              get: { true },\n              set: { if !$0 { projectEditorTarget = nil } }\n            ),\n            mode: .edit(existing: target)\n          )\n          .environmentObject(viewModel)\n        }\n        .sheet(isPresented: $showNewTaskSheet) {\n          NewTaskSheet(viewModel: viewModel)\n        }\n      }\n      .onAppear {\n        MainWindowCoordinator.shared.applyMenuVisibility(preferences.systemMenuVisibility)\n      }\n      .onChange(of: preferences.systemMenuVisibility) { newValue in\n        MainWindowCoordinator.shared.applyMenuVisibility(newValue)\n      }\n    }\n  }\n\n\n  private var statusBarSidebarInset: CGFloat {\n    // Sidebar inset: check if sidebar is actually visible\n    // NavigationSplitViewVisibility.all means sidebar is visible\n    // .doubleColumn means sidebar is hidden (only content+detail visible)\n    // .detailOnly means only detail is visible (sidebar and content hidden)\n    // Sidebar width is fixed at 260pt according to navigationSplitViewColumnWidth\n    switch columnVisibility {\n    case .all:\n      // Sidebar is visible, offset by sidebar width (dynamic)\n      return sidebarWidth\n    case .doubleColumn:\n      // Sidebar is hidden, no offset needed\n      return 0\n    case .detailOnly:\n      // Sidebar is hidden, no offset needed\n      return 0\n    default:\n      // Fallback: use storeSidebarHidden as backup\n      return storeSidebarHidden ? 0 : sidebarWidth\n    }\n  }\n\n  var statusBarReservedHeight: CGFloat {\n    guard preferences.statusBarVisibility != .hidden else { return 0 }\n    return statusBarStore.isExpanded ? statusBarStore.expandedHeight : statusBarStore.collapsedHeight\n  }\n\n  // navigationSplitView moved to Content/ContentView+Modifiers.swift\n\n  // applyTaskAndChangeModifiers moved to Content/ContentView+Modifiers.swift\n\n  // applyNotificationModifiers moved to Content/ContentView+Modifiers.swift\n\n  // applyDialogsAndAlerts moved to Content/ContentView+Modifiers.swift\n\n  // sidebarContent moved to Content/ContentView+Sidebar.swift\n\n  // listContent moved to Content/ContentView+Sidebar.swift\n\n  // refreshToolbarContent moved to Content/ContentView+Sidebar.swift\n\n  // detailColumn moved to Content/ContentView+Detail.swift\n\n  // mainDetailContent moved to ContentView+MainDetail.swift\n\n  // detailActionBar moved to ContentView+DetailActionBar.swift\n\n  // focusedSummary and summaryLookup moved to ContentView+Helpers.swift\n\n  func normalizeSelection() {\n    let orderedIDs = viewModel.sections.flatMap { $0.sessions.map(\\.id) }\n    let validIDs = Set(orderedIDs)\n    let original = selection\n    selection.formIntersection(validIDs)\n\n    // Don't auto-select first item when blocked (e.g., when search popover is about to open)\n    if selection.isEmpty, let first = orderedIDs.first, !shouldBlockAutoSelection {\n      selection.insert(first)\n    }\n    // Avoid unnecessary churn if nothing changed\n    if selection == original { return }\n  }\n\n  // Provide a stable GitChangesViewModel per selected project for Review layout\n  func projectReviewVM(for projectId: String) -> GitChangesViewModel {\n    if let existing = ContentView.sharedProjectReviewVMs[projectId] { return existing }\n    let vm = GitChangesViewModel()\n    ContentView.sharedProjectReviewVMs[projectId] = vm\n    return vm\n  }\n\n  func resumeFromList(_ session: SessionSummary, forceEmbedded: Bool = false, profileId: String? = nil) {\n    selection = [session.id]\n    selectionPrimaryId = session.id\n    if forceEmbedded {\n      startEmbedded(for: session)\n      return\n    }\n    if let pid = profileId,\n      let profile = ExternalTerminalProfileStore.shared.profile(for: pid)\n    {\n      launchResume(for: session, using: session.source, profile: profile)\n      return\n    }\n    if viewModel.preferences.defaultResumeUseEmbeddedTerminal {\n      startEmbedded(for: session)\n    } else {\n      openPreferredExternal(for: session)\n    }\n  }\n\n  func handleDeleteRequest(_ session: SessionSummary) {\n    if !selection.contains(session.id) {\n      selection = [session.id]\n    }\n    presentDeleteConfirmation()\n  }\n\n  // exportMarkdownForSession moved to ContentView+Helpers.swift\n\n  func presentDeleteConfirmation() {\n    guard !selection.isEmpty else { return }\n    deleteConfirmationPresented = true\n  }\n\n  func deleteSelections(ids: [SessionSummary.ID]) {\n    let summaries = ids.compactMap { summaryLookup[$0] }\n    guard !summaries.isEmpty else { return }\n\n    deleteConfirmationPresented = false\n    isPerformingAction = true\n\n    Task {\n      await viewModel.delete(summaries: summaries)\n      await MainActor.run {\n        // Best-effort: stop any embedded terminals for deleted sessions\n        for s in summaries { GhosttySessionManager.shared.removeScrollView(for: s.id) }\n        // Clean up per-session state for deleted sessions\n        for id in ids {\n          sessionDetailTabs.removeValue(forKey: id)\n          embeddedInitialCommands.removeValue(forKey: id)\n          runningSessionIDs.remove(id)\n        }\n        isPerformingAction = false\n        selection.subtract(ids)\n        normalizeSelection()\n      }\n    }\n  }\n\n  func startEmbedded(for session: SessionSummary, using source: SessionSource? = nil) {\n    let target = source.map { session.overridingSource($0) } ?? session\n    #if APPSTORE\n      openPreferredExternal(for: target)\n      return\n    #else\n      // Ensure cwd authorization under App Sandbox (both shell and CLI modes)\n      let cwd = workingDirectory(for: target)\n      let dirURL = URL(fileURLWithPath: cwd, isDirectory: true)\n      if !AuthorizationHub.shared.canAccessNow(directory: dirURL) {\n        let toolLabel = target.source.baseKind.cliExecutableName\n        let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n          directory: dirURL,\n          purpose: .cliConsoleCwd,\n          message: \"Authorize this folder for CLI console to run \\(toolLabel)\"\n        )\n        guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else {\n          // Do not start embedded; remain in timeline\n          return\n        }\n      }\n      // Build the default resume commands for this session so TerminalHostView can inject them\n      embeddedInitialCommands[target.id] = viewModel.buildResumeCommands(session: target)\n      runningSessionIDs.insert(target.id)\n      selectedTerminalKey = target.id\n      // Switch detail surface to Terminal when embedded starts\n      selectedDetailTab = .terminal\n      sessionDetailTabs[target.id] = .terminal\n      // User has taken over: clear awaiting follow-up highlight\n      viewModel.clearAwaitingFollowup(target.id)\n      // DISABLED: Ghostty doesn't need slash nudge\n    #endif\n  }\n\n  func stopEmbedded(forID id: SessionSummary.ID) {\n    // Tear down the embedded terminal view and terminate its child process\n    GhosttySessionManager.shared.removeScrollView(for: id)\n    runningSessionIDs.remove(id)\n    embeddedInitialCommands.removeValue(forKey: id)\n    if selectedTerminalKey == id {\n      selectedTerminalKey = runningSessionIDs.first\n    }\n    // Exit embedded terminal: clear awaiting follow-up highlight\n    viewModel.clearAwaitingFollowup(id)\n    if selectedDetailTab == .terminal {\n      selectedDetailTab = .timeline\n    }\n    // If this stop is triggered by Return to History, do not alter sidebar/list\n    // visibility to keep the view stable.\n    if softReturnPending {\n      softReturnPending = false\n      NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil)\n      return\n    }\n    // Default behavior: if no embedded terminals left, restore default columns\n    if runningSessionIDs.isEmpty {\n      isDetailMaximized = false\n      columnVisibility = .all\n    }\n    NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil)\n  }\n\n  func stopEmbedded(forKey key: String) {\n    if summaryLookup[key] != nil {\n      stopEmbedded(forID: key)\n      return\n    }\n      GhosttySessionManager.shared.removeScrollView(for: key)\n    runningSessionIDs.remove(key)\n    embeddedInitialCommands.removeValue(forKey: key)\n    if selectedTerminalKey == key {\n      selectedTerminalKey = runningSessionIDs.first\n    }\n    if selectedDetailTab == .terminal {\n      selectedDetailTab = .timeline\n    }\n    if softReturnPending {\n      softReturnPending = false\n      NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil)\n      return\n    }\n    if runningSessionIDs.isEmpty {\n      isDetailMaximized = false\n      columnVisibility = .all\n    }\n    NotificationCenter.default.post(name: .codMateTerminalSessionsUpdated, object: nil)\n  }\n\n  private func isTerminalLikelyRunning(forID id: SessionSummary.ID) -> Bool {\n    // Multi-layer detection for more accurate running state:\n    // 1. Check if terminal manager reports a running process\n      if GhosttySessionManager.shared.hasRunningProcess(for: id) {\n        return true\n      }\n\n    // 2. Check if this is a pending new session (anchor awaiting rekey)\n    if pendingEmbeddedRekeys.contains(where: { $0.anchorId == id }) {\n      return true\n    }\n\n    // 3. Check recent file activity heartbeat (session actively writing)\n    if viewModel.isActivelyUpdating(id) {\n      return true\n    }\n\n    return false\n  }\n\n  func requestStopEmbedded(forID id: SessionSummary.ID) {\n    // Always check current running state before showing confirmation\n    let isRunning = isTerminalLikelyRunning(forID: id)\n\n    if isRunning {\n      // Show confirmation dialog for running sessions\n      confirmStopState = ConfirmStopState(sessionId: id, terminalKey: id)\n    } else {\n      // Directly stop if not running\n      stopEmbedded(forID: id)\n    }\n  }\n\n  func requestStopEmbedded(forKey key: String) {\n      let isRunning = GhosttySessionManager.shared.hasRunningProcess(for: key)\n      if isRunning {\n        let sessionId = summaryLookup[key]?.id ?? key\n        confirmStopState = ConfirmStopState(sessionId: sessionId, terminalKey: key)\n      } else {\n        stopEmbedded(forKey: key)\n      }\n  }\n\n  private func shellEscapeForCD(_ path: String) -> String {\n    // Minimal POSIX shell escaping suitable for `cd` arguments\n    return \"'\" + path.replacingOccurrences(of: \"'\", with: \"'\\\\''\") + \"'\"\n  }\n\n  /// Launches a new session using the given anchor and shared task context.\n  /// This regenerates ~/.codmate/tasks/context-<taskId>.md before launching.\n  func newSessionWithTaskContext(\n    task: CodMateTask,\n    anchor: SessionSummary?,\n    source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    if let anchor = anchor {\n      // Only support local sessions as anchors for now; remote sessions\n      // cannot reliably access the local ~/.codmate/tasks directory.\n      guard !anchor.isRemote else { return }\n    }\n\n    Task {\n      let prompt: String\n      let effectiveAnchor: SessionSummary\n      var projectOverride: Project? = nil\n\n      if let anchor = anchor {\n          guard let workspaceVM = viewModel.workspaceVM else { return }\n          _ = await workspaceVM.syncTaskContext(taskId: task.id)\n\n          let taskIdString = task.id.uuidString\n          let pathHint = \"~/.codmate/tasks/context-\\(taskIdString).md\"\n          let promptLines: [String] = [\n            \"The shared context for the current Task has been organized and saved to a local file:\",\n            pathHint,\n            \"\",\n            \"Before answering this question, if needed, please read this file first to understand the task history and related constraints.\",\n          ]\n          prompt = promptLines.joined(separator: \"\\n\")\n          effectiveAnchor = anchor\n      } else {\n          // No anchor, find project and create dummy\n          guard let project = viewModel.projects.first(where: { $0.id == task.projectId }) else { return }\n          projectOverride = project\n\n          let cwd = project.directory ?? NSHomeDirectory()\n\n          effectiveAnchor = SessionSummary(\n            id: UUID().uuidString,\n            fileURL: URL(fileURLWithPath: \"/dev/null\"),\n            fileSizeBytes: 0,\n            startedAt: Date(),\n            endedAt: nil,\n            activeDuration: nil,\n            cliVersion: \"\",\n            cwd: cwd,\n            originator: \"system\",\n            instructions: nil,\n            model: nil,\n            approvalPolicy: nil,\n            userMessageCount: 0,\n            assistantMessageCount: 0,\n            toolInvocationCount: 0,\n            responseCounts: [:],\n            turnContextCount: 0,\n            totalTokens: 0,\n            eventCount: 0,\n            lineCount: 0,\n            lastUpdatedAt: Date(),\n            source: source,\n            remotePath: nil\n          )\n\n          var lines = [\"Task: \\(task.title)\"]\n          if let desc = task.description, !desc.isEmpty {\n              lines.append(\"\")\n              lines.append(desc)\n          }\n          prompt = lines.joined(separator: \"\\n\")\n      }\n\n      #if APPSTORE\n        // App Store version does not support embedded terminal, use external terminal flow directly.\n        launchNewSession(\n          for: effectiveAnchor,\n          using: source,\n          profile: profile,\n          initialPrompt: prompt,\n          warpTitle: task.effectiveTitle,\n          projectOverride: projectOverride\n        )\n      #else\n        if profile.id == \"codmate.embedded\" {\n          // Run a new session in the embedded terminal and inject Task context as the initial prompt.\n          startEmbeddedNewWithPrompt(anchor: effectiveAnchor, using: source, prompt: prompt, task: task, projectOverride: projectOverride)\n        } else {\n          // Use the selected external terminal configuration\n          launchNewSession(\n            for: effectiveAnchor,\n            using: source,\n            profile: profile,\n            initialPrompt: prompt,\n            warpTitle: task.effectiveTitle,\n            projectOverride: projectOverride\n          )\n        }\n      #endif\n\n      if viewModel.preferences.commandCopyNotificationsEnabled {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\",\n          body: anchor != nil\n            ? \"Command copied. Session starts with shared Task context.\"\n            : \"Command copied. Session starts with Task prompt.\"\n        )\n      }\n    }\n  }\n\n  func startEmbeddedNewWithPrompt(\n    anchor: SessionSummary,\n    using source: SessionSource,\n    prompt: String,\n    task: CodMateTask,\n    projectOverride: Project? = nil\n  ) {\n    selectedDetailTab = .terminal\n    sessionDetailTabs[anchor.id] = .terminal\n    let target = source == anchor.source ? anchor : anchor.overridingSource(source)\n    let cwd =\n      FileManager.default.fileExists(atPath: target.cwd)\n      ? target.cwd : target.fileURL.deletingLastPathComponent().path\n    let commandLines = viewModel.buildEmbeddedNewSessionCommands(\n      session: target,\n      initialPrompt: prompt,\n      projectOverride: projectOverride\n    )\n    let preclear = \"printf '\\\\033[?1049h\\\\033[H\\\\033[2J'\"\n\n    // Use a virtual anchor id to avoid hijacking an existing session's running state\n    let anchorId = \"new-anchor:task:\\(task.id.uuidString):\\(Int(Date().timeIntervalSince1970)))\"\n    embeddedInitialCommands[anchorId] =\n      preclear + \"\\n\" + commandLines\n    runningSessionIDs.insert(anchorId)\n    selectedTerminalKey = anchorId\n    sessionDetailTabs[anchorId] = .terminal\n    pendingEmbeddedRekeys.append(\n      PendingEmbeddedRekey(\n        anchorId: anchorId,\n        expectedCwd: canonicalizePath(cwd),\n        t0: Date(),\n        selectOnSuccess: true,\n        projectId: task.projectId\n      )\n    )\n    // Event-driven incremental refresh for quick visibility in Tasks/Sessions lists\n    applyIncrementalHint(for: target.source, directory: cwd)\n    scheduleIncrementalRefresh(for: target.source, directory: cwd)\n    selection.removeAll()\n    isDetailMaximized = true\n    columnVisibility = .detailOnly\n  }\n\n  func workingDirectory(for session: SessionSummary) -> String {\n    viewModel.resolvedWorkingDirectory(for: session)\n  }\n\n  func preferredExternalTerminalProfile() -> ExternalTerminalProfile? {\n    ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n      id: viewModel.preferences.defaultResumeExternalAppId\n    )\n  }\n\n  func projectDirectory(for session: SessionSummary) -> String? {\n    guard\n      let pid = viewModel.projectIdForSession(session.id),\n      let project = viewModel.projects.first(where: { $0.id == pid }),\n      let directory = project.directory,\n      !directory.isEmpty\n    else { return nil }\n    if FileManager.default.fileExists(atPath: directory) {\n      return directory\n    }\n    return directory\n  }\n\n  func ensureRepoAccessForReview() {\n    guard let focused = focusedSummary else { return }\n    // Non-sandboxed builds don't require bookmark authorization or forced refresh\n    if SecurityScopedBookmarks.shared.isSandboxed == false {\n      return\n    }\n    let dir = workingDirectory(for: focused)\n    let startURL = URL(fileURLWithPath: dir, isDirectory: true)\n\n    // Resolve repository root by walking up to the nearest folder that contains .git\n    func findRepoRootByFS(from start: URL) -> URL? {\n      let fm = FileManager.default\n      var cur = start.standardizedFileURL\n      var guardCounter = 0\n      while guardCounter < 200 {  // safety guard\n        let gitDir = cur.appendingPathComponent(\".git\", isDirectory: true)\n        var isDir: ObjCBool = false\n        if fm.fileExists(atPath: gitDir.path, isDirectory: &isDir) {\n          return cur\n        }\n        let parent = cur.deletingLastPathComponent()\n        if parent.path == cur.path { break }\n        cur = parent\n        guardCounter += 1\n      }\n      return nil\n    }\n\n    let repoRoot = findRepoRootByFS(from: startURL) ?? startURL\n\n    // If already authorized for this repo root, just ensure access is active and return silently\n    if SecurityScopedBookmarks.shared.hasDynamicBookmark(for: repoRoot) {\n      _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repoRoot)\n      return\n    }\n\n    // Use synchronous authorization to ensure we get the result before proceeding\n    let success = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n      directory: repoRoot,\n      purpose: .gitReviewRepo,\n      message: \"Authorize the repository folder (the one containing .git) for Git Review\"\n    )\n\n    if success {\n      print(\"[ContentView] Git review authorization successful for: \\(repoRoot.path)\")\n      // Force a view refresh by toggling away and back to Review\n      Task { @MainActor in\n        let was = selectedDetailTab\n        selectedDetailTab = .timeline\n        try? await Task.sleep(nanoseconds: 100_000_000)  // 0.1s\n        selectedDetailTab = was\n      }\n    } else {\n      print(\"[ContentView] Git review authorization failed or cancelled\")\n    }\n  }\n\n  func ensureRepoAccessForProjectReview(directory: String) {\n    // Non-sandboxed builds don't require bookmark authorization\n    if SecurityScopedBookmarks.shared.isSandboxed == false { return }\n    let startURL = URL(fileURLWithPath: directory, isDirectory: true)\n\n    func findRepoRootByFS(from start: URL) -> URL? {\n      let fm = FileManager.default\n      var cur = start.standardizedFileURL\n      var guardCounter = 0\n      while guardCounter < 200 {\n        let gitDir = cur.appendingPathComponent(\".git\", isDirectory: true)\n        var isDir: ObjCBool = false\n        if fm.fileExists(atPath: gitDir.path, isDirectory: &isDir) {\n          return cur\n        }\n        let parent = cur.deletingLastPathComponent()\n        if parent.path == cur.path { break }\n        cur = parent\n        guardCounter += 1\n      }\n      return nil\n    }\n\n    let repoRoot = findRepoRootByFS(from: startURL) ?? startURL\n\n    if SecurityScopedBookmarks.shared.hasDynamicBookmark(for: repoRoot) {\n      _ = SecurityScopedBookmarks.shared.startAccessDynamic(for: repoRoot)\n      return\n    }\n\n    let success = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n      directory: repoRoot,\n      purpose: .gitReviewRepo,\n      message: \"Authorize the repository folder (the one containing .git) for Git Review\"\n    )\n\n    if success {\n      print(\"[ContentView] Project Git review authorization successful for: \\(repoRoot.path)\")\n    } else {\n      print(\"[ContentView] Project Git review authorization failed or cancelled\")\n    }\n  }\n\n  // MARK: - Embedded CLI console specs (DEV)\n  private func consoleEnv(for source: SessionSource) -> [String: String] {\n    var env: [String: String] = [:]\n    env[\"LANG\"] = \"zh_CN.UTF-8\"\n    env[\"LC_ALL\"] = \"zh_CN.UTF-8\"\n    env[\"LC_CTYPE\"] = \"zh_CN.UTF-8\"\n    env[\"TERM\"] = \"xterm-256color\"\n    if source.baseKind == .codex { env[\"CODEX_DISABLE_COLOR_QUERY\"] = \"1\" }\n    return env\n  }\n\n\n  /// Schedule a short-lived incremental refresh loop to surface newly created\n  /// sessions for auto-assign (project / task) matching. Uses a 2s interval\n  /// for up to ~2 minutes, aligned with the PendingAssignIntent lifetime.\n  func startEmbeddedNew(for session: SessionSummary, using source: SessionSource? = nil) {\n    let target = source.map { session.overridingSource($0) } ?? session\n    #if APPSTORE\n      openPreferredExternalForNew(session: target)\n      return\n    #else\n      // Switch detail surface to Terminal tab when launching embedded new\n      selectedDetailTab = .terminal\n      sessionDetailTabs[session.id] = .terminal\n      // Build the 'new session' commands (respecting project profile when present)\n      let cwd =\n        FileManager.default.fileExists(atPath: target.cwd)\n        ? target.cwd : target.fileURL.deletingLastPathComponent().path\n      if viewModel.preferences.useEmbeddedCLIConsole {\n        let dirURL = URL(fileURLWithPath: cwd, isDirectory: true)\n        if !AuthorizationHub.shared.canAccessNow(directory: dirURL) {\n          let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n            directory: dirURL,\n            purpose: .cliConsoleCwd,\n            message: \"Authorize this folder for CLI console to run codex\"\n          )\n          guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else {\n            return\n          }\n        }\n      }\n      let commandLines = viewModel.buildEmbeddedNewSessionCommands(session: target)\n      // Enter alternate screen and clear for a truly clean view (cursor home);\n      // avoids reflow artifacts and isolates scrollback while the new session runs.\n      let preclear = \"printf '\\\\033[?1049h\\\\033[H\\\\033[2J'\"\n\n      // Use virtual anchor id to avoid hijacking an existing session's running state\n      let anchorId = \"new-anchor:detail:\\(target.id):\\(Int(Date().timeIntervalSince1970)))\"\n      embeddedInitialCommands[anchorId] =\n        preclear + \"\\n\" + commandLines\n      runningSessionIDs.insert(anchorId)\n      selectedTerminalKey = anchorId\n      sessionDetailTabs[anchorId] = .terminal\n      // Record pending rekey so that when the new session appears, we can move this PTY to the real id\n      pendingEmbeddedRekeys.append(\n        PendingEmbeddedRekey(\n          anchorId: anchorId,\n          expectedCwd: canonicalizePath(cwd),\n          t0: Date(),\n          selectOnSuccess: true,\n          projectId: viewModel.projectIdForSession(target.id)\n        )\n      )\n      // Event-driven incremental refresh: set a hint so directory monitor triggers a targeted refresh\n      applyIncrementalHint(for: target.source, directory: cwd)\n      // Proactively trigger a targeted incremental refresh for immediate visibility\n      scheduleIncrementalRefresh(for: target.source, directory: cwd)\n      // Clear selection so fallbackRunningAnchorId() can display the virtual anchor terminal\n      selection.removeAll()\n      // Ensure terminal is visible\n      isDetailMaximized = true\n      columnVisibility = .detailOnly\n    #endif\n  }\n\n  func startEmbeddedNewForProject(_ project: Project) {\n    #if APPSTORE\n      NSLog(\"📌 [ContentView] startEmbeddedNewForProject (APPSTORE fallback) id=%@\", project.id)\n      viewModel.newSession(project: project)\n      return\n    #else\n      // Build 'new project' invocation and inject into embedded terminal\n      let dir: String = {\n        let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n        return d.isEmpty ? NSHomeDirectory() : d\n      }()\n      NSLog(\n        \"📌 [ContentView] startEmbeddedNewForProject id=%@ dir=%@ useEmbeddedCLIConsole=%@\",\n        project.id, dir, viewModel.preferences.useEmbeddedCLIConsole ? \"YES\" : \"NO\"\n      )\n      // Ensure Terminal tab is active so the embedded session is visible\n      selectedDetailTab = .terminal\n      if viewModel.preferences.useEmbeddedCLIConsole {\n        let dirURL = URL(fileURLWithPath: dir, isDirectory: true)\n        if !AuthorizationHub.shared.canAccessNow(directory: dirURL) {\n          let granted = AuthorizationHub.shared.ensureDirectoryAccessOrPromptSync(\n            directory: dirURL,\n            purpose: .cliConsoleCwd,\n            message: \"Authorize this folder for CLI console to run codex\"\n          )\n          guard granted || AuthorizationHub.shared.canAccessNow(directory: dirURL) else {\n            NSLog(\"⚠️ [ContentView] Authorization denied for embedded New dir=%@\", dir)\n            return\n          }\n        }\n      }\n      let commandLines = viewModel.buildEmbeddedNewProjectCommands(project: project)\n      let preclear = \"printf '\\\\033[?1049h\\\\033[H\\\\033[2J'\"\n\n      // Always use a virtual anchor for project-level New\n      let anchorId = \"new-anchor:project:\\(project.id):\\(Int(Date().timeIntervalSince1970)))\"\n      NSLog(\"📌 [ContentView] Embedded New anchor=%@ command=%@\", anchorId, commandLines)\n      embeddedInitialCommands[anchorId] =\n        preclear + \"\\n\" + commandLines\n      runningSessionIDs.insert(anchorId)\n      selectedTerminalKey = anchorId\n      sessionDetailTabs[anchorId] = .terminal\n      // Pending rekey: when the new session lands under this cwd, move PTY to the real id\n      pendingEmbeddedRekeys.append(\n        PendingEmbeddedRekey(\n          anchorId: anchorId,\n          expectedCwd: canonicalizePath(dir),\n          t0: Date(),\n          selectOnSuccess: true,\n          projectId: project.id\n        )\n      )\n      // Event-driven incremental refresh: scoped to today's Codex folder\n      viewModel.setIncrementalHintForCodexToday()\n      // Proactively refresh today's subset so the new item appears quickly\n      Task {\n        await viewModel.refreshIncrementalForNewCodexToday()\n        // Follow-up probes to catch late file creation\n        try? await Task.sleep(nanoseconds: 600_000_000)\n        await viewModel.refreshIncrementalForNewCodexToday()\n        try? await Task.sleep(nanoseconds: 1_500_000_000)\n        await viewModel.refreshIncrementalForNewCodexToday()\n      }\n      // Clear selection so fallbackRunningAnchorId() can display the virtual anchor terminal\n      selection.removeAll()\n      // Maximize detail to show embedded terminal\n      isDetailMaximized = true\n      columnVisibility = .detailOnly\n    #endif\n  }\n\n  func openPreferredExternal(for session: SessionSummary, using source: SessionSource? = nil) {\n    let target = source.map { session.overridingSource($0) } ?? session\n    guard let profile = preferredExternalTerminalProfile() else { return }\n    guard viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile) else {\n      return\n    }\n    let dir = workingDirectory(for: target)\n    var didNotify = false\n    if profile.isNone {\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n    if profile.usesWarpCommands {\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n    } else if profile.isTerminal {\n      if !viewModel.openInTerminal(session: target) {\n        _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile)\n        _ = viewModel.openAppleTerminal(at: dir)\n        if viewModel.shouldCopyCommandsToClipboard {\n          if viewModel.preferences.commandCopyNotificationsEnabled {\n            Task {\n              await SystemNotifier.shared.notify(\n                title: \"CodMate\",\n                body: \"Command copied. Paste it in the opened terminal.\")\n            }\n            didNotify = true\n          }\n        }\n      }\n    } else {\n      let cmd = profile.supportsCommandResolved\n        ? viewModel.buildResumeCLIInvocationRespectingProject(session: target)\n        : nil\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n    }\n    if viewModel.shouldCopyCommandsToClipboard,\n      didNotify == false,\n      viewModel.preferences.commandCopyNotificationsEnabled\n    {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n  }\n\n  func openPreferredExternalForNew(session: SessionSummary, initialPrompt: String? = nil) {\n    guard let profile = preferredExternalTerminalProfile() else { return }\n    // Record pending intent for auto-assign before launching\n    viewModel.recordIntentForDetailNew(anchor: session)\n    let dir = workingDirectory(for: session)\n    // Event hint for targeted incremental refresh on FS change\n    applyIncrementalHint(for: session.source, directory: dir)\n    // Also proactively refresh the targeted subset for faster UI update\n    scheduleIncrementalRefresh(for: session.source, directory: dir)\n\n    guard viewModel.copyNewSessionCommandsIfEnabled(\n      session: session,\n      destinationApp: profile,\n      initialPrompt: initialPrompt\n    ) else { return }\n\n    if profile.isNone {\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n\n    if profile.usesWarpCommands {\n      // Warp scheme cannot run a command; open path only and rely on clipboard\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n    } else if profile.isTerminal {\n      #if APPSTORE\n        _ = viewModel.openAppleTerminal(at: dir)\n      #else\n        if let prompt = initialPrompt {\n          viewModel.openNewSessionRespectingProject(session: session, initialPrompt: prompt)\n        } else {\n          viewModel.openNewSessionRespectingProject(session: session)\n        }\n      #endif\n    } else {\n      let cmd: String? = {\n        guard profile.supportsCommandResolved else { return nil }\n        if let prompt = initialPrompt {\n          return viewModel.buildNewSessionCLIInvocationRespectingProject(\n            session: session,\n            initialPrompt: prompt\n          )\n        }\n        return viewModel.buildNewSessionCLIInvocationRespectingProject(session: session)\n      }()\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n    }\n    if viewModel.shouldCopyCommandsToClipboard\n      && viewModel.preferences.commandCopyNotificationsEnabled\n    {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n  }\n\n  func startNewSession(for session: SessionSummary, using source: SessionSource? = nil) {\n    let target = source.map { session.overridingSource($0) } ?? session\n    openPreferredExternalForNew(session: target)\n  }\n\n  func launchNewSession(\n    for session: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile,\n    initialPrompt: String? = nil,\n    warpTitle: String? = nil,\n    projectOverride: Project? = nil\n  ) {\n    let dir = workingDirectory(for: session)\n    viewModel.launchNewSessionWithProfile(\n      session: session,\n      using: source,\n      profile: profile,\n      workingDirectory: dir,\n      initialPrompt: initialPrompt,\n      warpTitle: warpTitle,\n      projectOverride: projectOverride\n    )\n  }\n\n  func launchResume(\n    for session: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    let target = source == session.source ? session : session.overridingSource(source)\n    let dir = workingDirectory(for: target)\n\n    guard viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile)\n    else { return }\n    if profile.isNone {\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n\n    if profile.usesWarpCommands {\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n      if viewModel.shouldCopyCommandsToClipboard {\n        if viewModel.preferences.commandCopyNotificationsEnabled {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\",\n              body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n      }\n      return\n    }\n\n    if profile.isTerminal {\n      if !viewModel.openInTerminal(session: target) {\n        _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile)\n        _ = viewModel.openAppleTerminal(at: dir)\n        if viewModel.shouldCopyCommandsToClipboard {\n          if viewModel.preferences.commandCopyNotificationsEnabled {\n            Task {\n              await SystemNotifier.shared.notify(\n                title: \"CodMate\",\n                body: \"Command copied. Paste it in the opened terminal.\")\n            }\n          }\n        }\n      }\n      return\n    }\n\n    if !profile.supportsCommandResolved {\n      _ = viewModel.copyResumeCommandsIfEnabled(session: target, destinationApp: profile)\n    }\n    let cmd = profile.supportsCommandResolved\n      ? viewModel.buildResumeCLIInvocationRespectingProject(session: target)\n      : nil\n    viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n    if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard,\n      viewModel.preferences.commandCopyNotificationsEnabled\n    {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\",\n          body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n  }\n\n  // moved to ContentView+Helpers.swift\n\n  private func toggleDetailMaximized() {\n    withAnimation(.easeInOut(duration: 0.18)) {\n      let shouldHide = columnVisibility != .detailOnly\n      columnVisibility = shouldHide ? .detailOnly : .all\n      isDetailMaximized = shouldHide\n    }\n  }\n\n  func toggleSidebarVisibility() {\n    // Toggle sidebar between shown (.all) and hidden (.doubleColumn). If maximized, restore.\n    withAnimation(.easeInOut(duration: 0.15)) {\n      switch columnVisibility {\n      case .detailOnly:\n        columnVisibility = .all; storeSidebarHidden = false\n      case .all:\n        columnVisibility = .doubleColumn; storeSidebarHidden = true\n      case .doubleColumn:\n        columnVisibility = .all; storeSidebarHidden = false\n      default:\n        columnVisibility = storeSidebarHidden ? .all : .doubleColumn\n        storeSidebarHidden.toggle()\n      }\n    }\n  }\n\n  func toggleListVisibility() {\n    // Revert to non-animated toggle to keep detail anchored and stable\n    isListHidden.toggle()\n    storeListHidden = isListHidden\n  }\n\n  func applyVisibilityFromStorage(animated: Bool) {\n    let action = {\n      // Apply list visibility\n      isListHidden = storeListHidden\n      // Apply sidebar visibility when not maximized\n      if columnVisibility != .detailOnly {\n        columnVisibility = storeSidebarHidden ? .doubleColumn : .all\n      }\n    }\n    if animated { withAnimation(.easeInOut(duration: 0.12)) { action() } } else { action() }\n  }\n\n  @ViewBuilder\n  func maximizeToggleButton() -> some View {\n    let isBothHidden = storeSidebarHidden && isListHidden\n    Button {\n      withAnimation(.easeInOut(duration: 0.15)) {\n        toggleSidebarVisibility()\n        toggleListVisibility()\n      }\n    } label: {\n      Image(\n        systemName: isBothHidden\n          ? \"arrow.up.right.and.arrow.down.left\"\n          : \"arrow.down.left.and.arrow.up.right\"\n      )\n      .imageScale(.medium)\n    }\n    .buttonStyle(.bordered)\n    .controlSize(.regular)\n    .frame(height: 28)\n    .accessibilityLabel(isBothHidden ? \"Restore lists\" : \"Maximize detail\")\n  }\n\n  func handleFolderSelection(\n    result: Result<[URL], Error>,\n    update: @escaping (URL) async -> Void\n  ) {\n    switch result {\n    case .success(let urls):\n      selectingSessionsRoot = false\n      guard let url = urls.first else { return }\n      Task { await update(url) }\n    case .failure(let error):\n      selectingSessionsRoot = false\n      alertState = AlertState(\n        title: \"Failed to choose directory\", message: error.localizedDescription)\n    }\n  }\n\n  // Removed: executable chooser handler\n\n  var placeholder: some View {\n    Group {\n      if #available(macOS 14.0, *) {\n        ContentUnavailableView(\n          \"Select a session\", systemImage: \"rectangle.and.text.magnifyingglass\",\n          description: Text(\"Pick a session from the middle list to view details.\")\n        )\n      } else {\n        UnavailableStateView(\n          \"Select a session\",\n          systemImage: \"rectangle.and.text.magnifyingglass\",\n          description: \"Pick a session from the middle list to view details.\",\n          titleColor: .primary\n        )\n      }\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n}\n\n// MARK: - Embedded PTY rekey helpers\nextension ContentView {\n  // canonicalizePath moved to ContentView+Helpers.swift\n\n  func reconcilePendingEmbeddedRekeys() {\n    guard !pendingEmbeddedRekeys.isEmpty else { return }\n    let all = viewModel.sections.flatMap(\\.sessions)\n    let now = Date()\n    var remaining: [PendingEmbeddedRekey] = []\n    for pending in pendingEmbeddedRekeys {\n      // Window to match nearby creations\n      let windowStart = pending.t0.addingTimeInterval(-2)\n      let windowEnd = pending.t0.addingTimeInterval(120)\n      let candidates = all.filter { s in\n        guard s.id != pending.anchorId else { return false }\n        let canon = canonicalizePath(s.cwd)\n        guard canon == pending.expectedCwd else { return false }\n        return s.startedAt >= windowStart && s.startedAt <= windowEnd\n      }\n      if let winner = candidates.min(by: {\n        abs($0.startedAt.timeIntervalSince(pending.t0))\n          < abs($1.startedAt.timeIntervalSince(pending.t0))\n      }) {\n          // DISABLED: rekey not needed for Ghostty\n        if runningSessionIDs.contains(pending.anchorId) {\n          runningSessionIDs.remove(pending.anchorId)\n          runningSessionIDs.insert(winner.id)\n        }\n        if selectedTerminalKey == pending.anchorId {\n          selectedTerminalKey = winner.id\n        }\n        if let savedTab = sessionDetailTabs.removeValue(forKey: pending.anchorId) {\n          sessionDetailTabs[winner.id] = savedTab\n        } else if selectedDetailTab == .terminal\n          && (pending.selectOnSuccess || selection.contains(pending.anchorId))\n        {\n          sessionDetailTabs[winner.id] = .terminal\n        }\n        if pending.selectOnSuccess || selection.contains(pending.anchorId) {\n          selection = [winner.id]\n        }\n        if let pid = pending.projectId {\n          Task {\n            await viewModel.assignSessions(to: pid, ids: [winner.id])\n          }\n        }\n      } else {\n        if now.timeIntervalSince(pending.t0) < 180 {\n          remaining.append(pending)\n        } else {\n          // Timeout: stop the anchor terminal to avoid lingering shells\n            GhosttySessionManager.shared.removeScrollView(for: pending.anchorId)\n          runningSessionIDs.remove(pending.anchorId)\n          embeddedInitialCommands.removeValue(forKey: pending.anchorId)\n          sessionDetailTabs.removeValue(forKey: pending.anchorId)\n          if selectedTerminalKey == pending.anchorId {\n            selectedTerminalKey = runningSessionIDs.first\n          }\n        }\n      }\n    }\n    pendingEmbeddedRekeys = remaining\n  }\n}\n\nstruct AlertState: Identifiable {\n  let id = UUID()\n  let title: String\n  let message: String\n}\n"
  },
  {
    "path": "views/Content/StatusBarOverlayView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct StatusBarOverlayView: View {\n  @ObservedObject var store: StatusBarLogStore\n  @ObservedObject var preferences: SessionPreferencesStore\n  let sidebarInset: CGFloat\n\n  @State private var dragStartHeight: CGFloat? = nil\n  @State private var draggedHeight: CGFloat? = nil\n  @State private var filterText: String = \"\"\n  @State private var filterLevel: StatusBarLogLevel? = nil  // nil = All\n  @State private var cachedFilteredEntries: [StatusBarLogEntry] = []\n  @State private var cacheKey: (filterText: String, filterLevel: StatusBarLogLevel?, entryCount: Int) = (\"\", nil, 0)\n  @State private var cachedCombinedText: AttributedString? = nil\n  @State private var cachedCombinedTextKey: Int = 0  // Use entry count + last entry ID hash as cache key\n  @State private var cachedEntryCount: Int = 0  // Track cached entry count for incremental updates\n  @State private var cachedFirstEntryId: UUID? = nil  // Track first entry ID for incremental update validation\n  @State private var cachedLastEntryId: UUID? = nil  // Track last entry ID for incremental update validation\n\n  private let maxVisibleLines: Int = 160\n  private let minExpandedHeight: CGFloat = 120\n  private let maxExpandedHeight: CGFloat = 520\n  private let maxMessageLength: Int = 5000  // Truncate messages longer than this\n  private let truncationMarker = \"… [truncated]\"\n\n  var body: some View {\n    if preferences.statusBarVisibility != .hidden {\n      content\n        .frame(maxHeight: totalHeight, alignment: .bottomLeading)\n        .animation(.none, value: sidebarInset)\n        .onAppear {\n          store.setAutoCollapseEnabled(preferences.statusBarVisibility == .auto)\n        }\n        .onChange(of: preferences.statusBarVisibility) { newValue in\n          store.setAutoCollapseEnabled(newValue == .auto)\n        }\n        .onChange(of: filterText) { _ in\n          invalidateCache()\n        }\n        .onChange(of: filterLevel) { _ in\n          invalidateCache()\n        }\n        .onChange(of: store.entries.count) { _ in\n          invalidateCache()\n        }\n    }\n  }\n\n  private var totalHeight: CGFloat {\n    if let draggedHeight = draggedHeight {\n      return store.isExpanded ? draggedHeight : store.collapsedHeight\n    }\n    return store.isExpanded ? store.expandedHeight : store.collapsedHeight\n  }\n\n  private var logListHeight: CGFloat {\n    let effectiveHeight = draggedHeight ?? store.expandedHeight\n    return max(0, effectiveHeight - store.collapsedHeight)\n  }\n\n  private var content: some View {\n    VStack(spacing: 0) {\n      // Top divider - separates status bar from content above\n      Divider()\n\n      if store.isExpanded {\n        // Title bar (serves as resize handle)\n        titleBar\n          .frame(height: store.collapsedHeight)\n          .background(Color(nsColor: .windowBackgroundColor))\n        // Divider between title bar and log content\n        Divider()\n        // Log content\n        logList\n          .frame(height: logListHeight)\n          .frame(maxWidth: .infinity, maxHeight: logListHeight)\n          .background(Color(nsColor: .textBackgroundColor))\n      } else {\n        // Collapsed state - just show the title bar\n        titleBar\n          .frame(height: store.collapsedHeight)\n          .background(Color(nsColor: .windowBackgroundColor))\n      }\n    }\n    .frame(maxWidth: .infinity, alignment: .leading)\n    .background(Color(nsColor: .windowBackgroundColor))\n    .onHover { hovering in\n      store.setInteracting(hovering)\n    }\n  }\n\n  private var titleBar: some View {\n    HStack(spacing: 8) {\n      statusIcon\n      if store.isExpanded {\n        // Filter menu and search field when expanded\n        filterMenu\n        searchField\n        Spacer(minLength: 8)\n      } else {\n        statusText\n        Spacer(minLength: 8)\n      }\n      // Toggle button on the right\n      Button {\n        withAnimation(.easeInOut(duration: 0.15)) {\n          store.isExpanded.toggle()\n          if store.isExpanded {\n            store.reveal(expanded: false)\n          }\n        }\n      } label: {\n        Image(systemName: \"rectangle.bottomthird.inset.filled\")\n          .font(.system(size: 13, weight: .semibold))\n          .frame(width: 18, height: 18)\n      }\n      .buttonStyle(.plain)\n      .padding(.horizontal, 4)\n      .contentShape(Rectangle())\n      .help(store.isExpanded ? \"Hide Debug Area\" : \"Show Debug Area\")\n    }\n    .font(.system(size: 11))\n    .foregroundStyle(.secondary)\n    .padding(.horizontal, 10)\n    .padding(.vertical, 6)\n    .contentShape(Rectangle())\n    .gesture(\n      store.isExpanded ? DragGesture(minimumDistance: 2)\n        .onChanged { value in\n          if dragStartHeight == nil {\n            dragStartHeight = store.expandedHeight\n          }\n          let startHeight = dragStartHeight ?? store.expandedHeight\n          let newHeight = startHeight - value.translation.height\n          let clamped = min(max(newHeight, minExpandedHeight), maxExpandedHeight)\n\n          store.setInteracting(true)\n          draggedHeight = clamped\n        }\n        .onEnded { _ in\n          if let finalHeight = draggedHeight {\n            store.setExpandedHeight(finalHeight)\n          }\n          dragStartHeight = nil\n          draggedHeight = nil\n          store.setInteracting(false)\n        } : nil\n    )\n  }\n  \n  // Custom NSTextView wrapper with custom context menu and full width\n  private struct LogTextView: NSViewRepresentable {\n    let text: AttributedString\n    let isEmpty: Bool\n    let onCopyAll: () -> Void\n    let onClear: () -> Void\n    let canCopy: Bool\n    let canClear: Bool\n    \n    func makeNSView(context: Context) -> NSScrollView {\n      let scrollView = NSScrollView()\n      scrollView.hasVerticalScroller = true\n      scrollView.hasHorizontalScroller = false\n      scrollView.borderType = .noBorder\n      scrollView.drawsBackground = false\n      scrollView.autohidesScrollers = true\n      scrollView.scrollerStyle = .overlay\n      \n      // Create text storage and layout manager\n      let textStorage = NSTextStorage()\n      let layoutManager = NSLayoutManager()\n      textStorage.addLayoutManager(layoutManager)\n      let textContainer = NSTextContainer()\n      textContainer.widthTracksTextView = true\n      textContainer.heightTracksTextView = false\n      // Set initial container size (will be updated after scrollView is configured)\n      textContainer.containerSize = NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude)\n      layoutManager.addTextContainer(textContainer)\n      \n      let textView = LogNSTextView(frame: .zero, textContainer: textContainer)\n      textView.coordinator = context.coordinator\n      textView.isEditable = false\n      textView.isSelectable = true\n      textView.isRichText = false  // Use plain text to avoid formatting issues\n      textView.drawsBackground = false\n      textView.textContainerInset = NSSize(width: 10, height: 6)\n      textView.font = NSFont.monospacedSystemFont(ofSize: 11, weight: .regular)\n      textView.textColor = .labelColor\n      textView.autoresizingMask = [.width]\n      textView.isVerticallyResizable = true\n      textView.isHorizontallyResizable = false\n      textView.allowsUndo = false  // Disable undo to improve performance\n      textView.minSize = NSSize(width: 0, height: 0)\n      textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)\n      \n      // Set initial text (use simple string conversion to avoid crashes)\n      let textString = String(text.characters)\n      let initialString = NSMutableAttributedString(string: textString)\n      initialString.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: initialString.length))\n      initialString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), range: NSRange(location: 0, length: initialString.length))\n      textStorage.setAttributedString(initialString)\n      \n      scrollView.documentView = textView\n      context.coordinator.textView = textView\n      context.coordinator.scrollView = scrollView\n      context.coordinator.textStorage = textStorage\n      context.coordinator.lastText = textString\n      \n      // Update container width after scrollView is set up\n      DispatchQueue.main.async {\n        let contentWidth = scrollView.contentSize.width\n        if contentWidth > 0 {\n          let padding: CGFloat = 20\n          let availableWidth = max(1, contentWidth - padding)\n          textContainer.containerSize = NSSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude)\n          layoutManager.ensureLayout(for: textContainer)\n        }\n        // Setup scroll observer after scrollView is fully configured\n        context.coordinator.setupScrollObserver()\n        // Initial check - assume at bottom initially\n        context.coordinator.isUserAtBottom = true\n      }\n      \n      return scrollView\n    }\n    \n    func updateNSView(_ nsView: NSScrollView, context: Context) {\n      // Safely check if view is still valid\n      guard nsView.window != nil else { return }\n      guard let textView = nsView.documentView as? LogNSTextView else { return }\n      \n      // Use coordinator's textStorage if available, otherwise fall back to textView's\n      guard let textStorage = context.coordinator.textStorage ?? textView.textStorage else {\n        return\n      }\n      \n      // Check if text actually changed to avoid unnecessary updates\n      let newTextString = String(text.characters)\n      guard context.coordinator.lastText != newTextString else {\n        // Text unchanged, just update menu if needed\n        textView.coordinator = context.coordinator\n        textView.customMenu = makeMenu(coordinator: context.coordinator)\n        return\n      }\n      \n      // Update text - use simple string conversion to avoid crashes\n      // Convert AttributedString to plain NSAttributedString with system colors\n      let simpleString = NSMutableAttributedString(string: newTextString)\n      simpleString.addAttribute(.foregroundColor, value: NSColor.labelColor, range: NSRange(location: 0, length: simpleString.length))\n      simpleString.addAttribute(.font, value: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), range: NSRange(location: 0, length: simpleString.length))\n      let nsAttributedString = simpleString\n      \n      // Safely update text storage (only update if actually changed)\n      textStorage.beginEditing()\n      textStorage.setAttributedString(nsAttributedString)\n      textStorage.endEditing()\n      \n      context.coordinator.lastText = newTextString\n      \n      // Update menu (only once per text change)\n      textView.coordinator = context.coordinator\n      textView.customMenu = makeMenu(coordinator: context.coordinator)\n      \n      // Update text view width (only once, after text update, with debouncing)\n      // Use a small delay to avoid layout loops\n      DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak nsView, weak textView] in\n        guard let scrollView = nsView, let tv = textView,\n              scrollView.window != nil, tv.window != nil,\n              !scrollView.isHidden else { return }\n        let contentWidth = scrollView.contentSize.width\n        guard contentWidth > 0 else { return }\n        let padding: CGFloat = 20\n        let availableWidth = max(1, contentWidth - padding)\n        if let container = tv.textContainer, let layoutMgr = tv.layoutManager {\n          // Only update if width actually changed to avoid loops\n          let currentWidth = container.containerSize.width\n          if abs(currentWidth - availableWidth) > 1.0 {\n            container.widthTracksTextView = true\n            container.containerSize = NSSize(width: availableWidth, height: CGFloat.greatestFiniteMagnitude)\n            // Force layout update to ensure scrolling works\n            layoutMgr.ensureLayout(for: container)\n            tv.needsDisplay = true\n          }\n        }\n      }\n      \n      // Auto-scroll to bottom if needed (only if user is at bottom, view is visible and not empty)\n      if !isEmpty && !nsView.isHidden && context.coordinator.isUserAtBottom {\n        let coordinator = context.coordinator\n        DispatchQueue.main.async { [weak nsView, weak coordinator] in\n          guard let scrollView = nsView,\n                let tv = scrollView.documentView as? LogNSTextView,\n                tv.window != nil,\n                let coord = coordinator,\n                coord.isUserAtBottom else { return }\n          tv.scrollToEndOfDocument(nil)\n          // Update scroll position after scrolling\n          coord.checkScrollPosition()\n        }\n      }\n    }\n    \n    \n    private func makeMenu(coordinator: Coordinator) -> NSMenu {\n      let menu = NSMenu()\n      \n      // Copy All Logs\n      let copyItem = NSMenuItem(\n        title: \"Copy All Logs\",\n        action: #selector(Coordinator.copyAll(_:)),\n        keyEquivalent: \"\"\n      )\n      copyItem.target = coordinator\n      copyItem.isEnabled = canCopy\n      copyItem.image = NSImage(systemSymbolName: \"doc.on.doc\", accessibilityDescription: nil)\n      menu.addItem(copyItem)\n      \n      menu.addItem(.separator())\n      \n      // Clear Logs (destructive action - use red color)\n      let clearItem = NSMenuItem(\n        title: \"Clear Logs\",\n        action: #selector(Coordinator.clear(_:)),\n        keyEquivalent: \"\"\n      )\n      clearItem.target = coordinator\n      clearItem.isEnabled = canClear\n      clearItem.image = NSImage(systemSymbolName: \"trash\", accessibilityDescription: nil)\n      let attributedTitle = NSMutableAttributedString(string: \"Clear Logs\")\n      attributedTitle.addAttribute(.foregroundColor, value: NSColor.systemRed, range: NSRange(location: 0, length: attributedTitle.length))\n      clearItem.attributedTitle = attributedTitle\n      menu.addItem(clearItem)\n      \n      return menu\n    }\n    \n    func makeCoordinator() -> Coordinator {\n      Coordinator(onCopyAll: onCopyAll, onClear: onClear)\n    }\n    \n    class Coordinator: NSObject {\n      let onCopyAll: () -> Void\n      let onClear: () -> Void\n      weak var textView: LogNSTextView?\n      weak var scrollView: NSScrollView?\n      var textStorage: NSTextStorage?\n      var lastText: String = \"\"\n      var isUserAtBottom: Bool = true  // Track if user is at bottom (auto-scroll only when true)\n      var scrollObserver: NSObjectProtocol?\n      \n      init(onCopyAll: @escaping () -> Void, onClear: @escaping () -> Void) {\n        self.onCopyAll = onCopyAll\n        self.onClear = onClear\n        super.init()\n      }\n      \n      deinit {\n        if let observer = scrollObserver {\n          NotificationCenter.default.removeObserver(observer)\n        }\n      }\n      \n      func checkScrollPosition() {\n        guard let scrollView = scrollView,\n              let textView = textView else { return }\n        \n        let clipView = scrollView.contentView\n        let offsetY = clipView.bounds.origin.y\n        let viewportHeight = clipView.bounds.height\n        let contentHeight = textView.bounds.height\n        let maxOffset = max(0, contentHeight - viewportHeight)\n        \n        // Check if user is at bottom (within 10pt threshold)\n        let isAtBottom = abs(offsetY - maxOffset) < 10\n        isUserAtBottom = isAtBottom\n      }\n      \n      func setupScrollObserver() {\n        guard scrollObserver == nil,\n              let scrollView = scrollView else { return }\n        \n        // Enable bounds change notifications\n        scrollView.contentView.postsBoundsChangedNotifications = true\n        \n        // Observe scroll events to track user scroll position\n        scrollObserver = NotificationCenter.default.addObserver(\n          forName: NSView.boundsDidChangeNotification,\n          object: scrollView.contentView,\n          queue: .main\n        ) { [weak self] _ in\n          self?.checkScrollPosition()\n        }\n        \n        // Also observe live scroll events for more accurate tracking\n        NotificationCenter.default.addObserver(\n          forName: NSScrollView.willStartLiveScrollNotification,\n          object: scrollView,\n          queue: .main\n        ) { [weak self] _ in\n          self?.checkScrollPosition()\n        }\n        \n        NotificationCenter.default.addObserver(\n          forName: NSScrollView.didEndLiveScrollNotification,\n          object: scrollView,\n          queue: .main\n        ) { [weak self] _ in\n          self?.checkScrollPosition()\n        }\n      }\n      \n      @objc func copyAll(_ sender: Any?) {\n        onCopyAll()\n      }\n      \n      @objc func clear(_ sender: Any?) {\n        onClear()\n      }\n    }\n  }\n  \n  // Custom NSTextView that overrides menu to show custom context menu\n  private class LogNSTextView: NSTextView {\n    var coordinator: LogTextView.Coordinator?\n    var customMenu: NSMenu?\n    \n    override func menu(for event: NSEvent) -> NSMenu? {\n      // Return custom menu on right-click\n      if event.type == .rightMouseDown || event.type == .rightMouseUp {\n        // Ensure menu items have valid targets\n        if let menu = customMenu {\n          for item in menu.items {\n            if item.target == nil {\n              item.target = coordinator\n            }\n          }\n        }\n        return customMenu\n      }\n      return super.menu(for: event)\n    }\n    \n    override func becomeFirstResponder() -> Bool {\n      // Allow text selection but prevent editing\n      return super.becomeFirstResponder()\n    }\n  }\n  \n  private func copyAllLogsToClipboard() {\n    let displayEntries = Array(filteredEntries.suffix(maxVisibleLines))\n    let text = displayEntries.map { entry in\n      let timestamp = timeString(entry.timestamp)\n      let source = entry.source.map { \"\\($0): \" } ?? \"\"\n      let message = truncateIfNeeded(entry.message)\n      return \"\\(timestamp) • \\(source)\\(message)\"\n    }.joined(separator: \"\\n\")\n    \n    let pasteboard = NSPasteboard.general\n    pasteboard.clearContents()\n    pasteboard.setString(text, forType: .string)\n  }\n\n\n  private var filterMenu: some View {\n    Menu {\n      Button {\n        filterLevel = nil\n      } label: {\n        HStack {\n          Text(\"All\")\n          if filterLevel == nil {\n            Image(systemName: \"checkmark\")\n          }\n        }\n      }\n      Divider()\n      ForEach(StatusBarLogLevel.allCases) { level in\n        Button {\n          filterLevel = level\n        } label: {\n          HStack {\n            Circle()\n              .fill(levelColor(level))\n              .frame(width: 6, height: 6)\n            Text(level.rawValue.capitalized)\n            if filterLevel == level {\n              Image(systemName: \"checkmark\")\n            }\n          }\n        }\n      }\n    } label: {\n      HStack(spacing: 4) {\n        Image(systemName: \"line.3.horizontal.decrease.circle\")\n          .font(.system(size: 11))\n        if let level = filterLevel {\n          Circle()\n            .fill(levelColor(level))\n            .frame(width: 6, height: 6)\n        }\n      }\n      .padding(.horizontal, 6)\n      .padding(.vertical, 3)\n      .background(\n        RoundedRectangle(cornerRadius: 4)\n          .fill(Color(nsColor: .controlBackgroundColor))\n      )\n      .overlay(\n        RoundedRectangle(cornerRadius: 4)\n          .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)\n      )\n    }\n    .menuStyle(.borderlessButton)\n    .frame(height: 20)\n    .help(\"Filter by level\")\n  }\n\n  private var searchField: some View {\n    HStack(spacing: 4) {\n      TextField(\"Filter messages\", text: $filterText)\n        .textFieldStyle(.plain)\n        .font(.system(size: 11))\n        .frame(minWidth: 180, maxWidth: 240)\n      if !filterText.isEmpty {\n        Button {\n          filterText = \"\"\n        } label: {\n          Image(systemName: \"xmark.circle.fill\")\n            .font(.system(size: 10))\n            .foregroundStyle(.secondary)\n        }\n        .buttonStyle(.plain)\n      }\n    }\n    .padding(.horizontal, 6)\n    .padding(.vertical, 3)\n    .background(\n      RoundedRectangle(cornerRadius: 4)\n        .fill(Color(nsColor: .textBackgroundColor))\n    )\n    .overlay(\n      RoundedRectangle(cornerRadius: 4)\n        .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)\n    )\n  }\n\n  private var filteredEntries: [StatusBarLogEntry] {\n    let currentKey = (filterText, filterLevel, store.entries.count)\n    \n    // Use cache if filter criteria and entry count haven't changed\n    if cacheKey == currentKey && !cachedFilteredEntries.isEmpty {\n      return cachedFilteredEntries\n    }\n    \n    // Recompute filtered entries\n    let filtered = store.entries.filter { entry in\n      // Filter by level\n      if let filterLevel = filterLevel, entry.level != filterLevel {\n        return false\n      }\n      // Filter by text\n      if filterText.isEmpty { return true }\n      let searchLower = filterText.lowercased()\n      if entry.message.lowercased().contains(searchLower) { return true }\n      if let source = entry.source, source.lowercased().contains(searchLower) { return true }\n      return false\n    }\n    \n    // Update cache\n    cacheKey = currentKey\n    cachedFilteredEntries = filtered\n    return filtered\n  }\n\n  private var statusIcon: some View {\n    let level = store.entries.last?.level ?? .info\n    let systemName: String\n    switch level {\n    case .info:\n      systemName = store.activeTaskCount > 0 ? \"clock.badge.checkmark\" : \"info.circle\"\n    case .success:\n      systemName = \"checkmark.circle\"\n    case .warning:\n      systemName = \"exclamationmark.triangle\"\n    case .error:\n      systemName = \"xmark.octagon\"\n    }\n    return Image(systemName: systemName)\n      .foregroundStyle(levelColor(level))\n  }\n\n  private var statusText: some View {\n    let entry = store.entries.last\n    let text = entry?.message ?? \"No recent activity\"\n    return HStack(spacing: 6) {\n      if let entry {\n        Text(timeString(entry.timestamp))\n          .foregroundStyle(.secondary)\n      }\n      Text(text)\n        .foregroundStyle(levelColor(entry?.level ?? .info))\n        .lineLimit(1)\n        .truncationMode(.middle)\n    }\n  }\n\n  private var logList: some View {\n    let displayEntries = Array(filteredEntries.suffix(maxVisibleLines))\n    \n    // Compute lightweight cache key: entry count + hash of last entry ID (if exists)\n    // Only use last entry ID hash to keep cache key lightweight (memory optimization)\n    let cacheKeyValue = displayEntries.count * 1000 + (displayEntries.last?.id.hashValue ?? 0)\n    \n    // Use cache if key matches, otherwise build (with incremental update if possible)\n    let combinedText: AttributedString\n    if cacheKeyValue == cachedCombinedTextKey, let cached = cachedCombinedText {\n      combinedText = cached\n    } else {\n      // Try incremental update if we have cached text and only new entries were added\n      if let cached = cachedCombinedText,\n         cachedEntryCount > 0,\n         displayEntries.count > cachedEntryCount,\n         let cachedFirstId = cachedFirstEntryId,\n         let cachedLastId = cachedLastEntryId {\n        // Check if previous entries match by comparing first and last cached entry IDs\n        // This is a lightweight check (memory optimization) - only store 2 UUIDs instead of full list\n        let currentFirstId = displayEntries.first?.id\n        let currentLastCachedId = displayEntries.count > cachedEntryCount ? \n          displayEntries[cachedEntryCount - 1].id : displayEntries.last?.id\n        \n        // If first and last cached entry IDs match, we can safely do incremental update\n        if currentFirstId == cachedFirstId && currentLastCachedId == cachedLastId {\n          // Incremental update: append only new entries (performance optimization)\n          var updated = cached\n          let newEntries = Array(displayEntries.suffix(displayEntries.count - cachedEntryCount))\n          if !newEntries.isEmpty {\n            updated.append(AttributedString(\"\\n\"))\n            for (index, entry) in newEntries.enumerated() {\n              if index > 0 {\n                updated.append(AttributedString(\"\\n\"))\n              }\n              // For very long messages, use optimized building\n              updated.append(buildSelectableLogLine(entry))\n            }\n          }\n          combinedText = updated\n        } else {\n          // Full rebuild needed (entries changed, not just added)\n          combinedText = buildCombinedLogTextOptimized(entries: displayEntries)\n        }\n      } else {\n        // Full rebuild (first time or cache invalidated)\n        combinedText = buildCombinedLogTextOptimized(entries: displayEntries)\n      }\n      \n      // Update cache with lightweight keys (memory optimization)\n      cachedCombinedText = combinedText\n      cachedCombinedTextKey = cacheKeyValue\n      cachedEntryCount = displayEntries.count\n      cachedFirstEntryId = displayEntries.first?.id\n      cachedLastEntryId = displayEntries.last?.id\n    }\n    \n    return LogTextView(\n      text: displayEntries.isEmpty ? AttributedString(\"No log entries\") : combinedText,\n      isEmpty: displayEntries.isEmpty,\n      onCopyAll: { copyAllLogsToClipboard() },\n      onClear: {\n        store.clear()\n        invalidateCache()\n      },\n      canCopy: !filteredEntries.isEmpty,\n      canClear: !store.entries.isEmpty\n    )\n  }\n  \n  /// Invalidate all caches\n  private func invalidateCache() {\n    cacheKey = (\"\", nil, 0)\n    cachedFilteredEntries = []\n    cachedCombinedText = nil\n    cachedCombinedTextKey = 0\n    cachedEntryCount = 0\n    cachedFirstEntryId = nil\n    cachedLastEntryId = nil\n  }\n\n  /// Build a combined AttributedString from multiple log entries, separated by newlines\n  /// Optimized version that handles large lists efficiently\n  private func buildCombinedLogTextOptimized(entries: [StatusBarLogEntry]) -> AttributedString {\n    guard !entries.isEmpty else { return AttributedString(\"\") }\n    \n    // For very long lists, build in chunks to avoid blocking UI\n    if entries.count > 100 {\n      var result = AttributedString(\"\")\n      // Build first entry immediately\n      result.append(buildSelectableLogLine(entries[0]))\n      \n      // Build remaining entries\n      for index in 1..<entries.count {\n        result.append(AttributedString(\"\\n\"))\n        result.append(buildSelectableLogLine(entries[index]))\n      }\n      return result\n    } else {\n      // For smaller lists, build normally\n      var result = AttributedString(\"\")\n      for (index, entry) in entries.enumerated() {\n        if index > 0 {\n          result.append(AttributedString(\"\\n\"))\n        }\n        result.append(buildSelectableLogLine(entry))\n      }\n      return result\n    }\n  }\n  \n  private func buildSelectableLogLine(_ entry: StatusBarLogEntry) -> AttributedString {\n    var result = AttributedString(\"\")\n    \n    // Timestamp - use system color that adapts to theme\n    var timestamp = AttributedString(timeString(entry.timestamp))\n    timestamp.font = .system(size: 10, design: .monospaced)\n    // Use a placeholder color that will be replaced by theme-aware color in NSTextView\n    timestamp.foregroundColor = Color.primary.opacity(0.4)\n    result.append(timestamp)\n    result.append(AttributedString(\" \"))\n    \n    // Bullet point (using Unicode character instead of Circle view)\n    var bullet = AttributedString(\"•\")\n    bullet.foregroundColor = levelColor(entry.level)\n    result.append(bullet)\n    result.append(AttributedString(\" \"))\n    \n    // Source (if present)\n    if let source = entry.source, !source.isEmpty {\n      var sourceText = AttributedString(source)\n      sourceText.font = .system(size: 10, weight: .medium, design: .monospaced)\n      // Use a placeholder color that will be replaced by theme-aware color\n      sourceText.foregroundColor = Color.secondary\n      result.append(sourceText)\n      result.append(AttributedString(\": \"))\n    }\n    \n    // Message with truncation for very long messages\n    let message = truncateIfNeeded(entry.message)\n    var messageAttr = highlightedMessage(message)\n    // Apply font and color to the entire message, preserving any existing attributes (like highlight background)\n    messageAttr.font = .system(size: 11, design: .monospaced)\n    messageAttr.foregroundColor = levelColor(entry.level)\n    \n    result.append(messageAttr)\n    \n    return result\n  }\n  \n  /// Truncate message if it exceeds maxMessageLength, keeping head and tail\n  private func truncateIfNeeded(_ message: String) -> String {\n    guard message.count > maxMessageLength else { return message }\n    \n    // Keep head (first 60%) and tail (last 30%), with truncation marker in between\n    let headLength = Int(Double(maxMessageLength) * 0.6)\n    let tailLength = Int(Double(maxMessageLength) * 0.3)\n    \n    let head = String(message.prefix(headLength))\n    let tail = String(message.suffix(tailLength))\n    \n    return \"\\(head)\\n\\(truncationMarker)\\n\\(tail)\"\n  }\n\n  private func highlightedMessage(_ message: String) -> AttributedString {\n    guard !filterText.isEmpty else {\n      return AttributedString(message)\n    }\n    \n    var result = AttributedString(message)\n    let searchLower = filterText.lowercased()\n    let messageLower = message.lowercased()\n    \n    var searchStart = messageLower.startIndex\n    var matchCount = 0\n    let maxMatches = 100  // Limit matches to avoid performance issues\n    \n    while searchStart < messageLower.endIndex, matchCount < maxMatches {\n      guard let range = messageLower[searchStart...].range(of: searchLower) else {\n        break\n      }\n      \n      // Convert String.Index range to AttributedString.Index range\n      let lowerBound = AttributedString.Index(range.lowerBound, within: result) ?? result.startIndex\n      let upperBound = AttributedString.Index(range.upperBound, within: result) ?? result.endIndex\n      let attrRange = lowerBound..<upperBound\n      \n      result[attrRange].backgroundColor = .yellow.opacity(0.3)\n      \n      searchStart = range.upperBound\n      matchCount += 1\n    }\n    \n    return result\n  }\n\n  private func levelColor(_ level: StatusBarLogLevel) -> Color {\n    switch level {\n    case .info:\n      return .secondary\n    case .success:\n      return Color.green\n    case .warning:\n      return Color.orange\n    case .error:\n      return Color.red\n    }\n  }\n\n  private func timeString(_ date: Date) -> String {\n    Self.timeFormatter.string(from: date)\n  }\n\n  private static let timeFormatter: DateFormatter = {\n    let formatter = DateFormatter()\n    formatter.setLocalizedDateFormatFromTemplate(\"HH:mm:ss\")\n    return formatter\n  }()\n}\n"
  },
  {
    "path": "views/Controls/CollapseExpandButtonGroup.swift",
    "content": "import SwiftUI\n\n/// Reusable pair of collapse/expand buttons used across Tasks and Review surfaces.\nstruct CollapseExpandButtonGroup: View {\n  var collapseHelp: String = \"Collapse All\"\n  var expandHelp: String = \"Expand All\"\n  let onCollapse: () -> Void\n  let onExpand: () -> Void\n\n  var body: some View {\n    HStack(spacing: 0) {\n      button(\n        systemImage: \"arrow.up.right.and.arrow.down.left\",\n        help: collapseHelp,\n        action: onCollapse\n      )\n      button(\n        systemImage: \"arrow.down.left.and.arrow.up.right\",\n        help: expandHelp,\n        action: onExpand\n      )\n    }\n  }\n\n  private func button(systemImage: String, help: String, action: @escaping () -> Void) -> some View {\n    Button(action: action) {\n      Image(systemName: systemImage)\n        .font(.system(size: 12))\n        .foregroundStyle(.secondary)\n    }\n    .buttonStyle(.plain)\n    .frame(width: 28, height: 28)\n    .background(\n      RoundedRectangle(cornerRadius: 4)\n        .fill(Color.clear)\n    )\n    .contentShape(Rectangle())\n    .help(help)\n  }\n}\n"
  },
  {
    "path": "views/Controls/FontPickerButton.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct FontPickerButton: View {\n    @Binding var fontName: String\n    @Binding var fontSize: Double\n\n    @StateObject private var controller = FontPanelController()\n\n    var body: some View {\n        HStack(spacing: 6) {\n            Spacer(minLength: 0)\n            Text(displayLabel)\n                .font(.system(size: 13))\n                .lineLimit(1)\n                .truncationMode(.tail)\n            Button(action: openFontPanel) {\n                Image(systemName: \"textformat.size\")\n                    .font(.system(size: 13, weight: .semibold))\n            }\n            .buttonStyle(.borderless)\n            .help(\"Choose terminal font and size\")\n        }\n    }\n\n    private func openFontPanel() {\n        controller.present(currentFont: resolvedFont) { newFont in\n            fontName = newFont.fontName\n            fontSize = Double(newFont.pointSize)\n        }\n    }\n\n    private var resolvedFont: NSFont {\n        TerminalFontResolver.resolvedFont(name: fontName, size: CGFloat(fontSize))\n    }\n\n    private var displayLabel: String {\n        let name = resolvedFont.displayName ?? resolvedFont.fontName\n        return String(format: \"%@ – %.1f pt\", name, fontSize)\n    }\n}\n\nprivate final class FontPanelController: NSObject, ObservableObject {\n    private var onChange: ((NSFont) -> Void)?\n    private var currentFont: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)\n\n    func present(currentFont: NSFont, onChange: @escaping (NSFont) -> Void) {\n        self.currentFont = currentFont\n        self.onChange = onChange\n        let manager = NSFontManager.shared\n        manager.target = self\n        manager.action = #selector(changeFont(_:))\n        manager.setSelectedFont(currentFont, isMultiple: false)\n        manager.orderFrontFontPanel(self)\n        manager.fontPanel(true)?.makeKeyAndOrderFront(self)\n    }\n\n    @objc private func changeFont(_ sender: NSFontManager) {\n        let converted = sender.convert(currentFont)\n        currentFont = converted\n        onChange?(converted)\n    }\n}\n"
  },
  {
    "path": "views/Controls/RainbowSpinnerView.swift",
    "content": "import SwiftUI\nimport AppKit\n\n/// CoreAnimation-based rainbow spinner (replaces SwiftUI repeatForever + drawingGroup)\n/// Uses CAGradientLayer with conic gradient and CABasicAnimation for rotation\n/// Pauses when window is not key or app is not active to reduce GPU usage\nstruct RainbowSpinnerView: NSViewRepresentable {\n    var spins: Bool = true\n    var size: CGFloat = 18\n    \n    func makeNSView(context: Context) -> NSView {\n        let containerView = ContainerView(size: size)\n        context.coordinator.containerView = containerView\n        return containerView\n    }\n    \n    func updateNSView(_ nsView: NSView, context: Context) {\n        guard let containerView = nsView as? ContainerView else { return }\n        containerView.setSpinning(spins)\n        \n        // Update size if changed\n        if containerView.frame.size.width != size || containerView.frame.size.height != size {\n            containerView.frame = NSRect(x: 0, y: 0, width: size, height: size)\n            containerView.needsLayout = true\n        }\n    }\n    \n    func makeCoordinator() -> Coordinator {\n        Coordinator()\n    }\n    \n    class Coordinator {\n        var containerView: ContainerView?\n    }\n    \n    /// Container view that manages the CoreAnimation spinner\n    class ContainerView: NSView {\n        private var gradientLayer: CAGradientLayer?\n        private var rotationAnimation: CABasicAnimation?\n        private var isSpinning: Bool = false\n        private let size: CGFloat\n        \n        init(size: CGFloat) {\n            self.size = size\n            super.init(frame: NSRect(x: 0, y: 0, width: size, height: size))\n            // Delay gradient setup until layout() when bounds are properly set\n            wantsLayer = true\n            layer = CALayer()\n            layer?.backgroundColor = NSColor.clear.cgColor\n            observeAppState()\n        }\n        \n        required init?(coder: NSCoder) {\n            fatalError(\"init(coder:) has not been implemented\")\n        }\n        \n        override func viewDidMoveToWindow() {\n            super.viewDidMoveToWindow()\n            observeWindowState()\n            updateAnimationState()\n        }\n        \n        override func viewDidMoveToSuperview() {\n            super.viewDidMoveToSuperview()\n            updateAnimationState()\n        }\n        \n        override func layout() {\n            super.layout()\n            \n            // Setup gradient layer on first layout when bounds are properly set\n            if gradientLayer == nil && bounds.width > 0 && bounds.height > 0 {\n                setupLayers()\n            }\n            \n            // Update gradient frame if it exists\n            if let gradientLayer = gradientLayer {\n                gradientLayer.frame = bounds\n                // Update center cap and separators when bounds change\n                updateGradientSublayers()\n            }\n        }\n        \n        private func updateGradientSublayers() {\n            guard let gradientLayer = gradientLayer else { return }\n            \n            // Update center cap frame\n            if let centerCap = gradientLayer.sublayers?.first(where: { $0.name == \"centerCap\" }) {\n                centerCap.frame = bounds.insetBy(dx: bounds.width * 0.35, dy: bounds.height * 0.35)\n                centerCap.cornerRadius = centerCap.frame.width / 2\n            }\n            \n            // Update separator positions\n            if let separators = gradientLayer.sublayers?.filter({ $0.name?.hasPrefix(\"separator\") == true }) {\n                for (index, separator) in separators.enumerated() {\n                    let separatorWidth: CGFloat = 1.2\n                    let separatorHeight: CGFloat = bounds.height * 0.15\n                    separator.frame = CGRect(\n                        x: (bounds.width - separatorWidth) / 2,\n                        y: 0,\n                        width: separatorWidth,\n                        height: separatorHeight\n                    )\n                    separator.position = CGPoint(x: bounds.midX, y: bounds.midY)\n                    separator.transform = CATransform3DMakeRotation(CGFloat(index) * .pi / 3, 0, 0, 1)\n                }\n            }\n        }\n        \n        func setSpinning(_ spinning: Bool) {\n            guard isSpinning != spinning else { return }\n            isSpinning = spinning\n            updateAnimationState()\n        }\n        \n        private func setupLayers() {\n            guard bounds.width > 0 && bounds.height > 0 else { return }\n            guard gradientLayer == nil else { return } // Already setup\n            \n            // Create conic gradient layer (rainbow colors)\n            let gradient = CAGradientLayer()\n            gradient.type = .conic\n            gradient.startPoint = CGPoint(x: 0.5, y: 0.5)\n            gradient.endPoint = CGPoint(x: 0.5, y: 0.0)\n            \n            // Rainbow colors: red, orange, yellow, green, blue, purple, red\n            gradient.colors = [\n                NSColor.red.cgColor,\n                NSColor.orange.cgColor,\n                NSColor.yellow.cgColor,\n                NSColor.green.cgColor,\n                NSColor.blue.cgColor,\n                NSColor.purple.cgColor,\n                NSColor.red.cgColor\n            ]\n            gradient.locations = [0.0, 0.166, 0.333, 0.5, 0.666, 0.833, 1.0]\n            gradient.frame = bounds\n            gradient.cornerRadius = bounds.width / 2\n            \n            // White center cap\n            let centerCap = CALayer()\n            centerCap.name = \"centerCap\"\n            centerCap.backgroundColor = NSColor.white.withAlphaComponent(0.92).cgColor\n            centerCap.frame = bounds.insetBy(dx: bounds.width * 0.35, dy: bounds.height * 0.35)\n            centerCap.cornerRadius = centerCap.frame.width / 2\n            \n            // Thin white separators\n            for i in 0..<6 {\n                let separator = CALayer()\n                separator.name = \"separator\\(i)\"\n                separator.backgroundColor = NSColor.white.withAlphaComponent(0.85).cgColor\n                let separatorWidth: CGFloat = 1.2\n                let separatorHeight: CGFloat = bounds.height * 0.15\n                separator.frame = CGRect(\n                    x: (bounds.width - separatorWidth) / 2,\n                    y: 0,\n                    width: separatorWidth,\n                    height: separatorHeight\n                )\n                separator.anchorPoint = CGPoint(x: 0.5, y: 1.0)\n                separator.position = CGPoint(x: bounds.midX, y: bounds.midY)\n                separator.transform = CATransform3DMakeRotation(CGFloat(i) * .pi / 3, 0, 0, 1)\n                gradient.addSublayer(separator)\n            }\n            \n            gradient.addSublayer(centerCap)\n            layer?.addSublayer(gradient)\n            gradientLayer = gradient\n        }\n        \n        private var windowKeyObserver: NSObjectProtocol?\n        private var windowResignObserver: NSObjectProtocol?\n        \n        private func observeAppState() {\n            // App activation state\n            NotificationCenter.default.addObserver(\n                self,\n                selector: #selector(applicationDidBecomeActive),\n                name: NSApplication.didBecomeActiveNotification,\n                object: nil\n            )\n            NotificationCenter.default.addObserver(\n                self,\n                selector: #selector(applicationDidResignActive),\n                name: NSApplication.didResignActiveNotification,\n                object: nil\n            )\n        }\n        \n        @objc private func applicationDidBecomeActive() {\n            updateAnimationState()\n        }\n        \n        @objc private func applicationDidResignActive() {\n            updateAnimationState()\n        }\n        \n        private func observeWindowState() {\n            guard let window = window else {\n                // Clean up observers if window is nil\n                windowKeyObserver = nil\n                windowResignObserver = nil\n                return\n            }\n            \n            // Window key state changes\n            windowKeyObserver = NotificationCenter.default.addObserver(\n                forName: NSWindow.didBecomeKeyNotification,\n                object: window,\n                queue: .main\n            ) { [weak self] _ in\n                self?.updateAnimationState()\n            }\n            \n            windowResignObserver = NotificationCenter.default.addObserver(\n                forName: NSWindow.didResignKeyNotification,\n                object: window,\n                queue: .main\n            ) { [weak self] _ in\n                self?.updateAnimationState()\n            }\n        }\n        \n        private func updateAnimationState() {\n            guard let gradientLayer = gradientLayer else { return }\n            \n            let shouldAnimate = isSpinning && isViewVisible()\n            \n            if shouldAnimate {\n                if rotationAnimation == nil {\n                    let animation = CABasicAnimation(keyPath: \"transform.rotation\")\n                    animation.fromValue = 0\n                    animation.toValue = Double.pi * 2\n                    animation.duration = 1.0\n                    animation.repeatCount = .greatestFiniteMagnitude\n                    animation.isRemovedOnCompletion = false\n                    gradientLayer.add(animation, forKey: \"rotation\")\n                    rotationAnimation = animation\n                }\n            } else {\n                if rotationAnimation != nil {\n                    gradientLayer.removeAnimation(forKey: \"rotation\")\n                    rotationAnimation = nil\n                }\n            }\n        }\n        \n        /// Check if view is visible and should animate\n        /// Returns true only when:\n        /// - View is in window hierarchy\n        /// - Window is visible\n        /// - App is active\n        private func isViewVisible() -> Bool {\n            guard let window = window else { return false }\n            guard window.isVisible else { return false }\n            guard NSApp.isActive else { return false }\n            // Check if view is in the window's view hierarchy\n            // isDescendant(of:) checks if self is a descendant of the parameter\n            // So we check if we are a descendant of the window's content view\n            if let contentView = window.contentView {\n                return self.isDescendant(of: contentView)\n            }\n            // Fallback: check if we have a superview (less precise but works)\n            return superview != nil\n        }\n        \n        deinit {\n            NotificationCenter.default.removeObserver(self)\n            if let keyObserver = windowKeyObserver {\n                NotificationCenter.default.removeObserver(keyObserver)\n            }\n            if let resignObserver = windowResignObserver {\n                NotificationCenter.default.removeObserver(resignObserver)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/Controls/TableSpacingRemover.swift",
    "content": "import SwiftUI\nimport AppKit\n\n#if os(macOS)\n\n/// Introspects the underlying NSTableView used by SwiftUI `Table`\n/// and forces `intercellSpacing` to zero so vertically drawn\n/// graph lanes can visually connect between rows.\nstruct TableSpacingRemover: NSViewRepresentable {\n  let rowHeight: CGFloat?\n\n  final class Coordinator {\n    var applied: Bool = false\n    weak var tableView: NSTableView?\n  }\n\n  init(rowHeight: CGFloat? = nil) {\n    self.rowHeight = rowHeight\n  }\n\n  func makeCoordinator() -> Coordinator {\n    Coordinator()\n  }\n\n  func makeNSView(context: Context) -> NSView {\n    let view = NSView()\n    DispatchQueue.main.async {\n      Self.applySpacingFix(\n        from: view,\n        rowHeight: rowHeight,\n        coordinator: context.coordinator\n      )\n    }\n    return view\n  }\n\n  func updateNSView(_ nsView: NSView, context: Context) {\n    // Only attempt once per coordinator / table instance to avoid\n    // repeatedly walking the NSView hierarchy during scroll.\n    guard !context.coordinator.applied else { return }\n    DispatchQueue.main.async {\n      Self.applySpacingFix(\n        from: nsView,\n        rowHeight: rowHeight,\n        coordinator: context.coordinator\n      )\n    }\n  }\n\n  private static func applySpacingFix(\n    from view: NSView,\n    rowHeight: CGFloat?,\n    coordinator: Coordinator\n  ) {\n    if coordinator.applied,\n      let tableView = coordinator.tableView,\n      tableView.window != nil\n    {\n      return\n    }\n\n    guard let tableView = findTableView(from: view) else { return }\n    coordinator.tableView = tableView\n\n    // Force zero vertical intercell spacing to remove the visible gap\n    // between rows for continuous graph lanes.\n    let spacing = tableView.intercellSpacing\n    if spacing.height != 0 {\n      tableView.intercellSpacing = NSSize(width: spacing.width, height: 0)\n    }\n\n    // Optionally pin row height and disable automatic height so the\n    // SwiftUI graph cell and the NSTableRowView share the same geometry.\n    if let h = rowHeight {\n      if tableView.rowHeight != h {\n        tableView.rowHeight = h\n      }\n      if tableView.usesAutomaticRowHeights {\n        tableView.usesAutomaticRowHeights = false\n      }\n    }\n\n    coordinator.applied = true\n  }\n\n  /// Attempts to locate the NSTableView backing a SwiftUI `Table`\n  /// near the given view by walking up to a root and then scanning\n  /// descendants. This is more robust than relying on\n  /// `enclosingScrollView` alone, since SwiftUI often arranges the\n  /// hosting views as siblings.\n  private static func findTableView(from view: NSView) -> NSTableView? {\n    // First, try the straightforward enclosure path.\n    if let scrollView = view.enclosingScrollView,\n      let tableView = scrollView.documentView as? NSTableView\n    {\n      return tableView\n    }\n\n    // Otherwise, walk up to the top-most ancestor and search its subtree.\n    var root: NSView = view\n    while let parent = root.superview {\n      root = parent\n    }\n    return findTableView(in: root)\n  }\n\n  private static func findTableView(in root: NSView) -> NSTableView? {\n    if let tableView = root as? NSTableView {\n      return tableView\n    }\n    for sub in root.subviews {\n      if let tableView = findTableView(in: sub) {\n        return tableView\n      }\n    }\n    return nil\n  }\n}\n\nextension View {\n  /// Removes the default vertical `intercellSpacing` for the\n  /// SwiftUI `Table` in which this view is hosted.\n  func removeTableSpacing(rowHeight: CGFloat? = nil) -> some View {\n    overlay(TableSpacingRemover(rowHeight: rowHeight).frame(width: 0, height: 0))\n  }\n}\n\n#endif\n"
  },
  {
    "path": "views/ConversationTimelineView.swift",
    "content": "import AppKit\nimport SwiftUI\n\nprivate let timelineTimeFormatter: DateFormatter = {\n  let formatter = DateFormatter()\n  formatter.dateFormat = \"HH:mm:ss\"\n  return formatter\n}()\nprivate let timelineRowSpacing: CGFloat = 16\n\nstruct ConversationTimelineView: View {\n  let turns: [ConversationTurn]\n  @Binding var expandedTurnIDs: Set<String>\n  let refreshToken: Int\n  var ascending: Bool = false\n  var branding: SessionSourceBranding = SessionSource.codexLocal.branding\n  var allowManualToggle: Bool = true\n  var autoExpandVisible: Bool = false\n  var isActive: Bool = false\n  var nowModeEnabled: Bool = false\n  var onNowModeChange: ((Bool) -> Void)? = nil\n  @State private var scrollView: NSScrollView?\n  @State private var scrollObserver: NSObjectProtocol?\n  @State private var suppressNowModeCallback = false\n  @State private var liveScrollObservers: [NSObjectProtocol] = []\n  @State private var userScrollActive = false\n  @State private var lastUserScrollTime: TimeInterval = 0\n  private let userScrollWindow: TimeInterval = 0.35\n  @State private var stickyTurnID: String? = nil\n  @State private var scrollThrottleTask: Task<Void, Never>? = nil\n  @State private var markerHeadFrames: [String: CGRect] = [:]\n  @State private var markerHeadHeight: CGFloat = 0\n  @State private var viewportHeight: CGFloat = 0\n  @State private var previewContext: ImagePreviewContext? = nil\n  @State private var timelinePositions: [Int: TimelinePositionData] = [:]\n  // Cached calculations to avoid recomputing on every body call\n  @State private var cachedMarkerOpacities: [String: Double] = [:]\n  // Debounce preference updates\n  @State private var preferenceDebounceTask: Task<Void, Never>? = nil\n  @State private var pendingMarkerFrames: [String: CGRect]? = nil\n\n  var body: some View {\n    timelineContent\n      .onChange(of: turns.map(\\.id)) { _, _ in\n        if previewContext != nil {\n          previewContext = nil\n        }\n        // Auto-scroll to bottom when Now mode is enabled and content changes\n        if nowModeEnabled {\n          scrollToBottom()\n        }\n      }\n      .onChange(of: refreshToken) { _, _ in\n        if nowModeEnabled {\n          scrollToBottom()\n        }\n      }\n      .onPreferenceChange(MarkerHeadFramePreferenceKey.self) { frames in\n        // Update immediately for timeline line rendering, but throttle sticky marker updates\n        markerHeadFrames = frames\n        if let height = frames.values.first?.height, height > 0, abs(height - markerHeadHeight) > 0.5 {\n          markerHeadHeight = height\n        }\n        // Throttle sticky marker and opacity updates\n        pendingMarkerFrames = frames\n        preferenceDebounceTask?.cancel()\n        preferenceDebounceTask = Task { @MainActor in\n          try? await Task.sleep(nanoseconds: 16_666_667) // ~60fps debounce\n          guard !Task.isCancelled, let frames = pendingMarkerFrames else { return }\n          pendingMarkerFrames = nil\n          updateStickyTurnID(using: frames)\n          updateMarkerOpacities()\n        }\n      }\n      .onPreferenceChange(TimelinePositionPreferenceKey.self) { positions in\n        // Update immediately for timeline line rendering\n        timelinePositions = positions\n      }\n      .onChange(of: nowModeEnabled) { _, isEnabled in\n        // Scroll to bottom when user explicitly enables Now mode\n        if isEnabled {\n          scrollToBottom()\n        }\n      }\n      .onChange(of: stickyTurnID) { _, _ in\n        updateMarkerOpacities()\n      }\n      .onChange(of: markerHeadFrames) { _, _ in\n        updateMarkerOpacities()\n      }\n      .onAppear {\n        updateMarkerOpacities()\n      }\n      .onDisappear {\n        scrollThrottleTask?.cancel()\n        preferenceDebounceTask?.cancel()\n        removeScrollObservers()\n      }\n  }\n  \n  @ViewBuilder\n  private var timelineContent: some View {\n    let topPadding: CGFloat = turns.isEmpty ? 8 : 0\n    // Recompute positions and turnsByID on each render to ensure correctness\n    let positions = Dictionary(uniqueKeysWithValues: turns.enumerated().map { index, turn in\n      let pos = ascending ? (index + 1) : (turns.count - index)\n      return (turn.id, pos)\n    })\n    let turnsByID = Dictionary(uniqueKeysWithValues: turns.map { ($0.id, $0) })\n\n    ZStack {\n      ScrollViewReader { proxy in\n        ScrollView {\n          ScrollViewAccessor { sv in\n            attachScrollView(sv)\n          }\n          .frame(width: 0, height: 0)\n\n          timelineRowsList(topPadding: topPadding)\n            .padding(.horizontal, 12)\n            .padding(.top, topPadding)\n            .padding(.bottom, 8)\n        }\n        .coordinateSpace(name: \"timelineScroll\")\n        .background(alignment: .topLeading) {\n          timelineVerticalLine\n        }\n        .overlay(alignment: .topLeading) {\n          stickyMarkerOverlay(positions: positions, turnsByID: turnsByID, topPadding: topPadding, proxy: proxy)\n        }\n      }\n\n      ImagePreviewOverlay(context: $previewContext)\n    }\n  }\n  \n  @ViewBuilder\n  private func timelineRowsList(topPadding: CGFloat) -> some View {\n    LazyVStack(alignment: .leading, spacing: timelineRowSpacing) {\n      ForEach(Array(turns.enumerated()), id: \\.element.id) { index, turn in\n        timelineRow(for: turn, at: index)\n      }\n    }\n  }\n  \n  private func timelineRow(for turn: ConversationTurn, at index: Int) -> some View {\n    // Always compute position directly from index to avoid cache staleness\n    let pos = ascending ? (index + 1) : (turns.count - index)\n    let markerOpacity = cachedMarkerOpacities[turn.id] ?? 1.0\n    let isExpanded = expandedTurnIDs.contains(turn.id)\n    let isFirst = index == turns.startIndex\n    let isLast = index == turns.count - 1\n    \n    // Use EquatableView to prevent unnecessary re-renders, but ensure position is always correct\n    return EquatableView(content: \n      ConversationTurnRow(\n        turn: turn,\n        position: pos,\n        isFirst: isFirst,\n        isLast: isLast,\n        markerOpacity: markerOpacity,\n        isExpanded: isExpanded,\n        branding: branding,\n        allowToggle: allowManualToggle,\n        autoExpandVisible: autoExpandVisible,\n        toggleExpanded: { toggle(turn) },\n        onSelectAttachment: { attachments, index in\n          previewContext = ImagePreviewContext(attachments: attachments, index: index)\n        }\n      )\n    )\n    .id(\"\\(turn.id)-\\(pos)\") // Include position in ID to force update when position changes\n  }\n  \n  @ViewBuilder\n  private func stickyMarkerOverlay(positions: [String: Int], turnsByID: [String: ConversationTurn], topPadding: CGFloat, proxy: ScrollViewProxy) -> some View {\n    if let stickyTurnID,\n       let turn = turnsByID[stickyTurnID],\n       let position = positions[stickyTurnID] {\n      let extraLineHeight = extraStickyLineHeight()\n      HStack(alignment: .top, spacing: 8) {\n        StickyTimelineMarker(\n          position: position,\n          timeText: timelineTimeFormatter.string(from: turn.timestamp),\n          isActive: isActive,\n          extraLineHeight: extraLineHeight\n        )\n        .contentShape(Rectangle())\n        .onTapGesture {\n          withAnimation(.easeInOut(duration: 0.2)) {\n            proxy.scrollTo(stickyTurnID, anchor: .top)\n          }\n        }\n        .hoverHand()\n        Spacer(minLength: 0)\n      }\n      .padding(.leading, 12)\n      .padding(.top, topPadding)\n    }\n  }\n\n  @ViewBuilder\n  private var timelineVerticalLine: some View {\n    // Draw vertical timeline line (behind all markers)\n    // Always recompute to ensure connection lines are visible\n    let lineParams = calculateTimelineLineParams()\n    if let lineParams = lineParams {\n      let segments = timelineLineSegments(for: lineParams)\n      ZStack(alignment: .topLeading) {\n        ForEach(segments) { segment in\n          Rectangle()\n            .fill(Color.secondary.opacity(0.5))\n            .frame(width: 1, height: segment.height)\n            .offset(x: lineParams.x - 1, y: segment.minY)\n        }\n      }\n      .animation(nil, value: timelinePositions)\n    }\n  }\n\n  private func calculateTimelineLineParams() -> (x: CGFloat, y: CGFloat, height: CGFloat)? {\n    guard !timelinePositions.isEmpty,\n          let firstPos = timelinePositions.keys.min(),\n          let lastPos = timelinePositions.keys.max(),\n          let firstData = timelinePositions[firstPos],\n          let lastData = timelinePositions[lastPos] else {\n      return nil\n    }\n\n    let hasValidMarkerData = firstData.markerCenterX != 0 || firstData.markerCenterY != 0\n    let hasValidCardData = lastData.messageBoxBottomY != 0\n\n    guard hasValidMarkerData && hasValidCardData else { return nil }\n\n    let lineX = firstData.markerCenterX\n    var lineTop = firstData.markerCenterY\n\n    // Limit line top to sticky marker bottom when sticky marker is visible\n    if stickyTurnID != nil && markerHeadHeight > 0 {\n      let topPadding: CGFloat = turns.isEmpty ? 8 : 0\n      lineTop = max(lineTop, markerHeadHeight + topPadding)\n    }\n\n    let lineBottom = lastData.messageBoxBottomY\n    let lineHeight = lineBottom - lineTop\n\n    guard lineHeight > 0 else { return nil }\n\n    return (x: lineX, y: lineTop, height: lineHeight)\n  }\n\n  private func timelineLineGaps(\n    for lineParams: (x: CGFloat, y: CGFloat, height: CGFloat)\n  ) -> [TimelineLineGap] {\n    let lineMinY = lineParams.y\n    let lineMaxY = lineParams.y + lineParams.height\n    let topPadding: CGFloat = timelineRowSpacing\n    let bottomPadding: CGFloat = 4\n\n    var ranges = markerHeadFrames.values.compactMap { frame -> TimelineLineRange? in\n      let minY = max(lineMinY, frame.minY - topPadding)\n      let maxY = min(lineMaxY, frame.maxY + bottomPadding)\n      guard maxY > minY else { return nil }\n      return TimelineLineRange(minY: minY, maxY: maxY)\n    }\n\n    ranges.sort { $0.minY < $1.minY }\n    var merged: [TimelineLineRange] = []\n\n    for range in ranges {\n      if let last = merged.last, range.minY <= last.maxY + 1 {\n        merged[merged.count - 1] = TimelineLineRange(minY: last.minY, maxY: max(last.maxY, range.maxY))\n      } else {\n        merged.append(range)\n      }\n    }\n\n    return merged.enumerated().map { index, range in\n      TimelineLineGap(id: index, minY: range.minY, maxY: range.maxY)\n    }\n  }\n\n  private func timelineLineSegments(\n    for lineParams: (x: CGFloat, y: CGFloat, height: CGFloat)\n  ) -> [TimelineLineSegment] {\n    let lineMinY = lineParams.y\n    let lineMaxY = lineParams.y + lineParams.height\n    let gaps = timelineLineGaps(for: lineParams)\n\n    var segments: [TimelineLineSegment] = []\n    var currentY = lineMinY\n\n    for gap in gaps {\n      if gap.minY > currentY {\n        segments.append(TimelineLineSegment(minY: currentY, maxY: gap.minY))\n      }\n      currentY = max(currentY, gap.maxY)\n    }\n\n    if lineMaxY > currentY {\n      segments.append(TimelineLineSegment(minY: currentY, maxY: lineMaxY))\n    }\n\n    return segments\n  }\n\n  private func attachScrollView(_ sv: NSScrollView) {\n    guard scrollView !== sv else { return }\n    scrollView = sv\n    sv.contentView.postsBoundsChangedNotifications = true\n\n    removeScrollObservers()\n\n    scrollObserver = NotificationCenter.default.addObserver(\n      forName: NSView.boundsDidChangeNotification,\n      object: sv.contentView,\n      queue: .main\n    ) { [weak sv] _ in\n      guard sv != nil else { return }\n      Task { @MainActor in\n        self.didScroll()\n      }\n    }\n\n    let startObserver = NotificationCenter.default.addObserver(\n      forName: NSScrollView.willStartLiveScrollNotification,\n      object: sv,\n      queue: .main\n    ) { _ in\n      userScrollActive = true\n      markUserScrollActivity()\n    }\n\n    let liveObserver = NotificationCenter.default.addObserver(\n      forName: NSScrollView.didLiveScrollNotification,\n      object: sv,\n      queue: .main\n    ) { _ in\n      markUserScrollActivity()\n    }\n\n    let endObserver = NotificationCenter.default.addObserver(\n      forName: NSScrollView.didEndLiveScrollNotification,\n      object: sv,\n      queue: .main\n    ) { _ in\n      userScrollActive = false\n      markUserScrollActivity()\n    }\n\n    liveScrollObservers = [startObserver, liveObserver, endObserver]\n\n    // Initialize scroll position without disabling an explicitly enabled Now mode.\n    DispatchQueue.main.async {\n      if self.nowModeEnabled {\n        self.scrollToBottom()\n      } else {\n        self.didScroll()\n      }\n    }\n  }\n\n  @MainActor\n  private func didScroll() {\n    guard scrollView != nil else { return }\n    if suppressNowModeCallback { return }\n    \n    // Throttle scroll updates to ~60fps (16.67ms)\n    scrollThrottleTask?.cancel()\n    scrollThrottleTask = Task { @MainActor in\n      try? await Task.sleep(nanoseconds: 16_666_667) // ~60fps\n      guard !Task.isCancelled else { return }\n      performScrollUpdate()\n    }\n  }\n  \n  @MainActor\n  private func performScrollUpdate() {\n    guard let scrollView else { return }\n    if suppressNowModeCallback { return }\n\n    let offsetY = scrollView.contentView.bounds.origin.y\n    let viewportHeight = scrollView.contentView.bounds.height\n    let contentHeight = scrollView.documentView?.bounds.height ?? 0\n    let maxOffset = max(0, contentHeight - viewportHeight)\n    let isAtBottom = abs(offsetY - maxOffset) < 10  // 10pt threshold\n    if abs(viewportHeight - self.viewportHeight) > 0.5 {\n      self.viewportHeight = viewportHeight\n    }\n\n    let now = Date().timeIntervalSinceReferenceDate\n    let userInitiated = userScrollActive || (now - lastUserScrollTime) < userScrollWindow\n    guard userInitiated else { return }\n\n    if nowModeEnabled && !isAtBottom {\n      onNowModeChange?(false)\n    } else if !nowModeEnabled && isAtBottom {\n      onNowModeChange?(true)\n    }\n  }\n\n  private func scrollToBottom() {\n    guard let scrollView else { return }\n    let viewport = scrollView.contentView.bounds.height\n    let contentHeight = scrollView.documentView?.bounds.height ?? 0\n    let maxOffset = max(0, contentHeight - viewport)\n\n    suppressNowModeCallback = true\n    scrollView.contentView.scroll(to: NSPoint(x: 0, y: maxOffset))\n    scrollView.reflectScrolledClipView(scrollView.contentView)\n\n    DispatchQueue.main.async {\n      self.suppressNowModeCallback = false\n      // Trigger sticky marker update after scroll completes\n      self.updateStickyTurnID(using: self.markerHeadFrames)\n    }\n  }\n\n  private func markUserScrollActivity() {\n    lastUserScrollTime = Date().timeIntervalSinceReferenceDate\n  }\n\n  private func removeScrollObservers() {\n    if let observer = scrollObserver {\n      NotificationCenter.default.removeObserver(observer)\n      scrollObserver = nil\n    }\n    if !liveScrollObservers.isEmpty {\n      for observer in liveScrollObservers {\n        NotificationCenter.default.removeObserver(observer)\n      }\n      liveScrollObservers.removeAll()\n    }\n  }\n\n  // Cached sticky turn ID calculation to avoid recomputing on every scroll\n  @State private var lastStickyFramesHash: Int = 0\n  \n  private func updateStickyTurnID(using frames: [String: CGRect]) {\n    guard !frames.isEmpty else {\n      if stickyTurnID != nil {\n        stickyTurnID = nil\n        lastStickyFramesHash = 0\n      }\n      return\n    }\n    \n    // Quick hash check to avoid unnecessary computation\n    let currentHash = frames.keys.sorted().joined().hashValue\n    guard currentHash != lastStickyFramesHash else { return }\n    lastStickyFramesHash = currentHash\n\n    // Find all markers that have scrolled past the top (minY <= 0)\n    let scrolledPast = frames.filter { $0.value.minY <= 0 }\n\n    if let topmost = scrolledPast.max(by: { $0.value.minY < $1.value.minY }) {\n      // Use the marker closest to the top (highest minY among those <= 0)\n      if topmost.key != stickyTurnID {\n        stickyTurnID = topmost.key\n      }\n    } else {\n      // No marker has scrolled past the top, use the first visible one\n      if let first = frames.min(by: { $0.value.minY < $1.value.minY }) {\n        if first.key != stickyTurnID {\n          stickyTurnID = first.key\n        }\n      }\n    }\n  }\n\n  private func extraStickyLineHeight() -> CGFloat {\n    guard viewportHeight > 0, markerHeadHeight > 0 else { return 0 }\n    let nextVisibleHeadMinY = markerHeadFrames.values\n      .filter { $0.minY > 0 && $0.minY < viewportHeight }\n      .map { $0.minY }\n      .min()\n    guard nextVisibleHeadMinY == nil else { return 0 }\n    return max(0, viewportHeight - markerHeadHeight)\n  }\n\n  private func toggle(_ turn: ConversationTurn) {\n    guard allowManualToggle else { return }\n    if expandedTurnIDs.contains(turn.id) {\n      expandedTurnIDs.remove(turn.id)\n    } else {\n      expandedTurnIDs.insert(turn.id)\n    }\n  }\n  \n  // Update cached marker opacities when stickyTurnID or markerHeadFrames change\n  private func updateMarkerOpacities() {\n    var newOpacities: [String: Double] = [:]\n    for turn in turns {\n      let opacity = computeMarkerOpacity(for: turn.id)\n      newOpacities[turn.id] = opacity\n    }\n    cachedMarkerOpacities = newOpacities\n  }\n  \n  // Compute marker opacity (extracted from markerOpacity method for caching)\n  private func computeMarkerOpacity(for id: String) -> Double {\n    guard id == stickyTurnID else { return 1 }\n    guard let frame = markerHeadFrames[id], frame.height > 0 else { return 1 }\n    let minY = frame.minY\n    if minY <= 0 { return 0 }\n    if minY >= frame.height { return 1 }\n    return Double(minY / frame.height)\n  }\n  \n  // Keep original method for backward compatibility (now uses cache)\n  private func markerOpacity(for id: String) -> Double {\n    return cachedMarkerOpacities[id] ?? 1.0\n  }\n}\n\nprivate struct TimelineLineRange {\n  let minY: CGFloat\n  let maxY: CGFloat\n}\n\nprivate struct TimelineLineGap: Identifiable {\n  let id: Int\n  let minY: CGFloat\n  let maxY: CGFloat\n  var height: CGFloat { maxY - minY }\n}\n\nprivate struct TimelineLineSegment: Identifiable {\n  let id = UUID()\n  let minY: CGFloat\n  let maxY: CGFloat\n  var height: CGFloat { maxY - minY }\n}\n\n// ScrollViewAccessor to get the underlying NSScrollView\nprivate struct ScrollViewAccessor: NSViewRepresentable {\n  let onScrollViewAvailable: (NSScrollView) -> Void\n\n  func makeNSView(context: Context) -> NSView {\n    let view = NSView()\n    DispatchQueue.main.async {\n      if let scrollView = view.enclosingScrollView {\n        onScrollViewAvailable(scrollView)\n      }\n    }\n    return view\n  }\n\n  func updateNSView(_ nsView: NSView, context: Context) {}\n}\n\nprivate struct MarkerHeadFramePreferenceKey: PreferenceKey {\n  static var defaultValue: [String: CGRect] = [:]\n  static func reduce(value: inout [String: CGRect], nextValue: () -> [String: CGRect]) {\n    value.merge(nextValue(), uniquingKeysWith: { $1 })\n  }\n}\n\nprivate struct TimelinePositionData: Equatable {\n  var markerCenterX: CGFloat = 0\n  var markerCenterY: CGFloat = 0\n  var messageBoxBottomY: CGFloat = 0\n}\n\nprivate struct TimelinePositionPreferenceKey: PreferenceKey {\n  static var defaultValue: [Int: TimelinePositionData] = [:]\n  static func reduce(value: inout [Int: TimelinePositionData], nextValue: () -> [Int: TimelinePositionData]) {\n    value.merge(nextValue(), uniquingKeysWith: { existing, new in\n      var merged = existing\n      if new.markerCenterX != 0 {\n        merged.markerCenterX = new.markerCenterX\n      }\n      if new.markerCenterY != 0 {\n        merged.markerCenterY = new.markerCenterY\n      }\n      if new.messageBoxBottomY != 0 {\n        merged.messageBoxBottomY = new.messageBoxBottomY\n      }\n      return merged\n    })\n  }\n}\n\nprivate struct ConversationTurnRow: View, Equatable {\n  let turn: ConversationTurn\n  let position: Int\n  let isFirst: Bool\n  let isLast: Bool\n  let markerOpacity: Double\n  let isExpanded: Bool\n  let branding: SessionSourceBranding\n  let allowToggle: Bool\n  let autoExpandVisible: Bool\n  let toggleExpanded: () -> Void\n  let onSelectAttachment: ([TimelineAttachment], Int) -> Void\n  @State private var isVisible = false\n  \n  static func == (lhs: ConversationTurnRow, rhs: ConversationTurnRow) -> Bool {\n    // Always return false if position changes to force layout update\n    guard lhs.position == rhs.position else { return false }\n    return lhs.turn.id == rhs.turn.id &&\n    lhs.isFirst == rhs.isFirst &&\n    lhs.isLast == rhs.isLast &&\n    abs(lhs.markerOpacity - rhs.markerOpacity) < 0.01 &&\n    lhs.isExpanded == rhs.isExpanded &&\n    lhs.branding.providerKind == rhs.branding.providerKind &&\n    lhs.allowToggle == rhs.allowToggle &&\n    lhs.autoExpandVisible == rhs.autoExpandVisible\n  }\n\n  var body: some View {\n    let expanded = autoExpandVisible ? isVisible : isExpanded\n    HStack(alignment: .top, spacing: 8) {\n      TimelineMarker(\n        position: position,\n        timeText: timelineTimeFormatter.string(from: turn.timestamp),\n        isFirst: isFirst,\n        isLast: isLast,\n        frameKeyID: turn.id,\n        reportPosition: position\n      )\n      .opacity(markerOpacity)\n\n      ConversationCard(\n        turn: turn,\n        isExpanded: expanded,\n        branding: branding,\n        allowToggle: allowToggle,\n        toggle: toggleExpanded,\n        onSelectAttachment: onSelectAttachment,\n        reportPosition: position\n      )\n      .frame(maxWidth: .infinity, alignment: .leading)\n    }\n    .onAppear {\n      if autoExpandVisible {\n        isVisible = true\n      }\n    }\n    .onDisappear {\n      if autoExpandVisible {\n        isVisible = false\n      }\n    }\n    .onChange(of: autoExpandVisible) { _, newValue in\n      if !newValue {\n        isVisible = false\n      }\n    }\n  }\n}\n\nprivate struct TimelineMarker: View {\n  let position: Int\n  let timeText: String\n  let isFirst: Bool\n  let isLast: Bool\n  var frameKeyID: String? = nil\n  var reportPosition: Int? = nil\n  var showBackground: Bool = false\n\n  var body: some View {\n    TimelineMarkerHead(\n      position: position,\n      timeText: timeText,\n      isFirst: isFirst,\n      frameKeyID: frameKeyID,\n      showBackground: showBackground\n    )\n    .frame(width: 72, alignment: .top)\n    .background(\n      GeometryReader { proxy in\n        Color.clear.preference(\n          key: TimelinePositionPreferenceKey.self,\n          value: reportPosition.map { pos in\n            let frame = proxy.frame(in: .named(\"timelineScroll\"))\n            var data = TimelinePositionData()\n            data.markerCenterX = frame.midX\n            data.markerCenterY = frame.midY\n            return [pos: data]\n          } ?? [:]\n        )\n      }\n    )\n  }\n}\n\nprivate struct TimelineMarkerHead: View {\n  let position: Int\n  let timeText: String\n  let isFirst: Bool\n  var frameKeyID: String? = nil\n  var showBackground: Bool = false\n\n  var body: some View {\n    VStack(alignment: .center, spacing: 6) {\n      Text(String(position))\n        .font(.caption.bold())\n        .foregroundColor(.white)\n        .padding(.horizontal, 6)\n        .padding(.vertical, 2)\n        .background(\n          Capsule()\n            .fill(Color.accentColor)\n        )\n\n      Text(timeText)\n        .font(.caption2.monospacedDigit())\n        .foregroundStyle(Color.accentColor)\n    }\n    .padding(.horizontal, 8)\n    .padding(.bottom, 8)\n    .background(showBackground ? Color(nsColor: .controlBackgroundColor) : Color.clear)\n    .background(\n      GeometryReader { proxy in\n        if let id = frameKeyID {\n          Color.clear.preference(\n            key: MarkerHeadFramePreferenceKey.self,\n            value: [id: proxy.frame(in: .named(\"timelineScroll\"))]\n          )\n        } else {\n          Color.clear\n        }\n      }\n    )\n  }\n}\n\nprivate struct StickyTimelineMarker: View {\n  let position: Int\n  let timeText: String\n  let isActive: Bool\n  let extraLineHeight: CGFloat\n\n  var body: some View {\n    TimelineMarker(\n      position: position,\n      timeText: timeText,\n      isFirst: true,\n      isLast: true,\n      showBackground: false\n    )\n  }\n}\n\nprivate struct ConversationCard: View {\n  let turn: ConversationTurn\n  let isExpanded: Bool\n  let branding: SessionSourceBranding\n  let allowToggle: Bool\n  let toggle: () -> Void\n  let onSelectAttachment: ([TimelineAttachment], Int) -> Void\n  var reportPosition: Int? = nil\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n\n      if isExpanded {\n        expandedBody\n      } else {\n        collapsedBody\n      }\n    }\n    .padding(16)\n    .background(\n      UnevenRoundedRectangle(\n        topLeadingRadius: 0,\n        bottomLeadingRadius: 14,\n        bottomTrailingRadius: 14,\n        topTrailingRadius: 14\n      )\n      .fill(Color(nsColor: .controlBackgroundColor))\n    )\n    .overlay(\n      UnevenRoundedRectangle(\n        topLeadingRadius: 0,\n        bottomLeadingRadius: 14,\n        bottomTrailingRadius: 14,\n        topTrailingRadius: 14\n      )\n      .stroke(Color.primary.opacity(0.07), lineWidth: 1)\n    )\n    .background(\n      GeometryReader { proxy in\n        Color.clear.preference(\n          key: TimelinePositionPreferenceKey.self,\n          value: reportPosition.map { pos in\n            let frame = proxy.frame(in: .named(\"timelineScroll\"))\n            var data = TimelinePositionData()\n            data.messageBoxBottomY = frame.maxY\n            return [pos: data]\n          } ?? [:]\n        )\n      }\n    )\n  }\n\n  private var header: some View {\n    HStack {\n      Text(turn.actorSummary(using: branding.displayName))\n        .font(.subheadline.weight(.semibold))\n        .foregroundStyle(.primary)\n      Spacer()\n      if allowToggle {\n        Image(systemName: isExpanded ? \"chevron.up\" : \"chevron.down\")\n          .font(.subheadline.weight(.semibold))\n          .foregroundStyle(.secondary)\n      }\n    }\n    .contentShape(Rectangle())\n    .onTapGesture {\n      if allowToggle {\n        toggle()\n      }\n    }\n    .hoverHand()\n  }\n\n  @ViewBuilder\n  private var collapsedBody: some View {\n    if let preview = turn.previewText, !preview.isEmpty {\n      Text(preview)\n        .font(.callout)\n        .foregroundStyle(.secondary)\n        .lineLimit(3)\n        .frame(maxWidth: .infinity, alignment: .leading)\n    } else {\n      Text(\"Tap to view details\")\n        .font(.caption)\n        .foregroundStyle(.tertiary)\n    }\n  }\n\n  @ViewBuilder\n  private var expandedBody: some View {\n    if let user = turn.userMessage {\n      EventSegmentView(event: user, branding: branding, onSelectAttachment: onSelectAttachment)\n    }\n\n    ForEach(Array(turn.outputs.enumerated()), id: \\.offset) { index, event in\n      if index > 0 || turn.userMessage != nil {\n        Divider()\n      }\n      EventSegmentView(event: event, branding: branding, onSelectAttachment: onSelectAttachment)\n    }\n  }\n}\n\nprivate struct EventSegmentView: View {\n  let event: TimelineEvent\n  let branding: SessionSourceBranding\n  let onSelectAttachment: ([TimelineAttachment], Int) -> Void\n  @State private var isHover = false\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      HStack(alignment: .firstTextBaseline, spacing: 6) {\n        roleIconView\n          .foregroundStyle(roleColor)\n\n        Text(roleTitle)\n          .font(.caption.weight(.medium))\n          .foregroundStyle(.secondary)\n\n        if event.repeatCount > 1 {\n          Text(\"×\\(event.repeatCount)\")\n            .font(.caption2.monospacedDigit())\n            .foregroundStyle(.tertiary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(\n              Capsule()\n                .fill(Color.secondary.opacity(0.1))\n            )\n        }\n\n        Spacer()\n\n        Button {\n          NSPasteboard.general.clearContents()\n          NSPasteboard.general.setString(event.text ?? \"\", forType: .string)\n        } label: {\n          Image(systemName: \"doc.on.doc\")\n            .font(.caption2)\n        }\n        .buttonStyle(.plain)\n        .foregroundStyle(.secondary)\n        .opacity(isHover ? 1 : 0)\n        .help(\"Copy to clipboard\")\n      }\n\n      if let text = event.text, !text.isEmpty {\n        // User messages and tool_output use collapsible text\n        if event.visibilityKind == .user {\n          CollapsibleText(text: text, lineLimit: 10)\n        } else if event.actor == .tool {\n          CollapsibleText(text: text, lineLimit: 3)\n        } else {\n          Text(text)\n            .textSelection(.enabled)\n            .font(.body)\n            .frame(maxWidth: .infinity, alignment: .leading)\n        }\n      }\n\n      if !event.attachments.isEmpty {\n        AttachmentStripView(attachments: event.attachments, onSelect: onSelectAttachment)\n      }\n\n      if let metadata = event.metadata {\n        MetadataView(metadata: metadata)\n      }\n    }\n    .onHover { hovering in\n      isHover = hovering\n    }\n  }\n\n  private var roleTitle: String {\n    event.visibilityKind.settingsLabel\n  }\n\n  @ViewBuilder\n  private var roleIconView: some View {\n    switch event.visibilityKind {\n    case .assistant:\n      ProviderIconView(provider: branding.providerKind, size: 12, cornerRadius: 2)\n    default:\n      Image(systemName: roleIconName)\n        .font(.caption2)\n    }\n  }\n\n  private var roleIconName: String {\n    switch event.visibilityKind {\n    case .user: return \"person.fill\"\n    case .assistant: return branding.symbolName\n    case .tool: return \"hammer.fill\"\n    case .codeEdit: return \"square.and.pencil\"\n    case .reasoning: return \"brain\"\n    case .tokenUsage: return \"gauge\"\n    case .environmentContext: return \"macwindow\"\n    case .turnContext: return \"arrow.triangle.2.circlepath\"\n    case .infoOther: return \"info.circle\"\n    }\n  }\n\n  private var roleColor: Color {\n    switch event.visibilityKind {\n    case .user: return .accentColor\n    case .assistant: return branding.iconColor\n    case .tool: return .yellow\n    case .codeEdit: return .green\n    case .reasoning: return .purple\n    case .tokenUsage: return .orange\n    case .environmentContext, .turnContext, .infoOther:\n      return .gray\n    }\n  }\n}\n\nprivate struct ImagePreviewContext: Equatable {\n  var attachments: [TimelineAttachment]\n  var index: Int\n}\n\nprivate struct ImagePreviewOverlay: View {\n  @Binding var context: ImagePreviewContext?\n  @State private var image: NSImage? = nil\n  @State private var isLoading = false\n  @State private var errorText: String? = nil\n  @State private var scale: CGFloat = 1\n  @State private var gestureScale: CGFloat = 1\n  @State private var offset: CGSize = .zero\n  @GestureState private var dragOffset: CGSize = .zero\n\n  var body: some View {\n    if let context, let attachment = currentAttachment(from: context) {\n      GeometryReader { proxy in\n        ZStack {\n          Color.black.opacity(0.78)\n            .onTapGesture { close() }\n\n          VStack(spacing: 16) {\n            ZStack {\n              if let image {\n                ZStack {\n                  Image(nsImage: image)\n                    .interpolation(.high)\n                    .resizable()\n                    .scaledToFit()\n                    .scaleEffect(currentScale)\n                    .offset(\n                      x: offset.width + dragOffset.width,\n                      y: offset.height + dragOffset.height\n                    )\n                    .shadow(color: Color.black.opacity(0.5), radius: 24, x: 0, y: 12)\n\n                  ScrollWheelZoomView { delta in\n                    applyScrollZoom(delta)\n                  }\n                  .frame(maxWidth: .infinity, maxHeight: .infinity)\n                }\n                .gesture(\n                  DragGesture()\n                    .updating($dragOffset) { value, state, _ in\n                      state = value.translation\n                    }\n                    .onEnded { value in\n                      offset.width += value.translation.width\n                      offset.height += value.translation.height\n                    }\n                )\n                .simultaneousGesture(\n                  MagnificationGesture()\n                    .onChanged { value in\n                      gestureScale = value\n                    }\n                    .onEnded { value in\n                      scale = clampScale(scale * value)\n                      gestureScale = 1\n                    }\n                )\n                .frame(maxWidth: 1200, maxHeight: 800)\n              } else if isLoading {\n                ProgressView()\n                  .progressViewStyle(.circular)\n                  .tint(.white)\n              } else {\n                Text(errorText ?? \"Unable to preview image\")\n                  .foregroundStyle(.white)\n                  .font(.callout)\n              }\n            }\n\n            HStack(spacing: 12) {\n              Button {\n                goPrevious()\n              } label: {\n                Image(systemName: \"chevron.left\")\n              }\n              .disabled(!canGoPrevious)\n\n              Text(\"\\(context.index + 1) / \\(context.attachments.count)\")\n                .font(.caption2)\n                .foregroundStyle(.white.opacity(0.85))\n\n              Button {\n                goNext()\n              } label: {\n                Image(systemName: \"chevron.right\")\n              }\n              .disabled(!canGoNext)\n\n              Spacer(minLength: 12)\n\n              Button(\"Close (Esc)\") { close() }\n              Button(\"Open Externally\") { TimelineAttachmentOpener.shared.open(attachment) }\n            }\n            .buttonStyle(.bordered)\n            .foregroundStyle(.white)\n          }\n          .padding(24)\n\n          KeyCommandCatcher { event in\n            handleKey(event)\n          }\n          .frame(width: proxy.size.width, height: proxy.size.height)\n          .allowsHitTesting(false)\n        }\n        .frame(width: proxy.size.width, height: proxy.size.height)\n        .clipped()\n      }\n      .onAppear {\n        resetTransform()\n        load(attachment)\n      }\n      .onChange(of: attachment.id) { _, _ in\n        resetTransform()\n        load(attachment)\n      }\n    }\n  }\n\n  private var currentScale: CGFloat {\n    clampScale(scale * gestureScale)\n  }\n\n  private var canGoPrevious: Bool {\n    guard let context else { return false }\n    return context.index > 0\n  }\n\n  private var canGoNext: Bool {\n    guard let context else { return false }\n    return context.index < (context.attachments.count - 1)\n  }\n\n  private func currentAttachment(from context: ImagePreviewContext) -> TimelineAttachment? {\n    guard context.index >= 0, context.index < context.attachments.count else { return nil }\n    return context.attachments[context.index]\n  }\n\n  private func close() {\n    context = nil\n  }\n\n  private func goPrevious() {\n    guard var context, context.index > 0 else { return }\n    context.index -= 1\n    self.context = context\n  }\n\n  private func goNext() {\n    guard var context, context.index + 1 < context.attachments.count else { return }\n    context.index += 1\n    self.context = context\n  }\n\n  private func resetTransform() {\n    scale = 1\n    gestureScale = 1\n    offset = .zero\n  }\n\n  private func clampScale(_ value: CGFloat) -> CGFloat {\n    min(max(value, 0.2), 6)\n  }\n\n  private func applyScrollZoom(_ delta: CGFloat) {\n    guard delta != 0 else { return }\n    let step = max(-0.25, min(0.25, delta / 300))\n    scale = clampScale(scale * (1 + step))\n  }\n\n  private func handleKey(_ event: NSEvent) -> Bool {\n    switch event.keyCode {\n    case 53: // escape\n      close()\n      return true\n    case 123: // left arrow\n      goPrevious()\n      return true\n    case 124: // right arrow\n      goNext()\n      return true\n    default:\n      return false\n    }\n  }\n\n  private func load(_ attachment: TimelineAttachment) {\n    isLoading = true\n    image = nil\n    errorText = nil\n    Task.detached {\n      let resolvedData = TimelineAttachmentDecoder.imageData(for: attachment)\n      await MainActor.run {\n        if let resolvedData {\n          self.image = NSImage(data: resolvedData)\n        } else {\n          self.image = nil\n        }\n        self.isLoading = false\n        if resolvedData == nil {\n          self.errorText = \"Unable to preview this image.\"\n        }\n      }\n    }\n  }\n}\n\nprivate struct KeyCommandCatcher: NSViewRepresentable {\n  let onKeyDown: (NSEvent) -> Bool\n\n  func makeNSView(context: Context) -> KeyCommandCatcherView {\n    let view = KeyCommandCatcherView()\n    view.onKeyDown = onKeyDown\n    DispatchQueue.main.async {\n      view.window?.makeFirstResponder(view)\n    }\n    return view\n  }\n\n  func updateNSView(_ nsView: KeyCommandCatcherView, context: Context) {\n    nsView.onKeyDown = onKeyDown\n    DispatchQueue.main.async {\n      if nsView.window?.firstResponder !== nsView {\n        nsView.window?.makeFirstResponder(nsView)\n      }\n    }\n  }\n}\n\nprivate final class KeyCommandCatcherView: NSView {\n  var onKeyDown: ((NSEvent) -> Bool)?\n\n  override var acceptsFirstResponder: Bool { true }\n\n  override func keyDown(with event: NSEvent) {\n    if onKeyDown?(event) == true { return }\n    super.keyDown(with: event)\n  }\n}\n\nprivate struct ScrollWheelZoomView: NSViewRepresentable {\n  let onScroll: (CGFloat) -> Void\n\n  func makeNSView(context: Context) -> ScrollWheelCatcher {\n    let view = ScrollWheelCatcher()\n    view.onScroll = onScroll\n    return view\n  }\n\n  func updateNSView(_ nsView: ScrollWheelCatcher, context: Context) {\n    nsView.onScroll = onScroll\n  }\n}\n\nprivate final class ScrollWheelCatcher: NSView {\n  var onScroll: ((CGFloat) -> Void)?\n\n  override func scrollWheel(with event: NSEvent) {\n    onScroll?(event.scrollingDeltaY)\n  }\n}\n\nprivate struct AttachmentStripView: View {\n  let attachments: [TimelineAttachment]\n  let onSelect: ([TimelineAttachment], Int) -> Void\n\n  var body: some View {\n    HStack(spacing: 8) {\n      ForEach(Array(attachments.enumerated()), id: \\.element.id) { index, attachment in\n        Button {\n          onSelect(attachments, index)\n        } label: {\n          HStack(spacing: 4) {\n            Image(systemName: \"photo\")\n              .font(.caption)\n            Text(attachment.label ?? \"Image \\(index + 1)\")\n              .font(.caption2)\n          }\n          .padding(.vertical, 2)\n          .padding(.horizontal, 6)\n          .background(\n            RoundedRectangle(cornerRadius: 6)\n              .fill(Color.secondary.opacity(0.1))\n          )\n        }\n        .buttonStyle(.plain)\n        .help(attachment.label ?? \"Open image\")\n        .hoverHand()\n      }\n    }\n  }\n}\n\nprivate struct CollapsibleText: View {\n  let text: String\n  let lineLimit: Int\n  @State private var isExpanded = false\n\n  var body: some View {\n    let previewInfo = linePreview(text, limit: lineLimit)\n    let preview = previewInfo.text\n    let truncated = previewInfo.truncated\n    VStack(alignment: .leading, spacing: 6) {\n      Text(isExpanded ? text : preview)\n        .textSelection(.enabled)\n        .font(.body)\n        .frame(maxWidth: .infinity, alignment: .leading)\n\n      if truncated {\n        Button(action: { isExpanded.toggle() }) {\n          HStack {\n            Spacer()\n            Image(systemName: isExpanded ? \"chevron.up\" : \"chevron.down\")\n              .font(.caption.weight(.bold))\n              .foregroundStyle(.secondary)\n            Spacer()\n          }\n          .frame(height: 24)\n          .contentShape(Rectangle())\n        }\n        .buttonStyle(.plain)\n        .hoverHand()\n      }\n    }\n  }\n\n  private func linePreview(_ text: String, limit: Int) -> (text: String, truncated: Bool) {\n    // limit = 0 means no truncation, show all\n    guard limit > 0 else { return (text, false) }\n    var newlineCount = 0\n    for index in text.indices {\n      if text[index] == \"\\n\" {\n        newlineCount += 1\n        if newlineCount == limit {\n          return (String(text[..<index]), true)\n        }\n      }\n    }\n    return (text, false)\n  }\n}\n\nprivate struct MetadataView: View {\n  let metadata: [String: String]\n  private let keyColumnWidth: CGFloat = 240\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 4) {\n      ForEach(metadata.keys.sorted(), id: \\.self) { key in\n        if let value = metadata[key], !value.isEmpty {\n          HStack(alignment: .firstTextBaseline, spacing: 8) {\n            Text(key)\n              .font(.caption2)\n              .foregroundStyle(.tertiary)\n              .lineLimit(1)\n              .truncationMode(.tail)\n              .frame(width: keyColumnWidth, alignment: .trailing)\n            Text(value)\n              .font(.caption2.monospaced())\n              .foregroundStyle(.secondary)\n              .textSelection(.enabled)\n              .frame(maxWidth: .infinity, alignment: .leading)\n            Spacer(minLength: 8)\n          }\n        }\n      }\n    }\n    .padding(.top, 4)\n  }\n}\n\n#Preview {\n  ConversationTimelinePreview()\n}\n\nprivate struct ConversationTimelinePreview: View {\n  @State private var expanded: Set<String> = []\n\n  private var sampleTurn: ConversationTurn {\n    let now = Date()\n    let userEvent = TimelineEvent(\n      id: UUID().uuidString,\n      timestamp: now,\n      actor: .user,\n      title: nil,\n      text: \"Please outline a multi-tenant design for the MCP Mate project.\",\n      metadata: nil,\n      repeatCount: 1,\n      attachments: [],\n      visibilityKind: .user\n    )\n    let infoEvent = TimelineEvent(\n      id: UUID().uuidString,\n      timestamp: now.addingTimeInterval(6),\n      actor: .info,\n      title: \"Context Updated\",\n      text: \"model: gpt-5.2-codex\\npolicy: on-request\",\n      metadata: nil,\n      repeatCount: 3,\n      attachments: [],\n      visibilityKind: .turnContext\n    )\n    let assistantEvent = TimelineEvent(\n      id: UUID().uuidString,\n      timestamp: now.addingTimeInterval(12),\n      actor: .assistant,\n      title: nil,\n      text: \"Certainly. Here are the key considerations for a multi-tenant design...\",\n      metadata: nil,\n      repeatCount: 1,\n      attachments: [],\n      visibilityKind: .assistant\n    )\n    return ConversationTurn(\n      id: UUID().uuidString,\n      timestamp: now,\n      userMessage: userEvent,\n      outputs: [infoEvent, assistantEvent]\n    )\n  }\n\n  var body: some View {\n    ConversationTimelineView(\n      turns: [sampleTurn],\n      expandedTurnIDs: $expanded,\n      refreshToken: 0,\n      branding: SessionSource.codexLocal.branding,\n      isActive: true\n    )\n    .padding()\n    .frame(width: 540)\n  }\n}\n\n// Provide a handy pointer extension to keep cursor behavior consistent on clickable areas\nextension View {\n  func hoverHand() -> some View {\n    self.onHover { inside in\n      if inside { NSCursor.pointingHand.set() } else { NSCursor.arrow.set() }\n    }\n  }\n}\n"
  },
  {
    "path": "views/DiagnosticsViews.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct DiagnosticsSection: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    @State private var running = false\n    @State private var lastResult: SessionsDiagnostics? = nil\n    @State private var lastError: String? = nil\n    private let service = SessionsDiagnosticsService()\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            Text(\"Diagnostics\")\n                .font(.headline)\n                .foregroundColor(.primary)\n\n            HStack(spacing: 8) {\n                Button(action: runDiagnostics) {\n                    if running { ProgressView().controlSize(.small) }\n                    Text(running ? \"Diagnosing…\" : \"Diagnose Data Directories\")\n                }\n                .disabled(running)\n\n                if let result = lastResult,\n                    result.current.enumeratedCount == 0,\n                    result.defaultRoot.enumeratedCount > 0,\n                    preferences.sessionsRoot.path != result.defaultRoot.path\n                {\n                    Button(\"Switch to Default Path\") {\n                        preferences.sessionsRoot = URL(\n                            fileURLWithPath: result.defaultRoot.path, isDirectory: true)\n                    }\n                }\n\n                if lastResult != nil {\n                    Button(\"Save Report…\", action: saveReport)\n                }\n            }\n\n            if let error = lastError { Text(error).foregroundStyle(.red).font(.caption) }\n\n            if let result = lastResult {\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Codex Sessions Root\").font(.headline).fontWeight(.semibold)\n                    DiagnosticsReportView(result: result)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(8)\n                        .background(\n                            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                .fill(Color(nsColor: .textBackgroundColor))\n                                .overlay(\n                                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                                )\n                        )\n                    Text(\"Claude Sessions Directory\").font(.headline).fontWeight(.semibold).padding(.top, 4)\n                    VStack(alignment: .leading, spacing: 8) {\n                        if let cc = result.claudeCurrent {\n                            DataPairReportView(current: cc, defaultProbe: result.claudeDefault)\n                        } else {\n                            DataPairReportView(current: result.claudeDefault, defaultProbe: result.claudeDefault)\n                        }\n                    }\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(8)\n                        .background(\n                            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                .fill(Color(nsColor: .textBackgroundColor))\n                                .overlay(\n                                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                                )\n                        )\n                    Text(\"Gemini Sessions Directory\").font(.headline).fontWeight(.semibold).padding(.top, 4)\n                    VStack(alignment: .leading, spacing: 8) {\n                        if let gc = result.geminiCurrent {\n                            DataPairReportView(current: gc, defaultProbe: result.geminiDefault)\n                        } else {\n                            DataPairReportView(current: result.geminiDefault, defaultProbe: result.geminiDefault)\n                        }\n                    }\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(8)\n                        .background(\n                            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                .fill(Color(nsColor: .textBackgroundColor))\n                                .overlay(\n                                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                                )\n                        )\n                    Text(\"Notes Directory\").font(.headline).fontWeight(.semibold).padding(.top, 4)\n                    DataPairReportView(current: result.notesCurrent, defaultProbe: result.notesDefault)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(8)\n                        .background(\n                            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                .fill(Color(nsColor: .textBackgroundColor))\n                                .overlay(\n                                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                                )\n                        )\n                    Text(\"Projects Directory\").font(.headline).fontWeight(.semibold).padding(.top, 4)\n                    DataPairReportView(current: result.projectsCurrent, defaultProbe: result.projectsDefault)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(8)\n                        .background(\n                            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                .fill(Color(nsColor: .textBackgroundColor))\n                                .overlay(\n                                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                        .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                                )\n                        )\n\n                    Text(\"Claude Sessions Directory\").font(.headline).fontWeight(.semibold).padding(.top, 4)\n                    VStack(alignment: .leading, spacing: 8) {\n                        if let cc = result.claudeCurrent {\n                            DataPairReportView(current: cc, defaultProbe: result.claudeDefault)\n                        } else {\n                            // Show default path only (current is unknown/not configured)\n                            DataPairReportView(current: result.claudeDefault, defaultProbe: result.claudeDefault)\n                        }\n                    }\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                    .padding(8)\n                    .background(\n                        RoundedRectangle(cornerRadius: 8, style: .continuous)\n                            .fill(Color(nsColor: .textBackgroundColor))\n                            .overlay(\n                                RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                    .stroke(Color.secondary.opacity(0.2), lineWidth: 1)\n                            )\n                    )\n                }\n            }\n        }\n    }\n\n    private func runDiagnostics() {\n        running = true\n        lastError = nil\n        lastResult = nil\n        let current = preferences.sessionsRoot\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        let def = SessionPreferencesStore.defaultSessionsRoot(for: home)\n        let notesCurrent = preferences.notesRoot\n        let notesDefault = SessionPreferencesStore.defaultNotesRoot(for: def)\n        let projectsCurrent = preferences.projectsRoot\n        let projectsDefault = SessionPreferencesStore.defaultProjectsRoot(for: home)\n        let claudeDefault = home.appendingPathComponent(\".claude\", isDirectory: true).appendingPathComponent(\"projects\", isDirectory: true)\n        let claudeCurrent: URL? = FileManager.default.fileExists(atPath: claudeDefault.path) ? claudeDefault : nil\n        let geminiDefault = home.appendingPathComponent(\".gemini\", isDirectory: true).appendingPathComponent(\"tmp\", isDirectory: true)\n        let geminiCurrent: URL? = FileManager.default.fileExists(atPath: geminiDefault.path) ? geminiDefault : nil\n        Task {\n            let res = await service.run(\n                currentRoot: current,\n                defaultRoot: def,\n                notesCurrentRoot: notesCurrent,\n                notesDefaultRoot: notesDefault,\n                projectsCurrentRoot: projectsCurrent,\n                projectsDefaultRoot: projectsDefault,\n                claudeCurrentRoot: claudeCurrent,\n                claudeDefaultRoot: claudeDefault,\n                geminiCurrentRoot: geminiCurrent,\n                geminiDefaultRoot: geminiDefault\n            )\n            await MainActor.run {\n                self.lastResult = res\n                self.running = false\n            }\n        }\n    }\n\n    private func saveReport() {\n        guard let result = lastResult else { return }\n        let encoder = JSONEncoder()\n        encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]\n        encoder.dateEncodingStrategy = .iso8601\n        do {\n            let data = try encoder.encode(result)\n            let panel = NSSavePanel()\n            panel.canCreateDirectories = true\n            panel.allowedContentTypes = [.json]\n            let df = DateFormatter()\n            df.dateFormat = \"yyyyMMdd-HHmmss\"\n            let ts = df.string(from: result.timestamp)\n            panel.nameFieldStringValue = \"CodMate-Sessions-Diagnostics-\\(ts).json\"\n            panel.begin { resp in\n                if resp == .OK, let url = panel.url {\n                    do { try data.write(to: url, options: .atomic) } catch {\n                        self.lastError = \"Failed to save report: \\(error.localizedDescription)\"\n                    }\n                }\n            }\n        } catch {\n            self.lastError = \"Failed to prepare report: \\(error.localizedDescription)\"\n        }\n    }\n}\n\nstruct DiagnosticsReportView: View {\n    let result: SessionsDiagnostics\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Text(\"Timestamp: \\(formatDate(result.timestamp))\").font(.caption)\n            let same = result.current.path == result.defaultRoot.path\n            Group {\n                Text(same ? \"Sessions Root (= Default)\" : \"Current Root\")\n                    .font(.subheadline).bold()\n                DiagnosticsProbeView(p: result.current)\n            }\n            if !same {\n                Group {\n                    Text(\"Default Root\").font(.subheadline).bold().padding(.top, 4)\n                    DiagnosticsProbeView(p: result.defaultRoot)\n                }\n            }\n\n            if !result.suggestions.isEmpty {\n                Text(\"Suggestions\").font(.subheadline).bold().padding(.top, 4)\n                ForEach(result.suggestions, id: \\.self) { s in\n                    Text(\"• \\(s)\").font(.caption)\n                }\n            }\n        }\n    }\n\n    private func formatDate(_ d: Date) -> String {\n        let df = DateFormatter()\n        df.dateStyle = .medium\n        df.timeStyle = .medium\n        return df.string(from: d)\n    }\n}\n\nstruct DiagnosticsProbeView: View {\n    let p: SessionsDiagnostics.Probe\n    var body: some View {\n        VStack(alignment: .leading, spacing: 0) {\n            Text(\"Path: \\(p.path)\").font(.caption)\n            Text(\"Exists: \\(p.exists ? \"yes\" : \"no\")\").font(.caption)\n            Text(\"Directory: \\(p.isDirectory ? \"yes\" : \"no\")\").font(.caption)\n            Text(\"Files: \\(p.enumeratedCount)\").font(.caption)\n            if !p.sampleFiles.isEmpty {\n                Text(\"Samples:\").font(.caption)\n                ForEach(p.sampleFiles.prefix(5), id: \\.self) { s in\n                    Text(\"• \\(s)\").font(.caption2)\n                }\n                if p.sampleFiles.count > 5 {\n                    Text(\"(\\(p.sampleFiles.count - 5) more…)\").font(.caption2).foregroundStyle(\n                        .secondary)\n                }\n            }\n            if let err = p.enumeratorError {\n                Text(\"Enumerator Error: \\(err)\").font(.caption).foregroundStyle(.red)\n            }\n        }\n    }\n}\n\nstruct DataPairReportView: View {\n    let current: SessionsDiagnostics.Probe\n    let defaultProbe: SessionsDiagnostics.Probe\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            let same = current.path == defaultProbe.path\n            Group {\n                Text(same ? \"Current (= Default)\" : \"Current\")\n                    .font(.subheadline).bold()\n                DiagnosticsProbeView(p: current)\n            }\n            if !same {\n                Group {\n                    Text(\"Default\").font(.subheadline).bold().padding(.top, 4)\n                    DiagnosticsProbeView(p: defaultProbe)\n                }\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/DialecticsPane.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct DialecticsPane: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    @StateObject private var vm = DialecticsVM()\n    @StateObject private var permissionsManager = SandboxPermissionsManager.shared\n    @EnvironmentObject private var listViewModel: SessionListViewModel\n    @State private var ripgrepReport: SessionRipgrepStore.Diagnostics?\n    @State private var ripgrepLoading = false\n    @State private var ripgrepRebuilding = false\n    @State private var sessionIndexRebuilding = false\n    @State private var activeRebuildAlert: RebuildAlert?\n\n    enum RebuildAlert: Identifiable {\n        case ripgrepCoverage\n        case sessionIndex\n\n        var id: String {\n            switch self {\n            case .ripgrepCoverage: return \"ripgrepCoverage\"\n            case .sessionIndex: return \"sessionIndex\"\n            }\n        }\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 20) {\n                // App & OS\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Environment\").font(.headline).fontWeight(.semibold)\n                    settingsCard {\n                        Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n                        GridRow {\n                            Text(\"App Version\").font(.subheadline)\n                            Text(vm.appVersion).frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"Build Time\").font(.subheadline)\n                            Text(vm.buildTime).frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"macOS\").font(.subheadline)\n                            Text(vm.osVersion).frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        GridRow {\n                            Text(\"App Sandbox\").font(.subheadline)\n                            Text(vm.sandboxOn ? \"On\" : \"Off\")\n                                .foregroundStyle(vm.sandboxOn ? .green : .secondary)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                        }\n                    }\n                }\n\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Ripgrep Indexes\").font(.headline).fontWeight(.semibold)\n                    settingsCard {\n                        if let report = ripgrepReport {\n                            Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n                                gridRow(label: \"Cached Coverage Entries\", value: \"\\(report.cachedCoverageEntries)\")\n                                gridRow(label: \"Cached Tool Entries\", value: \"\\(report.cachedToolEntries)\")\n                                gridRow(label: \"Cached Token Entries\", value: \"\\(report.cachedTokenEntries)\")\n                                gridRow(label: \"Last Coverage Scan\", value: timestampLabel(report.lastCoverageScan))\n                                gridRow(label: \"Last Tool Scan\", value: timestampLabel(report.lastToolScan))\n                                gridRow(label: \"Last Token Scan\", value: timestampLabel(report.lastTokenScan))\n                            }\n                        } else {\n                            Text(\"Ripgrep stats not loaded yet.\")\n                                .font(.caption)\n                                .foregroundStyle(.secondary)\n                        }\n                        Divider()\n                        HStack(spacing: 12) {\n                            Button {\n                                Task { await refreshRipgrepDiagnostics() }\n                            } label: {\n                                Label(\"Refresh Ripgrep Stats\", systemImage: \"arrow.clockwise\")\n                            }\n                            .buttonStyle(.bordered)\n                            .disabled(ripgrepLoading || ripgrepRebuilding || sessionIndexRebuilding)\n                            if ripgrepLoading || ripgrepRebuilding || sessionIndexRebuilding {\n                                ProgressView().controlSize(.small)\n                            }\n                            Button {\n                                activeRebuildAlert = .ripgrepCoverage\n                            } label: {\n                                Label(\"Rebuild Coverage\", systemImage: \"hammer\")\n                            }\n                            .buttonStyle(.borderedProminent)\n                            .tint(.orange)\n                            .disabled(ripgrepRebuilding || sessionIndexRebuilding)\n\n                            Button {\n                                activeRebuildAlert = .sessionIndex\n                            } label: {\n                                Label(\"Rebuild Session Index\", systemImage: \"arrow.counterclockwise.circle\")\n                            }\n                            .buttonStyle(.bordered)\n                            .tint(.orange)\n                            .disabled(sessionIndexRebuilding || ripgrepRebuilding)\n                        }\n                    }\n                }\n\n                // Sandbox Permissions (only show if sandboxed and missing permissions)\n                if vm.sandboxOn && permissionsManager.needsAuthorization {\n                    VStack(alignment: .leading, spacing: 10) {\n                        HStack {\n                            Image(systemName: \"exclamationmark.triangle.fill\")\n                                .foregroundStyle(.orange)\n                            Text(\"Directory Access Required\")\n                                .font(.headline)\n                                .fontWeight(.semibold)\n                        }\n\n                        settingsCard {\n                            VStack(alignment: .leading, spacing: 16) {\n                                VStack(alignment: .leading, spacing: 8) {\n                                    Text(\"CodMate needs access to the following directories to function properly:\")\n                                        .font(.subheadline)\n                                        .foregroundStyle(.secondary)\n\n                                    // Show actual resolved paths for debugging\n                                    if vm.sandboxOn {\n                                        Text(\"Note: These are the real user directories, not sandbox container paths.\")\n                                            .font(.caption)\n                                            .foregroundStyle(.orange)\n                                            .padding(.vertical, 4)\n                                    }\n                                }\n\n                                ForEach(permissionsManager.missingPermissions) { directory in\n                                    HStack(spacing: 12) {\n                                        Image(systemName: permissionsManager.hasPermission(for: directory) ? \"checkmark.circle.fill\" : \"circle\")\n                                            .foregroundStyle(permissionsManager.hasPermission(for: directory) ? .green : .secondary)\n                                            .font(.system(size: 16))\n\n                                        VStack(alignment: .leading, spacing: 4) {\n                                            Text(directory.displayName)\n                                                .font(.subheadline)\n                                                .fontWeight(.medium)\n                                            Text(directory.description)\n                                                .font(.caption)\n                                                .foregroundStyle(.secondary)\n                                            Text(directory.rawValue)\n                                                .font(.caption2)\n                                                .foregroundStyle(.tertiary)\n                                                .monospaced()\n                                        }\n\n                                        Spacer()\n\n                                        if !permissionsManager.hasPermission(for: directory) {\n                                            Button {\n                                                Task {\n                                                    let granted = await permissionsManager.requestPermission(for: directory)\n                                                    if granted {\n                                                        permissionsManager.checkPermissions()\n                                                    }\n                                                }\n                                            } label: {\n                                                Text(\"Grant Access\")\n                                                    .font(.caption)\n                                            }\n                                            .buttonStyle(.borderedProminent)\n                                            .controlSize(.small)\n                                        }\n                                    }\n                                    .padding(.vertical, 8)\n                                }\n\n                                Divider()\n\n                                HStack {\n                                    Text(\"Click \\\"Grant Access\\\" to select each directory when prompted.\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n\n                                    Spacer()\n\n                                    Button {\n                                        Task {\n                                            _ = await permissionsManager.requestAllMissingPermissions()\n                                        }\n                                    } label: {\n                                        Text(\"Grant All Access\")\n                                    }\n                                    .buttonStyle(.borderedProminent)\n                                    .controlSize(.small)\n                                }\n                            }\n                            .padding(.vertical, 8)\n                        }\n                    }\n                }\n\n                // Codex sessions diagnostics\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Codex Sessions Root\").font(.headline).fontWeight(.semibold)\n                    if let s = vm.sessions {\n                        settingsCard {\n                            DiagnosticsReportView(result: s)\n                                .frame(maxWidth: .infinity, alignment: .topLeading)\n                        }\n                    } else {\n                        Text(\"No data yet. Click Run Diagnostics.\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                }\n\n                // Claude sessions diagnostics (moved above Notes)\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Claude Sessions Directory\").font(.headline).fontWeight(.semibold)\n                    if let s = vm.sessions {\n                        settingsCard {\n                            if let cc = s.claudeCurrent {\n                                DataPairReportView(current: cc, defaultProbe: s.claudeDefault)\n                                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                            } else {\n                                DataPairReportView(current: s.claudeDefault, defaultProbe: s.claudeDefault)\n                                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                            }\n                        }\n                    } else {\n                        Text(\"No data yet. Click Run Diagnostics.\").font(.caption).foregroundStyle(.secondary)\n                    }\n                }\n\n                // Gemini sessions diagnostics\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Gemini Sessions Directory\").font(.headline).fontWeight(.semibold)\n                    if let s = vm.sessions {\n                        settingsCard {\n                            if let gc = s.geminiCurrent {\n                                DataPairReportView(current: gc, defaultProbe: s.geminiDefault)\n                                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                            } else {\n                                DataPairReportView(current: s.geminiDefault, defaultProbe: s.geminiDefault)\n                                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                            }\n                        }\n                    } else {\n                        Text(\"No data yet. Click Run Diagnostics.\").font(.caption).foregroundStyle(.secondary)\n                    }\n                }\n\n                // Notes diagnostics\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Notes Directory\").font(.headline).fontWeight(.semibold)\n                    if let s = vm.sessions {\n                        settingsCard {\n                            DataPairReportView(current: s.notesCurrent, defaultProbe: s.notesDefault)\n                                .frame(maxWidth: .infinity, alignment: .topLeading)\n                        }\n                    } else {\n                        Text(\"No data yet. Click Run Diagnostics.\").font(.caption).foregroundStyle(.secondary)\n                    }\n                }\n\n                // Projects diagnostics\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"Projects Directory\").font(.headline).fontWeight(.semibold)\n                    if let s = vm.sessions {\n                        settingsCard {\n                            DataPairReportView(current: s.projectsCurrent, defaultProbe: s.projectsDefault)\n                                .frame(maxWidth: .infinity, alignment: .topLeading)\n                        }\n                    } else {\n                        Text(\"No data yet. Click Run Diagnostics.\").font(.caption).foregroundStyle(.secondary)\n                    }\n                }\n\n\n                // Removed: Authorization Shortcuts — unify to on-demand authorization in context\n\n                HStack {\n                    Spacer(minLength: 8)\n                    Button {\n                        Task { await vm.runAll(preferences: preferences) }\n                    } label: {\n                        Label(\"Run Diagnostics\", systemImage: \"stethoscope\")\n                    }\n                    .buttonStyle(.bordered)\n                    Button {\n                        vm.saveReport(\n                            preferences: preferences,\n                            ripgrepReport: ripgrepReport,\n                            indexMeta: listViewModel.indexMeta,\n                            cacheCoverage: listViewModel.cacheCoverage\n                        )\n                    } label: {\n                        Label(\"Save Report…\", systemImage: \"square.and.arrow.down\")\n                    }\n                    .buttonStyle(.bordered)\n                }\n            }\n            .task { await vm.runAll(preferences: preferences) }\n            .task { await refreshRipgrepDiagnostics() }\n            .alert(item: $activeRebuildAlert) { alert in\n            switch alert {\n            case .ripgrepCoverage:\n                return Alert(\n                    title: Text(\"Rebuild Ripgrep Coverage?\"),\n                    message: Text(\n                        \"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.\"\n                    ),\n                    primaryButton: .destructive(Text(\"Rebuild\")) {\n                        Task { await rebuildRipgrepIndexes() }\n                    },\n                    secondaryButton: .cancel()\n                )\n            case .sessionIndex:\n                return Alert(\n                    title: Text(\"Rebuild Session Index?\"),\n                    message: Text(\n                        \"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.\"\n                    ),\n                    primaryButton: .destructive(Text(\"Rebuild\")) {\n                        Task { await rebuildSessionIndex() }\n                    },\n                    secondaryButton: .cancel()\n                )\n            }\n        }\n    }\n\n    // Helper function to create settings card\n    @ViewBuilder\n    private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n        VStack(alignment: .leading, spacing: 8) {\n            content()\n        }\n        .padding(10)\n        .background(Color(nsColor: .separatorColor).opacity(0.35))\n        .cornerRadius(10)\n    }\n}\n\nextension DialecticsPane {\n    private func authorizeFolder(_ suggested: URL) {\n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.directoryURL = suggested\n        panel.message = \"Authorize this folder for sandboxed access\"\n        panel.prompt = \"Authorize\"\n        panel.begin { resp in\n            if resp == .OK, let url = panel.url {\n                SecurityScopedBookmarks.shared.saveDynamic(url: url)\n                NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil)\n            }\n        }\n    }\n\n    @ViewBuilder\n    private func gridRow(label: String, value: String) -> some View {\n        GridRow {\n            Text(label).font(.subheadline)\n            Text(value)\n                .font(.caption)\n                .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n    }\n\n    private func timestampLabel(_ date: Date?) -> String {\n        guard let date else { return \"—\" }\n        let formatter = DateFormatter()\n        formatter.dateStyle = .medium\n        formatter.timeStyle = .short\n        return formatter.string(from: date)\n    }\n\n    private func refreshRipgrepDiagnostics() async {\n        await MainActor.run { ripgrepLoading = true }\n        let report = await listViewModel.ripgrepDiagnostics()\n        await MainActor.run {\n            ripgrepReport = report\n            ripgrepLoading = false\n        }\n    }\n\n    private func rebuildRipgrepIndexes() async {\n        await MainActor.run { ripgrepRebuilding = true }\n        await listViewModel.rebuildRipgrepIndexes()\n        await refreshRipgrepDiagnostics()\n        await MainActor.run { ripgrepRebuilding = false }\n    }\n\n    private func rebuildSessionIndex() async {\n        await MainActor.run { sessionIndexRebuilding = true }\n        await listViewModel.rebuildSessionIndex()\n        await refreshRipgrepDiagnostics()\n        await MainActor.run { sessionIndexRebuilding = false }\n    }\n}\n"
  },
  {
    "path": "views/EditSessionMetaView.swift",
    "content": "import SwiftUI\n\nstruct EditSessionMetaView: View {\n    @ObservedObject var viewModel: SessionListViewModel\n    @FocusState private var focusedField: Field?\n\n    enum Field {\n        case title\n        case comment\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 16) {\n            HStack {\n                Text(\"Edit Session\")\n                    .font(.title3).bold()\n                Spacer()\n\n                // Generate button (icon only, transparent background)\n                if let session = viewModel.editingSession {\n                    Button(action: {\n                        Task { @MainActor in\n                            await viewModel.generateTitleAndComment(for: session, force: false)\n                        }\n                    }) {\n                        if viewModel.isGeneratingTitleComment && viewModel.generatingSessionId == session.id {\n                            ProgressView()\n                                .controlSize(.small)\n                                .frame(width: 16, height: 16)\n                        } else {\n                            Image(systemName: \"sparkles\")\n                                .font(.system(size: 16))\n                                .foregroundStyle(.secondary)\n                        }\n                    }\n                    .buttonStyle(.plain)\n                    .help(\"Generate title and comment using AI\")\n                    .disabled(viewModel.isGeneratingTitleComment && viewModel.generatingSessionId == session.id)\n                }\n            }\n\n            TextField(\"Name (optional)\", text: $viewModel.editTitle)\n                .textFieldStyle(.roundedBorder)\n                .focused($focusedField, equals: .title)\n\n            VStack(alignment: .leading, spacing: 8) {\n                Text(\"Comment (optional)\").font(.subheadline)\n                TextEditor(text: $viewModel.editComment)\n                    .font(.body)\n                    .codmatePlainTextEditorStyleIfAvailable()\n                    .scrollContentBackground(.hidden)\n                    .frame(minHeight: 120)\n                    .padding(8) // use outer padding; avoid inner padding that can clip first baseline on macOS\n                    .background(\n                        RoundedRectangle(cornerRadius: 6)\n                            .stroke(Color.gray.opacity(0.2), lineWidth: 1)\n                    )\n                    .focused($focusedField, equals: .comment)\n            }\n\n            HStack {\n                Button(\"Cancel\") { viewModel.cancelEdits() }\n                Spacer()\n                Button(\"Save\") { Task { await viewModel.saveEdits() } }\n                    .keyboardShortcut(.defaultAction)\n            }\n        }\n        .padding(20)\n        .frame(minWidth: 520)\n        .onAppear {\n            // Set focus to title field when view appears\n            focusedField = .title\n        }\n    }\n}\n"
  },
  {
    "path": "views/EditorMenuHelpers.swift",
    "content": "import AppKit\nimport SwiftUI\n\n/// Creates a label with the editor's title and icon\n@ViewBuilder\nfunc editorLabel(for editor: EditorApp) -> some View {\n  Label {\n    Text(editor.title)\n  } icon: {\n    if let icon = editor.menuIcon {\n      Image(nsImage: icon)\n    } else {\n      Image(systemName: \"chevron.left.forwardslash.chevron.right\")\n    }\n  }\n}\n\n@ViewBuilder\nfunc openInEditorMenu(\n  editors: [EditorApp],\n  onOpen: @escaping (EditorApp) -> Void\n) -> some View {\n  if !editors.isEmpty {\n    Menu {\n      ForEach(editors) { editor in\n        Button {\n          onOpen(editor)\n        } label: {\n          editorLabel(for: editor)\n        }\n      }\n    } label: {\n      Label(\"Open in\", systemImage: \"arrow.up.forward.app\")\n    }\n  }\n}\n"
  },
  {
    "path": "views/EmbeddedTerminalView.swift",
    "content": "import SwiftUI\nimport GhosttyKit\nimport CGhostty\n\n/// Embedded Ghostty terminal view\n/// Directly uses TerminalScrollView provided by GhosttyKit\n/// Ghostty runtime is lazy-initialized only when this view appears\nstruct EmbeddedTerminalView: View {\n    let sessionID: String\n    let initialCommands: String\n    let worktreePath: String\n\n    // Lazy-initialize Ghostty only when terminal view is shown\n    @StateObject private var ghosttyApp = Ghostty.App()\n\n    var body: some View {\n        Group {\n            if let ghosttyApp = ghosttyApp.app {\n                GhosttyTerminalViewRepresentable(\n                    sessionID: sessionID,\n                    worktreePath: worktreePath,\n                    initialCommands: initialCommands,\n                    ghosttyApp: ghosttyApp,\n                    appWrapper: self.ghosttyApp\n                )\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n                .onAppear {\n                    NSLog(\"[EmbeddedTerminalView] ghosttyApp is available\")\n                }\n            } else {\n                VStack {\n                    Text(\"Terminal Initializing...\")\n                        .foregroundStyle(.secondary)\n                    ProgressView()\n                }\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n                .onAppear {\n                    NSLog(\"[EmbeddedTerminalView] ghosttyApp is initializing\")\n                }\n            }\n        }\n        .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))\n    }\n}\n\n/// NSViewRepresentable wrapper for Ghostty Terminal\nprivate struct GhosttyTerminalViewRepresentable: NSViewRepresentable {\n    let sessionID: String\n    let worktreePath: String\n    let initialCommands: String\n    let ghosttyApp: ghostty_app_t\n    let appWrapper: Ghostty.App\n\n    func makeNSView(context: Context) -> TerminalScrollView {\n        if let cached = GhosttySessionManager.shared.getScrollView(for: sessionID) {\n            NSLog(\"[GhosttyTerminalViewRepresentable] reusing cached TerminalScrollView for %@\", sessionID)\n            return cached\n        }\n\n        NSLog(\"[GhosttyTerminalViewRepresentable] makeNSView called\")\n        NSLog(\"[GhosttyTerminalViewRepresentable]   worktreePath: %@\", worktreePath)\n        NSLog(\"[GhosttyTerminalViewRepresentable]   initialCommands: %@\", initialCommands)\n\n        // Use a stable paneId based on worktreePath to ensure the same terminal session\n        // is reused when the view is recreated with the same worktreePath\n        let paneId = \"embedded:\\(sessionID)\"\n\n        let terminalView = GhosttyTerminalView(\n            frame: .zero,\n            worktreePath: worktreePath,\n            ghosttyApp: ghosttyApp,\n            appWrapper: appWrapper,\n            paneId: paneId,\n            command: nil\n        )\n        NSLog(\"[GhosttyTerminalViewRepresentable] GhosttyTerminalView created with paneId: %@\", paneId)\n\n        let scrollView = TerminalScrollView(\n            contentSize: CGSize(width: 800, height: 600),\n            surfaceView: terminalView\n        )\n        NSLog(\"[GhosttyTerminalViewRepresentable] TerminalScrollView created\")\n        GhosttySessionManager.shared.setScrollView(scrollView, for: sessionID)\n\n        // Store the initial commands in the coordinator to track changes\n        context.coordinator.pendingCommands = initialCommands.isEmpty ? nil : initialCommands\n        context.coordinator.worktreePath = worktreePath\n        context.coordinator.didInjectInitialCommands = false\n\n        terminalView.onReady = { [weak terminalView, weak coordinator = context.coordinator] in\n            guard let terminalView, let coordinator else { return }\n            guard !coordinator.didInjectInitialCommands else { return }\n            guard let commands = coordinator.pendingCommands, !commands.isEmpty else { return }\n            let trimmed = commands.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { return }\n            coordinator.didInjectInitialCommands = true\n            let payload = commands.hasSuffix(\"\\n\") || commands.hasSuffix(\"\\r\")\n                ? commands\n                : commands + \"\\n\"\n            terminalView.sendText(payload)\n        }\n\n        // Ensure the view is properly retained by setting a non-zero frame\n        // This helps SwiftUI recognize the view as valid\n        scrollView.frame = NSRect(x: 0, y: 0, width: 800, height: 600)\n\n        NSLog(\"[GhosttyTerminalViewRepresentable] View setup complete, frame=%@\", NSStringFromRect(scrollView.frame))\n\n        return scrollView\n    }\n\n    func updateNSView(_ nsView: TerminalScrollView, context: Context) {\n        // Track if this is the first update after view creation\n        let isFirstUpdate = context.coordinator.pendingCommands == nil && context.coordinator.worktreePath.isEmpty\n\n        // Only log if something actually changed to reduce noise\n        let commandsChanged = context.coordinator.pendingCommands != initialCommands\n        let pathChanged = context.coordinator.worktreePath != worktreePath\n\n        if isFirstUpdate {\n            NSLog(\"[GhosttyTerminalViewRepresentable] updateNSView: first update, window=%@\",\n                  nsView.window != nil ? \"YES\" : \"NO\")\n        } else if commandsChanged || pathChanged {\n            NSLog(\"[GhosttyTerminalViewRepresentable] updateNSView: commandsChanged=%@, pathChanged=%@\",\n                  commandsChanged ? \"YES\" : \"NO\", pathChanged ? \"YES\" : \"NO\")\n        }\n\n        // Update coordinator state\n        if commandsChanged {\n            if !context.coordinator.didInjectInitialCommands {\n                context.coordinator.pendingCommands = initialCommands.isEmpty ? nil : initialCommands\n            }\n        }\n        if pathChanged {\n            context.coordinator.worktreePath = worktreePath\n        }\n\n        // Note: We don't recreate the terminal view here because initialCommands and worktreePath\n        // should only be set once when the view is first created. The view will be recreated\n        // by SwiftUI if the id() changes or if makeNSView is called again.\n\n        // Theme updates are managed by Ghostty.App, no manual updates needed\n        // View size updates are handled by TerminalScrollView's layout() method\n        // 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\n    }\n\n    func makeCoordinator() -> Coordinator {\n        Coordinator()\n    }\n\n    class Coordinator {\n        var pendingCommands: String? = nil\n        var worktreePath: String = \"\"\n        var didInjectInitialCommands: Bool = false\n    }\n}\n"
  },
  {
    "path": "views/EquatableContainers.swift",
    "content": "import SwiftUI\n\n// Equatable wrapper to minimize diffs for the Git Review panel when state is unchanged\nstruct EquatableGitChangesContainer: View, Equatable {\n  struct Key: Equatable {\n    var workingDirectoryPath: String\n    var projectDirectoryPath: String?\n    var state: ReviewPanelState\n    var refreshToken: Int\n  }\n\n  static func == (lhs: EquatableGitChangesContainer, rhs: EquatableGitChangesContainer) -> Bool {\n    lhs.key == rhs.key\n  }\n\n  let key: Key\n  let workingDirectory: URL\n  let projectDirectory: URL?\n  let presentation: GitChangesPanel.Presentation\n  // Region layout: combined (default), leftOnly, or rightOnly\n  var regionLayout: GitChangesPanel.RegionLayout = .combined\n  let preferences: SessionPreferencesStore\n  var onRequestAuthorization: (() -> Void)? = nil\n  // Optional external shared VM; when nil, this container owns an internal VM\n  var externalVM: GitChangesViewModel? = nil\n  var refreshToken: Int = 0\n  @Binding var savedState: ReviewPanelState\n\n  @StateObject private var internalVM = GitChangesViewModel()\n\n  var body: some View {\n    let vm = externalVM ?? internalVM\n    GitChangesPanel(\n      workingDirectory: workingDirectory,\n      projectDirectory: projectDirectory,\n      presentation: presentation,\n      regionLayout: regionLayout,\n      preferences: preferences,\n      onRequestAuthorization: onRequestAuthorization,\n      refreshToken: refreshToken,\n      savedState: $savedState,\n      vm: vm\n    )\n  }\n}\n\n// Equatable wrapper for the Usage capsule to reduce AttributeGraph diffs.\nstruct EquatableUsageContainer: View, Equatable {\n  struct UsageDigest: Equatable {\n    var codexUpdatedAt: TimeInterval?\n    var codexAvailability: Int\n    var codexUrgentProgress: Double?\n    var codexUrgentReset: TimeInterval?\n    var codexOrigin: Int\n    var codexStatusHash: Int\n    var claudeUpdatedAt: TimeInterval?\n    var claudeAvailability: Int\n    var claudeUrgentProgress: Double?\n    var claudeUrgentReset: TimeInterval?\n    var claudeOrigin: Int\n    var claudeStatusHash: Int\n    var geminiUpdatedAt: TimeInterval?\n    var geminiAvailability: Int\n    var geminiUrgentProgress: Double?\n    var geminiUrgentReset: TimeInterval?\n    var geminiOrigin: Int\n    var geminiStatusHash: Int\n  }\n\n  static func == (lhs: EquatableUsageContainer, rhs: EquatableUsageContainer) -> Bool {\n    lhs.key == rhs.key\n  }\n\n  let key: UsageDigest\n\n  var snapshots: [UsageProviderKind: UsageProviderSnapshot]\n  var preferences: SessionPreferencesStore\n  @Binding var selectedProvider: UsageProviderKind\n  var onRequestRefresh: (UsageProviderKind) -> Void\n\n  init(\n    snapshots: [UsageProviderKind: UsageProviderSnapshot],\n    preferences: SessionPreferencesStore,\n    selectedProvider: Binding<UsageProviderKind>,\n    onRequestRefresh: @escaping (UsageProviderKind) -> Void\n  ) {\n    self.snapshots = snapshots\n    self.preferences = preferences\n    self._selectedProvider = selectedProvider\n    self.onRequestRefresh = onRequestRefresh\n    self.key = Self.digest(snapshots)\n  }\n\n  var body: some View {\n    UsageStatusControl(\n      snapshots: snapshots,\n      preferences: preferences,\n      selectedProvider: $selectedProvider,\n      onRequestRefresh: onRequestRefresh\n    )\n  }\n\n  private static func digest(_ snapshots: [UsageProviderKind: UsageProviderSnapshot]) -> UsageDigest\n  {\n    func parts(for provider: UsageProviderKind) -> (TimeInterval?, Int, Double?, TimeInterval?, Int, Int) {\n      guard let snap = snapshots[provider] else { return (nil, -1, nil, nil, -1, 0) }\n      let updated = snap.updatedAt?.timeIntervalSinceReferenceDate\n      let availability: Int\n      switch snap.availability {\n      case .ready: availability = 1\n      case .empty: availability = 2\n      case .comingSoon: availability = 3\n      }\n      let urgentMetric = snap.urgentMetric()\n      let urgent = urgentMetric?.progress\n      let urgentReset = urgentMetric?.resetDate?.timeIntervalSinceReferenceDate\n      let origin = snap.origin == .thirdParty ? 1 : 0\n      var hasher = Hasher()\n      if let message = snap.statusMessage {\n        hasher.combine(message)\n      }\n      if let action = snap.action {\n        hasher.combine(action)\n      }\n      let statusHash = hasher.finalize()\n      return (updated, availability, urgent, urgentReset, origin, statusHash)\n    }\n    let cdx = parts(for: .codex)\n    let cld = parts(for: .claude)\n    let gmn = parts(for: .gemini)\n    return UsageDigest(\n      codexUpdatedAt: cdx.0,\n      codexAvailability: cdx.1,\n      codexUrgentProgress: cdx.2,\n      codexUrgentReset: cdx.3,\n      codexOrigin: cdx.4,\n      codexStatusHash: cdx.5,\n      claudeUpdatedAt: cld.0,\n      claudeAvailability: cld.1,\n      claudeUrgentProgress: cld.2,\n      claudeUrgentReset: cld.3,\n      claudeOrigin: cld.4,\n      claudeStatusHash: cld.5,\n      geminiUpdatedAt: gmn.0,\n      geminiAvailability: gmn.1,\n      geminiUrgentProgress: gmn.2,\n      geminiUrgentReset: gmn.3,\n      geminiOrigin: gmn.4,\n      geminiStatusHash: gmn.5\n    )\n  }\n}\n\n// Digest for Sidebar state equality\nstruct SidebarDigest: Equatable {\n  var projectsCount: Int\n  var projectsIdsHash: Int\n  var totalSessionCount: Int\n  var selectedProjectsHash: Int\n  var selectedDaysHash: Int\n  var dateDimensionRaw: Int\n  var monthStartInterval: TimeInterval\n  var calendarCountsHash: Int\n  var enabledDaysHash: Int\n  var visibleAllCount: Int\n  var projectWorkspaceMode: ProjectWorkspaceMode\n}\n\n// Equatable wrapper for the Sidebar content to minimize diffs while keeping\n// the internal view hierarchy (which still uses EnvironmentObject) unchanged.\nstruct EquatableSidebarContainer<Content: View>: View, Equatable {\n  static func == (lhs: EquatableSidebarContainer<Content>, rhs: EquatableSidebarContainer<Content>)\n    -> Bool\n  {\n    lhs.key == rhs.key\n  }\n\n  let key: SidebarDigest\n  let content: () -> Content\n\n  var body: some View { content() }\n}\n"
  },
  {
    "path": "views/ExtensionsImportSheets.swift",
    "content": "import SwiftUI\nimport AppKit\n\nprivate let importSheetPadding: CGFloat = 16\n\nstruct MCPImportSheet: View {\n  @Binding var candidates: [MCPImportCandidate]\n  let isImporting: Bool\n  let statusMessage: String?\n  let title: String\n  let subtitle: String\n  let onCancel: () -> Void\n  let onImport: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Text(title)\n        .font(.title3)\n        .fontWeight(.semibold)\n      Text(subtitle)\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      if isImporting {\n        HStack(spacing: 8) {\n          ProgressView().controlSize(.small)\n          Text(\"Scanning…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 160)\n      } else if candidates.isEmpty {\n        VStack(spacing: 8) {\n          Image(systemName: \"server.rack\")\n            .font(.system(size: 28))\n            .foregroundStyle(.secondary)\n          Text(statusMessage ?? \"No MCP servers found.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 200)\n      } else {\n        List {\n          ForEach($candidates) { $item in\n            VStack(alignment: .leading, spacing: 6) {\n              HStack(alignment: .top, spacing: 8) {\n                Toggle(\"\", isOn: $item.isSelected)\n                  .labelsHidden()\n                  .controlSize(.small)\n                VStack(alignment: .leading, spacing: 4) {\n                  HStack(spacing: 6) {\n                    Text(item.name)\n                      .font(.body.weight(.medium))\n                    Text(item.kind.rawValue)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                  }\n                  if let desc = item.description, !desc.isEmpty {\n                    Text(desc)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                  } else if let url = item.url, !url.isEmpty {\n                    Text(url)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .lineLimit(1)\n                      .truncationMode(.middle)\n                  } else if let cmd = item.command, !cmd.isEmpty {\n                    Text(cmd)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .lineLimit(1)\n                      .truncationMode(.middle)\n                  }\n                  Text(\"Sources: \\(item.sources.joined(separator: \", \"))\")\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                }\n                Spacer(minLength: 0)\n                VStack(alignment: .trailing, spacing: 6) {\n                  Picker(\"\", selection: $item.resolution) {\n                    ForEach(ImportResolutionChoice.allCases) { choice in\n                      Text(choice.title).tag(choice)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.segmented)\n                  .frame(width: 240)\n\n                  if item.resolution == .rename {\n                    TextField(\"New name\", text: $item.renameName)\n                      .textFieldStyle(.roundedBorder)\n                      .frame(maxWidth: 220)\n                  }\n                }\n              }\n\n              if item.hasConflict {\n                Label(\"Already exists in CodMate (default: skip)\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              } else if item.hasNameCollision {\n                Label(\"Duplicate name in import list\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              }\n            }\n            .padding(.vertical, 6)\n            .contextMenu {\n              buildOpenMenu(sourcePaths: item.sourcePaths)\n              buildRevealMenu(sourcePaths: item.sourcePaths)\n            }\n          }\n        }\n        .listStyle(.inset)\n      }\n\n      Spacer(minLength: 0)\n\n      if let statusMessage, !statusMessage.isEmpty {\n        Text(statusMessage)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      HStack {\n        Text(\"Conflicts default to Skip. Review before importing.\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n        Spacer()\n        if candidates.isEmpty && !isImporting {\n          Button(\"Close\") { onCancel() }\n            .buttonStyle(.borderedProminent)\n        } else {\n          Button(\"Cancel\") { onCancel() }\n          Button(\"Import\") { onImport() }\n            .buttonStyle(.borderedProminent)\n            .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty)\n        }\n      }\n    }\n    .padding(importSheetPadding)\n  }\n}\n\nstruct SkillsImportSheet: View {\n  @Binding var candidates: [SkillImportCandidate]\n  let isImporting: Bool\n  let statusMessage: String?\n  let title: String\n  let subtitle: String\n  let onCancel: () -> Void\n  let onImport: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Text(title)\n        .font(.title3)\n        .fontWeight(.semibold)\n      Text(subtitle)\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      if isImporting {\n        HStack(spacing: 8) {\n          ProgressView().controlSize(.small)\n          Text(\"Scanning…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 160)\n      } else if candidates.isEmpty {\n        VStack(spacing: 8) {\n          Image(systemName: \"sparkles\")\n            .font(.system(size: 28))\n            .foregroundStyle(.secondary)\n          Text(statusMessage ?? \"No skills found.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 200)\n      } else {\n        List {\n          ForEach($candidates) { $item in\n            VStack(alignment: .leading, spacing: 6) {\n              HStack(alignment: .top, spacing: 8) {\n                Toggle(\"\", isOn: $item.isSelected)\n                  .labelsHidden()\n                  .controlSize(.small)\n                VStack(alignment: .leading, spacing: 4) {\n                  Text(item.name)\n                    .font(.body.weight(.medium))\n                  if !item.summary.isEmpty {\n                    Text(item.summary)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                  }\n                  Text(\"Sources: \\(item.sources.joined(separator: \", \"))\")\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                }\n                Spacer(minLength: 0)\n              }\n\n              if item.hasConflict {\n                Label(item.conflictDetail ?? \"Already exists in CodMate (default: skip)\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              }\n\n              if item.hasConflict {\n                HStack(spacing: 8) {\n                  Picker(\"\", selection: $item.resolution) {\n                    ForEach(ImportResolutionChoice.allCases) { choice in\n                      Text(choice.title).tag(choice)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.segmented)\n                  .frame(width: 240)\n\n                  if item.resolution == .rename {\n                    TextField(\"New ID\", text: $item.renameId)\n                      .textFieldStyle(.roundedBorder)\n                      .frame(maxWidth: 220)\n                  }\n                }\n              }\n            }\n            .padding(.vertical, 6)\n            .contextMenu {\n              buildOpenMenu(sourcePaths: item.sourcePaths)\n              buildRevealMenu(sourcePaths: item.sourcePaths)\n            }\n          }\n        }\n        .listStyle(.inset)\n      }\n\n      Spacer(minLength: 0)\n\n      if let statusMessage, !statusMessage.isEmpty {\n        Text(statusMessage)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      HStack {\n        Text(\"Conflicts default to Skip. Review before importing.\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n        Spacer()\n        if candidates.isEmpty && !isImporting {\n          Button(\"Close\") { onCancel() }\n            .buttonStyle(.borderedProminent)\n        } else {\n          Button(\"Cancel\") { onCancel() }\n          Button(\"Import\") { onImport() }\n            .buttonStyle(.borderedProminent)\n            .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty)\n        }\n      }\n    }\n    .padding(importSheetPadding)\n  }\n}\n\nstruct CommandsImportSheet: View {\n  @Binding var candidates: [CommandImportCandidate]\n  let isImporting: Bool\n  let statusMessage: String?\n  let title: String\n  let subtitle: String\n  let onCancel: () -> Void\n  let onImport: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Text(title)\n        .font(.title3)\n        .fontWeight(.semibold)\n      Text(subtitle)\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      if isImporting {\n        HStack(spacing: 8) {\n          ProgressView().controlSize(.small)\n          Text(\"Scanning…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 160)\n      } else if candidates.isEmpty {\n        VStack(spacing: 8) {\n          Image(systemName: \"command\")\n            .font(.system(size: 28))\n            .foregroundStyle(.secondary)\n          Text(statusMessage ?? \"No commands found.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 200)\n      } else {\n        List {\n          ForEach($candidates) { $item in\n            VStack(alignment: .leading, spacing: 6) {\n              HStack(alignment: .top, spacing: 8) {\n                Toggle(\"\", isOn: $item.isSelected)\n                  .labelsHidden()\n                  .controlSize(.small)\n                VStack(alignment: .leading, spacing: 4) {\n                  Text(item.name)\n                    .font(.body.weight(.medium))\n                  if !item.description.isEmpty {\n                    Text(item.description)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .lineLimit(2)\n                  }\n                  Text(\"Sources: \\(item.sources.joined(separator: \", \"))\")\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                }\n                Spacer(minLength: 0)\n              }\n\n              if item.hasConflict {\n                Label(\"Already exists in CodMate (default: skip)\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              }\n\n              if item.hasConflict {\n                HStack(spacing: 8) {\n                  Picker(\"\", selection: $item.resolution) {\n                    ForEach(ImportResolutionChoice.allCases) { choice in\n                      Text(choice.title).tag(choice)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.segmented)\n                  .frame(width: 240)\n\n                  if item.resolution == .rename {\n                    TextField(\"New ID\", text: $item.renameId)\n                      .textFieldStyle(.roundedBorder)\n                      .frame(maxWidth: 220)\n                  }\n                }\n              }\n            }\n            .padding(.vertical, 6)\n            .contextMenu {\n              buildOpenMenu(sourcePaths: item.sourcePaths)\n              buildRevealMenu(sourcePaths: item.sourcePaths)\n            }\n          }\n        }\n        .listStyle(.inset)\n      }\n\n      Spacer(minLength: 0)\n\n      if let statusMessage, !statusMessage.isEmpty {\n        Text(statusMessage)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      HStack {\n        Text(\"Conflicts default to Skip. Review before importing.\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n        Spacer()\n        if candidates.isEmpty && !isImporting {\n          Button(\"Close\") { onCancel() }\n            .buttonStyle(.borderedProminent)\n        } else {\n          Button(\"Cancel\") { onCancel() }\n          Button(\"Import\") { onImport() }\n            .buttonStyle(.borderedProminent)\n            .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty)\n        }\n      }\n    }\n    .padding(importSheetPadding)\n  }\n}\n\nstruct HooksImportSheet: View {\n  @Binding var candidates: [HookImportCandidate]\n  let isImporting: Bool\n  let statusMessage: String?\n  let title: String\n  let subtitle: String\n  let onCancel: () -> Void\n  let onImport: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Text(title)\n        .font(.title3)\n        .fontWeight(.semibold)\n      Text(subtitle)\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      if isImporting {\n        HStack(spacing: 8) {\n          ProgressView().controlSize(.small)\n          Text(\"Scanning…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 160)\n      } else if candidates.isEmpty {\n        VStack(spacing: 8) {\n          Image(systemName: \"link\")\n            .font(.system(size: 28))\n            .foregroundStyle(.secondary)\n          Text(statusMessage ?? \"No hooks found.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, minHeight: 200)\n      } else {\n        List {\n          ForEach($candidates) { $item in\n            VStack(alignment: .leading, spacing: 6) {\n              HStack(alignment: .top, spacing: 8) {\n                Toggle(\"\", isOn: $item.isSelected)\n                  .labelsHidden()\n                  .controlSize(.small)\n\n                VStack(alignment: .leading, spacing: 4) {\n                  Text(item.rule.name.isEmpty ? item.rule.event : item.rule.name)\n                    .font(.body.weight(.medium))\n                  Text(summaryText(item.rule))\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .lineLimit(2)\n                  Text(\"Sources: \\(item.sources.sorted().joined(separator: \", \"))\")\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                }\n                Spacer(minLength: 0)\n                VStack(alignment: .trailing, spacing: 6) {\n                  Picker(\"\", selection: $item.resolution) {\n                    ForEach(ImportResolutionChoice.allCases) { choice in\n                      Text(choice.title).tag(choice)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.segmented)\n                  .frame(width: 240)\n\n                  if item.resolution == .rename {\n                    TextField(\"New name\", text: $item.renameName)\n                      .textFieldStyle(.roundedBorder)\n                      .frame(maxWidth: 220)\n                  }\n                }\n              }\n\n              if item.hasConflict {\n                Label(\"Already exists in CodMate (default: skip)\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              } else if item.hasNameCollision {\n                Label(\"Duplicate name in import list\", systemImage: \"exclamationmark.triangle\")\n                  .font(.caption)\n                  .foregroundStyle(.orange)\n              }\n            }\n            .padding(.vertical, 6)\n            .contextMenu {\n              buildOpenMenu(sourcePaths: item.sourcePaths)\n              buildRevealMenu(sourcePaths: item.sourcePaths)\n            }\n          }\n        }\n        .listStyle(.inset)\n      }\n\n      Spacer(minLength: 0)\n\n      if let statusMessage, !statusMessage.isEmpty {\n        Text(statusMessage)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      HStack {\n        Text(\"Conflicts default to Skip. Review before importing.\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n        Spacer()\n        if candidates.isEmpty && !isImporting {\n          Button(\"Close\") { onCancel() }\n            .buttonStyle(.borderedProminent)\n        } else {\n          Button(\"Cancel\") { onCancel() }\n          Button(\"Import\") { onImport() }\n            .buttonStyle(.borderedProminent)\n            .disabled(isImporting || candidates.filter { $0.isSelected }.isEmpty)\n        }\n      }\n    }\n    .padding(importSheetPadding)\n  }\n\n  private func summaryText(_ rule: HookRule) -> String {\n    let event = rule.event.isEmpty ? \"Event\" : rule.event\n    let matcher = rule.matcher?.trimmingCharacters(in: .whitespacesAndNewlines)\n    let cmd = rule.commands.first?.command.trimmingCharacters(in: .whitespacesAndNewlines)\n    let parts = [\n      event,\n      (matcher?.isEmpty == false ? \"matcher: \\(matcher!)\" : nil),\n      (cmd?.isEmpty == false ? cmd : nil),\n      \"\\(rule.commands.count) command(s)\"\n    ].compactMap { $0 }\n    return parts.joined(separator: \" · \")\n  }\n}\n\n@ViewBuilder\nprivate func buildOpenMenu(sourcePaths: [String: String]) -> some View {\n  let editors = EditorApp.installedEditors\n  let sortedSources = sourcePaths.keys.sorted()\n  if sortedSources.isEmpty {\n    EmptyView()\n  } else {\n    Menu {\n      if sortedSources.count == 1, let key = sortedSources.first, let path = sourcePaths[key] {\n        buildEditorEntries(editors: editors, path: path)\n      } else {\n        ForEach(sortedSources, id: \\.self) { key in\n          if let path = sourcePaths[key] {\n            Menu(key) {\n              buildEditorEntries(editors: editors, path: path)\n            }\n          }\n        }\n      }\n    } label: {\n      Label(\"Open in\", systemImage: \"arrow.up.forward.app\")\n    }\n  }\n}\n\n@ViewBuilder\nprivate func buildRevealMenu(sourcePaths: [String: String]) -> some View {\n  let sortedSources = sourcePaths.keys.sorted()\n  if sortedSources.isEmpty {\n    EmptyView()\n  } else if sortedSources.count == 1, let key = sortedSources.first, let path = sourcePaths[key] {\n    Button {\n      revealInFinder(path)\n    } label: {\n      Label(\"Reveal in Finder\", systemImage: \"folder\")\n    }\n  } else {\n    Menu {\n      ForEach(sortedSources, id: \\.self) { key in\n        if let path = sourcePaths[key] {\n          Button(key) {\n            revealInFinder(path)\n          }\n        }\n      }\n    } label: {\n      Label(\"Reveal in Finder\", systemImage: \"folder\")\n    }\n  }\n}\n\n@ViewBuilder\nprivate func buildEditorEntries(editors: [EditorApp], path: String) -> some View {\n  if editors.isEmpty {\n    Button(\"Default App\") { openSourcePath(path) }\n  } else {\n    ForEach(editors) { editor in\n      Button {\n        openSourcePath(path, using: editor)\n      } label: {\n        Label {\n          Text(editor.title)\n        } icon: {\n          if let icon = editor.menuIcon {\n            Image(nsImage: icon)\n              .frame(width: 14, height: 14)\n          } else {\n            Image(systemName: \"chevron.left.forwardslash.chevron.right\")\n          }\n        }\n      }\n    }\n  }\n}\n\nprivate func openSourcePath(_ path: String) {\n  let url = URL(fileURLWithPath: path)\n  NSWorkspace.shared.open(url)\n}\n\nprivate func revealInFinder(_ path: String) {\n  let url = URL(fileURLWithPath: path)\n  NSWorkspace.shared.activateFileViewerSelecting([url])\n}\n\nprivate func openSourcePath(_ path: String, using editor: EditorApp) {\n  // Try CLI command first.\n  if let exe = findExecutableInPath(editor.cliCommand) {\n    let p = Process()\n    p.executableURL = URL(fileURLWithPath: exe)\n    p.arguments = [path]\n    p.standardOutput = Pipe(); p.standardError = Pipe()\n    do {\n      try p.run()\n      return\n    } catch {\n      // Fall through to bundle open.\n    }\n  }\n  if let appURL = editor.appURL {\n    let config = NSWorkspace.OpenConfiguration()\n    config.activates = true\n    NSWorkspace.shared.open([URL(fileURLWithPath: path)], withApplicationAt: appURL, configuration: config, completionHandler: nil)\n    return\n  }\n  openSourcePath(path)\n}\n\nprivate func findExecutableInPath(_ name: String) -> String? {\n  let process = Process()\n  process.executableURL = URL(fileURLWithPath: \"/usr/bin/which\")\n  process.arguments = [name]\n  let pipe = Pipe(); process.standardOutput = pipe; process.standardError = Pipe()\n  do {\n    try process.run()\n    process.waitUntilExit()\n    guard process.terminationStatus == 0 else { return nil }\n    let data = pipe.fileHandleForReading.readDataToEndOfFile()\n    let path = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)\n    return (path?.isEmpty == false) ? path : nil\n  } catch {\n    return nil\n  }\n}\n"
  },
  {
    "path": "views/ExtensionsSettingsView.swift",
    "content": "import SwiftUI\n\nstruct ExtensionsSettingsView: View {\n    @Binding var selectedTab: ExtensionsSettingsTab\n    @ObservedObject var preferences: SessionPreferencesStore\n    var openMCPMateDownload: () -> Void\n    @EnvironmentObject private var wizardGuard: WizardGuard\n    @State private var lastStableTab: ExtensionsSettingsTab = .commands\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            header\n            Group {\n                if #available(macOS 15.0, *) {\n                    TabView(selection: $selectedTab) {\n                        Tab(\"Commands\", systemImage: \"command\", value: ExtensionsSettingsTab.commands) {\n                            SettingsTabContent { CommandsSettingsView(preferences: preferences) }\n                        }\n                        Tab(\"Hooks\", systemImage: \"link\", value: ExtensionsSettingsTab.hooks) {\n                            SettingsTabContent { HooksSettingsView(preferences: preferences) }\n                        }\n                        Tab(\"MCP Servers\", systemImage: \"server.rack\", value: ExtensionsSettingsTab.mcp) {\n                            SettingsTabContent {\n                                MCPServersSettingsPane(\n                                    preferences: preferences,\n                                    openMCPMateDownload: openMCPMateDownload,\n                                    showHeader: false\n                                )\n                            }\n                        }\n                        Tab(\"Skills\", systemImage: \"sparkles\", value: ExtensionsSettingsTab.skills) {\n                            SettingsTabContent { SkillsSettingsView(preferences: preferences) }\n                        }\n                    }\n                } else {\n                    TabView(selection: $selectedTab) {\n                        SettingsTabContent { CommandsSettingsView(preferences: preferences) }\n                            .tabItem { Label(\"Commands\", systemImage: \"command\") }\n                            .tag(ExtensionsSettingsTab.commands)\n\n                        SettingsTabContent { HooksSettingsView(preferences: preferences) }\n                            .tabItem { Label(\"Hooks\", systemImage: \"link\") }\n                            .tag(ExtensionsSettingsTab.hooks)\n\n                        SettingsTabContent {\n                            MCPServersSettingsPane(\n                                preferences: preferences,\n                                openMCPMateDownload: openMCPMateDownload,\n                                showHeader: false\n                            )\n                        }\n                        .tabItem { Label(\"MCP Servers\", systemImage: \"server.rack\") }\n                        .tag(ExtensionsSettingsTab.mcp)\n\n                        SettingsTabContent { SkillsSettingsView(preferences: preferences) }\n                            .tabItem { Label(\"Skills\", systemImage: \"sparkles\") }\n                            .tag(ExtensionsSettingsTab.skills)\n                    }\n                }\n            }\n            .padding(.bottom, 16)\n        }\n        .onAppear { lastStableTab = selectedTab }\n        .onChange(of: selectedTab) { newValue in\n          if wizardGuard.isActive {\n            if newValue != lastStableTab {\n              selectedTab = lastStableTab\n            }\n          } else {\n            lastStableTab = newValue\n          }\n        }\n    }\n\n    private var header: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            Text(\"Extensions Settings\")\n                .font(.title2)\n                .fontWeight(.bold)\n            Text(\"Manage MCP servers, Skills, and Commands across AI CLI providers.\")\n                .font(.subheadline)\n                .foregroundStyle(.secondary)\n        }\n    }\n}\n"
  },
  {
    "path": "views/ExternalTerminalMenuHelpers.swift",
    "content": "import Foundation\n\nfunc externalTerminalOrderedProfiles(includeNone: Bool) -> [ExternalTerminalProfile] {\n  let profiles = ExternalTerminalProfileStore.shared.availableProfiles(includeNone: includeNone)\n  var ordered: [ExternalTerminalProfile] = []\n  if includeNone, let none = profiles.first(where: { $0.isNone }) {\n    ordered.append(none)\n  }\n  if let terminal = profiles.first(where: { $0.isTerminal }) {\n    ordered.append(terminal)\n  }\n  let others = profiles\n    .filter { !$0.isTerminal && !$0.isNone }\n    .sorted {\n      $0.displayTitle.localizedCaseInsensitiveCompare($1.displayTitle) == .orderedAscending\n    }\n  return ordered + others\n}\n\nfunc externalTerminalMenuProfiles() -> [ExternalTerminalProfile] {\n  externalTerminalOrderedProfiles(includeNone: false)\n}\n\nfunc embeddedTerminalProfile() -> ExternalTerminalProfile {\n  ExternalTerminalProfile(\n    id: \"codmate.embedded\",\n    title: \"CodMate\",\n    bundleIdentifiers: [],\n    urlTemplate: nil,\n    supportsCommand: true,\n    supportsDirectory: true,\n    managedByCodMate: true,\n    commandStyle: .standard\n  )\n}\n\nfunc externalTerminalMenuItems(\n  idPrefix: String,\n  titlePrefix: String? = nil,\n  titleSuffix: String? = nil,\n  profiles: [ExternalTerminalProfile]? = nil,\n  action: @escaping (ExternalTerminalProfile) -> Void\n) -> [SplitMenuItem] {\n  let list = profiles ?? externalTerminalMenuProfiles()\n  return list.map { profile in\n    let title = (titlePrefix ?? \"\") + profile.displayTitle + (titleSuffix ?? \"\")\n    let icon = profile.id == \"codmate.embedded\" ? \"macwindow\" : \"terminal\"\n    return SplitMenuItem(\n      id: \"\\(idPrefix)-\\(profile.id)\",\n      kind: .action(title: title, systemImage: icon, run: { action(profile) })\n    )\n  }\n}\n"
  },
  {
    "path": "views/GeminiSettingsView.swift",
    "content": "import SwiftUI\n\nstruct GeminiSettingsView: View {\n  @ObservedObject var vm: GeminiVM\n  @ObservedObject var preferences: SessionPreferencesStore\n  @StateObject private var providerCatalog = UnifiedProviderCatalogModel()\n  @State private var providerModels: [String] = []\n  @State private var lastProviderId: String?\n  @State private var showDisableBlockedAlert = false\n\n  private let docsURL = URL(string: \"https://geminicli.com/docs/cli/settings/\")!\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n      GroupBox {\n        HStack(spacing: 12) {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Enable Gemini CLI\", systemImage: \"power\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Turning this off hides Gemini UI, stops session scans, and makes settings read-only.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n          }\n          Spacer()\n          Toggle(\"\", isOn: geminiEnabledBinding)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n        }\n        .padding(10)\n      }\n      Group {\n        if #available(macOS 15.0, *) {\n          TabView {\n            Tab(\"Provider\", systemImage: \"server.rack\") { providerTab }\n            Tab(\"General\", systemImage: \"gearshape\") { generalTab }\n            Tab(\"Runtime\", systemImage: \"gauge\") { runtimeTab }\n            Tab(\"Sessions\", systemImage: \"folder.badge.gearshape\") { sessionsTab }\n            Tab(\"Model\", systemImage: \"cpu\") { modelTab }\n            Tab(\"Raw Config\", systemImage: \"doc.text\") { rawTab }\n          }\n        } else {\n          TabView {\n            providerTab\n              .tabItem { Label(\"Provider\", systemImage: \"server.rack\") }\n            generalTab\n              .tabItem { Label(\"General\", systemImage: \"gearshape\") }\n            runtimeTab\n              .tabItem { Label(\"Runtime\", systemImage: \"gauge\") }\n            sessionsTab\n              .tabItem { Label(\"Sessions\", systemImage: \"folder.badge.gearshape\") }\n            modelTab\n              .tabItem { Label(\"Model\", systemImage: \"cpu\") }\n            rawTab\n              .tabItem { Label(\"Raw Config\", systemImage: \"doc.text\") }\n          }\n        }\n      }\n      .controlSize(.regular)\n      .disabled(!preferences.cliGeminiEnabled)\n      .opacity(preferences.cliGeminiEnabled ? 1.0 : 0.6)\n    }\n    .padding(.bottom, 16)\n    .task {\n      await vm.loadIfNeeded()\n      await reloadProxyCatalog()\n    }\n    // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode\n    .onChange(of: preferences.oauthProvidersEnabled) { _ in\n      Task { await reloadProxyCatalog() }\n    }\n    .onChange(of: preferences.apiKeyProvidersEnabled) { _ in\n      Task { await reloadProxyCatalog() }\n    }\n    .onChange(of: CLIProxyService.shared.isRunning) { _ in\n      Task { await reloadProxyCatalog() }\n    }\n    .alert(\"At least one CLI must remain enabled.\", isPresented: $showDisableBlockedAlert) {\n      Button(\"OK\", role: .cancel) {}\n    }\n  }\n\n  private var geminiEnabledBinding: Binding<Bool> {\n    Binding(\n      get: { preferences.cliGeminiEnabled },\n      set: { newValue in\n        if preferences.setCLIEnabled(.gemini, enabled: newValue) == false {\n          showDisableBlockedAlert = true\n        }\n      }\n    )\n  }\n\n  private var header: some View {\n    HStack(alignment: .firstTextBaseline) {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(\"Gemini CLI Settings\")\n          .font(.title2)\n          .fontWeight(.bold)\n        Text(\"Configure Gemini CLI defaults: features, models, and raw settings.json.\")\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n      }\n      Spacer()\n      Link(destination: docsURL) {\n        Label(\"Docs\", systemImage: \"questionmark.circle\")\n          .labelStyle(.iconOnly)\n      }\n      .buttonStyle(.plain)\n    }\n  }\n\n  private func reloadProxyCatalog(forceRefresh: Bool = false) async {\n    await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh)\n    normalizeProxySelection()\n  }\n\n  private func normalizeProxySelection() {\n    let normalized = providerCatalog.normalizeProviderId(preferences.geminiProxyProviderId)\n    if normalized != preferences.geminiProxyProviderId {\n      preferences.geminiProxyProviderId = normalized\n    }\n    let providerChanged = lastProviderId != nil && lastProviderId != preferences.geminiProxyProviderId\n    lastProviderId = preferences.geminiProxyProviderId\n    guard let providerId = preferences.geminiProxyProviderId else {\n      providerModels = []\n      preferences.geminiProxyModelId = nil\n      return\n    }\n    providerModels = providerCatalog.models(for: providerId)\n    if providerChanged {\n      preferences.geminiProxyModelId = nil\n      return\n    }\n    guard !providerModels.isEmpty else {\n      return\n    }\n  }\n\n  private var generalTab: some View {\n    SettingsTabContent {\n      Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Preview Features\", systemImage: \"wand.and.stars\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Enable experimental features like preview models.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.previewFeatures)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.previewFeatures) { _ in vm.applyPreviewFeaturesChange() }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Prompt Completion\", systemImage: \"text.cursor\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Show inline command suggestions while typing.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.enablePromptCompletion)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.enablePromptCompletion) { _ in vm.applyPromptCompletionChange() }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Vim Mode\", systemImage: \"keyboard\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Use Vim keybindings inside Gemini CLI.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.vimMode)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.vimMode) { _ in vm.applyVimModeChange() }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Disable Auto Update\", systemImage: \"stop.circle\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Prevent Gemini CLI from auto-updating itself.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.disableAutoUpdate)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.disableAutoUpdate) { _ in vm.applyDisableAutoUpdateChange() }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Session Retention\", systemImage: \"trash\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Automatically clean up old sessions when enabled.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.sessionRetentionEnabled)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.sessionRetentionEnabled) { _ in vm.applySessionRetentionChange() }\n        }\n        if let error = vm.lastError {\n          dividerRow\n          GridRow {\n            Text(\"\")\n            Text(error)\n              .font(.caption)\n              .foregroundStyle(.red)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n        }\n      }\n    }\n  }\n\n  private var sessionsTab: some View {\n    SettingsTabContent {\n      SessionsPathPane(preferences: preferences, fixedKind: .gemini)\n    }\n  }\n\n  private var providerTab: some View {\n    SettingsTabContent {\n      Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Active Provider\", systemImage: \"server.rack\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Use built-in provider or route through CLI Proxy API.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          SimpleProviderPicker(providerId: $preferences.geminiProxyProviderId)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: preferences.geminiProxyProviderId) { _ in\n              normalizeProxySelection()\n              if preferences.geminiProxyProviderId == nil {\n                Task { await reloadProxyCatalog(forceRefresh: true) }\n              }\n            }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Model List\", systemImage: \"list.bullet\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Select a default model from the available models.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          SimpleModelPicker(\n            models: providerModels,\n            isDisabled: preferences.geminiProxyProviderId == nil\n              || !providerCatalog.isProviderAvailable(preferences.geminiProxyProviderId),\n            providerId: preferences.geminiProxyProviderId,\n            providerCatalog: providerCatalog,\n            modelId: $preferences.geminiProxyModelId\n          )\n          .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n      }\n    }\n  }\n\n  private var runtimeTab: some View {\n    SettingsTabContent {\n      Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Sandbox Mode\", systemImage: \"lock.shield\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Controls Gemini CLI sandbox defaults for new sessions.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Picker(\"\", selection: $preferences.defaultResumeSandboxMode) {\n            ForEach(SandboxMode.allCases) { Text($0.title).tag($0) }\n          }\n          .labelsHidden()\n          .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Approval Policy\", systemImage: \"hand.raised\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Set the default automation level when launching Gemini CLI.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Picker(\"\", selection: $preferences.defaultResumeApprovalPolicy) {\n            ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) }\n          }\n          .labelsHidden()\n          .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n      }\n    }\n  }\n\n  private var modelTab: some View {\n    SettingsTabContent {\n      Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Model\", systemImage: \"cpu\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Choose the model alias to use when launching Gemini CLI.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Picker(\"\", selection: $vm.selectedModelId) {\n            ForEach(vm.modelOptions) { option in\n              Text(option.title).tag(option.value)\n            }\n          }\n          .labelsHidden()\n          .frame(maxWidth: .infinity, alignment: .trailing)\n          .onChange(of: vm.selectedModelId) { _ in vm.applyModelSelectionChange() }\n        }\n        if let selection = vm.selectedModelId,\n          let descriptor = vm.modelOptions.first(where: { $0.value == selection })?.subtitle\n        {\n          GridRow {\n            Text(\"\")\n            Text(descriptor)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n        } else if let descriptor = vm.modelOptions.first(where: { $0.value == nil })?.subtitle {\n          GridRow {\n            Text(\"\")\n            Text(descriptor)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Max Session Turns\", systemImage: \"arrow.counterclockwise\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Number of turns kept in memory (-1 keeps everything).\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Stepper(value: $vm.maxSessionTurns, in: -1...10_000, step: 1) {\n            Text(vm.maxSessionTurns < 0 ? \"Unlimited (-1)\" : \"\\(vm.maxSessionTurns)\")\n          }\n          .frame(maxWidth: .infinity, alignment: .trailing)\n          .onChange(of: vm.maxSessionTurns) { _ in vm.applyMaxSessionTurnsChange() }\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Compression Threshold\", systemImage: \"arrow.down.circle\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Fraction of context usage that triggers compression.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          VStack(alignment: .trailing, spacing: 6) {\n            Slider(value: $vm.compressionThreshold, in: 0...1, step: 0.05)\n              .frame(maxWidth: 240)\n              .onChange(of: vm.compressionThreshold) { _ in vm.applyCompressionThresholdChange() }\n            Text(\"\\(vm.compressionThreshold, format: .number.precision(.fractionLength(2)))\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n          }\n          .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n        dividerRow\n        GridRow {\n          VStack(alignment: .leading, spacing: 2) {\n            Label(\"Skip Next Speaker Check\", systemImage: \"checkmark.circle.badge.xmark\")\n              .font(.subheadline).fontWeight(.medium)\n            Text(\"Bypass the next speaker role verification step.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          Toggle(\"\", isOn: $vm.skipNextSpeakerCheck)\n            .labelsHidden()\n            .toggleStyle(.switch)\n            .controlSize(.small)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n            .onChange(of: vm.skipNextSpeakerCheck) { _ in vm.applySkipNextSpeakerChange() }\n        }\n        if let error = vm.lastError {\n          dividerRow\n          GridRow {\n            Text(\"\")\n            Text(error)\n              .font(.caption)\n              .foregroundStyle(.red)\n              .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n        }\n      }\n    }\n  }\n\n  private var rawTab: some View {\n    SettingsTabContent {\n      ZStack(alignment: .topTrailing) {\n        ScrollView {\n          Text(vm.rawSettingsText.isEmpty ? \"(settings.json not found or empty)\" : vm.rawSettingsText)\n            .font(.system(.caption, design: .monospaced))\n            .textSelection(.enabled)\n            .frame(maxWidth: .infinity, alignment: .topLeading)\n        }\n        HStack(spacing: 8) {\n          Button {\n            Task { await vm.refreshSettings(); await vm.reloadRawSettings() }\n          } label: {\n            Image(systemName: \"arrow.clockwise\")\n          }\n          .help(\"Reload settings\")\n          .buttonStyle(.borderless)\n          Button {\n            vm.openSettingsInEditor()\n          } label: {\n            Image(systemName: \"square.and.pencil\")\n          }\n          .help(\"Reveal settings.json\")\n          .buttonStyle(.borderless)\n        }\n      }\n    }\n  }\n\n  @ViewBuilder\n  private var dividerRow: some View {\n    GridRow { Divider().gridCellColumns(2) }\n  }\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Browser.swift",
    "content": "import SwiftUI\n#if canImport(AppKit)\nimport AppKit\n#endif\n\nextension GitChangesPanel {\n    struct BrowserRow: Identifiable {\n        let node: FileNode\n        let depth: Int\n\n        var id: String {\n            if let dir = node.dirPath { return \"dir:\\(dir)\" }\n            if let file = node.fullPath { return \"file:\\(file)\" }\n            return \"node:\\(node.name)-\\(depth)\"\n        }\n\n        var directoryKey: String? { node.dirPath }\n        var filePath: String? { node.fullPath }\n    }\n\n    var browserTreeView: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            if isLoadingBrowserTree {\n                HStack(spacing: 8) {\n                    ProgressView()\n                    Text(\"Loading repository…\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                }\n                .padding(.vertical, 6)\n            } else if let error = browserTreeError {\n                VStack(spacing: 8) {\n                    Text(error)\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .multilineTextAlignment(.leading)\n                    HStack {\n                        Button(\"Retry\") { requestBrowserTreeReload(force: true) }\n                        if let action = onRequestAuthorization {\n                            Button(\"Authorize Repository Folder…\") { action() }\n                        }\n                    }\n                    .controlSize(.small)\n                }\n                .padding(.vertical, 6)\n            } else if displayedBrowserRows.isEmpty {\n                let message = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n                    ? \"No files in repository.\"\n                    : \"No matches.\"\n                Text(message)\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .padding(.vertical, 6)\n            } else {\n                LazyVStack(alignment: .leading, spacing: 0) {\n                    ForEach(displayedBrowserRows) { row in\n                        browserRow(row)\n                    }\n                }\n            }\n            if browserTreeTruncated {\n                Text(\"Showing first \\(browserEntryLimit) entries. Use search to narrow results.\")\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                    .padding(.top, 6)\n            }\n            if !isLoadingBrowserTree, browserTreeError == nil, browserTotalEntries > 0 {\n                Text(\"\\(browserTotalEntries)\\(browserTreeTruncated ? \"+\" : \"\") items\")\n                    .font(.caption2)\n                    .foregroundStyle(.tertiary)\n            }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .padding(.vertical, 2)\n    }\n\n    @ViewBuilder\n    private func browserRow(_ row: BrowserRow) -> some View {\n        if row.node.isDirectory {\n            browserDirectoryRow(row)\n        } else {\n            browserFileRow(row)\n        }\n    }\n\n    private func browserDirectoryRow(_ row: BrowserRow) -> some View {\n        let key = row.directoryKey ?? row.node.name\n        let repoAvailable = vm.repoRoot != nil\n        let indent = CGFloat(max(row.depth, 0)) * indentStep\n        let isExpanded = !treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || expandedDirsBrowser.contains(key)\n        return HStack(spacing: 0) {\n            ZStack(alignment: .leading) {\n                Color.clear.frame(width: indent + chevronWidth)\n                let guideColor = Color.secondary.opacity(0.15)\n                if row.depth > 0 {\n                    ForEach(0..<row.depth, id: \\.self) { idx in\n                        Rectangle()\n                            .fill(guideColor)\n                            .frame(width: 1)\n                            .offset(x: CGFloat(idx) * indentStep + chevronWidth / 2)\n                    }\n                }\n                HStack(spacing: 0) {\n                    Spacer().frame(width: indent)\n                    Image(systemName: isExpanded ? \"chevron.down\" : \"chevron.right\")\n                        .font(.system(size: 11))\n                        .foregroundStyle(.secondary)\n                        .frame(width: chevronWidth, height: 20)\n                }\n            }\n            HStack(spacing: 6) {\n                Image(systemName: \"folder\")\n                    .font(.system(size: 13))\n                    .foregroundStyle(.secondary)\n                Text(row.node.name)\n                    .font(.system(size: 13))\n                    .lineLimit(1)\n                Spacer(minLength: 0)\n            }\n            .padding(.trailing, trailingPad)\n        }\n        .frame(height: 22)\n        .contentShape(Rectangle())\n        .background(\n            RoundedRectangle(cornerRadius: 4)\n                .fill((hoverBrowserDirKey == key) ? Color.secondary.opacity(0.06) : Color.clear)\n        )\n        .onTapGesture {\n            toggleBrowserDirectory(key)\n        }\n        .onHover { inside in\n            if inside {\n                hoverBrowserDirKey = key\n            } else if hoverBrowserDirKey == key {\n                hoverBrowserDirKey = nil\n            }\n        }\n        .contextMenu {\n            Button {\n                toggleBrowserDirectory(key)\n            } label: {\n                Label(isExpanded ? \"Collapse\" : \"Expand\", systemImage: isExpanded ? \"chevron.down\" : \"chevron.right\")\n            }\n            let paths = filePaths(under: key)\n            if repoAvailable, !paths.isEmpty {\n                Button {\n                    Task { await vm.stage(paths: paths) }\n                } label: {\n                    Label(\"Stage Folder\", systemImage: \"plus.circle\")\n                }\n                Button {\n                    Task { await vm.unstage(paths: paths) }\n                } label: {\n                    Label(\"Unstage Folder\", systemImage: \"minus.circle\")\n                }\n            }\n#if canImport(AppKit)\n            Button {\n                revealBrowserItem(path: key, isDirectory: true)\n            } label: {\n                Label(\"Reveal in Finder\", systemImage: \"finder\")\n            }\n#endif\n            Divider()\n            Button {\n                Task {\n                    await vm.refreshStatus()\n                    await MainActor.run { requestBrowserTreeReload(force: true) }\n                }\n            } label: {\n                Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n            }\n        }\n    }\n\n    private func browserFileRow(_ row: BrowserRow) -> some View {\n        Group {\n            if let path = row.filePath {\n                browserFileRowContent(path: path, row: row)\n            } else {\n                EmptyView()\n            }\n        }\n    }\n    \n    @ViewBuilder\n    private func browserFileRowContent(path: String, row: BrowserRow) -> some View {\n        let indent = CGFloat(max(row.depth, 0)) * indentStep\n        let change = vm.changes.first { $0.path == path }\n        let repoAvailable = vm.repoRoot != nil\n        let isSelected = vm.selectedPath == path\n        let bulletColor = change.map { statusColor(for: $0.path) } ?? Color.clear\n        // Explorer overlay: do not show Stage/Unstage quick actions; keep them in context menus only\n        let showStageAction = false\n        let activeHover = hoverBrowserFilePath == path\n        let buttonCount: Int = {\n            var count = 1 // Open\n#if canImport(AppKit)\n            count += 1 // Reveal\n#endif\n            if showStageAction { count += 1 }\n            return count\n        }()\n        let actionWidth = CGFloat(buttonCount) * quickActionWidth + CGFloat(max(buttonCount - 1, 0)) * hoverButtonSpacing\n        HStack(spacing: 0) {\n            ZStack(alignment: .leading) {\n                Color.clear.frame(width: indent)\n                if row.depth > 0 {\n                    let guideColor = Color.secondary.opacity(0.15)\n                    ForEach(0..<row.depth, id: \\.self) { idx in\n                        Rectangle()\n                            .fill(guideColor)\n                            .frame(width: 1)\n                            .offset(x: CGFloat(idx) * indentStep - indentStep / 2)\n                    }\n                }\n            }\n            .frame(width: indent)\n            HStack(spacing: 6) {\n                if change != nil {\n                    Circle()\n                        .fill(bulletColor.opacity(0.8))\n                        .frame(width: 6, height: 6)\n                } else {\n                    Circle()\n                        .fill(Color.clear)\n                        .frame(width: 6, height: 6)\n                }\n                let icon = fileTypeIconName(for: path)\n                Image(systemName: icon.name)\n                    .font(.system(size: 12))\n                    .foregroundStyle(icon.color)\n                Text(row.node.name)\n                    .font(.system(size: 13))\n                    .lineLimit(1)\n                Spacer(minLength: 0)\n            }\n            .padding(.trailing, activeHover ? (actionWidth + trailingPad + (change != nil ? statusBadgeWidth : 0)) : (trailingPad + (change != nil ? statusBadgeWidth : 0)))\n            .overlay(alignment: .trailing) {\n                HStack(spacing: hoverButtonSpacing) {\n                    if activeHover {\n                        Button {\n                            let editor = preferences.defaultFileEditor\n                            if EditorApp.installedEditors.contains(editor) {\n                                vm.openFile(path, using: editor)\n                            } else {\n                                let full = vm.repoRoot?.appendingPathComponent(path).path ?? path\n                                NSWorkspace.shared.open(URL(fileURLWithPath: full))\n                            }\n                        } label: {\n                            Image(systemName: \"square.and.pencil\")\n                                .foregroundStyle((hoverBrowserEditPath == path) ? Color.accentColor : Color.secondary)\n                        }\n                        .buttonStyle(.plain)\n                        .frame(width: quickActionWidth, height: quickActionHeight)\n                        .onHover { inside in\n                            if inside {\n                                hoverBrowserEditPath = path\n                            } else if hoverBrowserEditPath == path {\n                                hoverBrowserEditPath = nil\n                            }\n                        }\n#if canImport(AppKit)\n                        Button {\n                            revealBrowserItem(path: path, isDirectory: false)\n                        } label: {\n                            Image(systemName: \"finder\")\n                                .foregroundStyle((hoverBrowserRevealPath == path) ? Color.accentColor : Color.secondary)\n                        }\n                        .buttonStyle(.plain)\n                        .frame(width: quickActionWidth, height: quickActionHeight)\n                        .onHover { inside in\n                            if inside {\n                                hoverBrowserRevealPath = path\n                            } else if hoverBrowserRevealPath == path {\n                                hoverBrowserRevealPath = nil\n                            }\n                        }\n#endif\n                    }\n                    if repoAvailable, let change {\n                        statusBadge(for: change)\n                            .frame(height: quickActionHeight)\n                    }\n                }\n            }\n        }\n        .frame(height: 22)\n        .contentShape(Rectangle())\n        .background(\n            RoundedRectangle(cornerRadius: 4)\n                .fill(isSelected ? Color.accentColor.opacity(0.15) : (activeHover ? Color.secondary.opacity(0.06) : Color.clear))\n        )\n        .onTapGesture {\n            handleBrowserSelection(path: path)\n        }\n        .onHover { inside in\n            if inside {\n                hoverBrowserFilePath = path\n            } else if hoverBrowserFilePath == path {\n                hoverBrowserFilePath = nil\n            }\n        }\n        .contextMenu {\n            let editors = EditorApp.installedEditors\n            openInEditorMenu(editors: editors) { editor in\n                vm.openFile(path, using: editor)\n            }\n#if canImport(AppKit)\n            Button {\n                revealBrowserItem(path: path, isDirectory: false)\n            } label: {\n                Label(\"Reveal in Finder\", systemImage: \"finder\")\n            }\n#endif\n            Divider()\n            Button {\n                Task {\n                    await vm.refreshStatus()\n                    await MainActor.run { requestBrowserTreeReload(force: true) }\n                }\n            } label: {\n                Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n            }\n            if repoAvailable, let change {\n                if change.staged != nil {\n                    Button {\n                        Task { await vm.unstage(paths: [path]) }\n                    } label: {\n                        Label(\"Unstage File\", systemImage: \"minus.circle\")\n                    }\n                } else {\n                    Button {\n                        Task { await vm.stage(paths: [path]) }\n                    } label: {\n                        Label(\"Stage File\", systemImage: \"plus.circle\")\n                    }\n                }\n            } else if repoAvailable {\n                Button {\n                    Task { await vm.stage(paths: [path]) }\n                } label: {\n                    Label(\"Stage File\", systemImage: \"plus.circle\")\n                }\n            }\n        }\n        .onTapGesture(count: 2) {\n            let editor = preferences.defaultFileEditor\n            if EditorApp.installedEditors.contains(editor) {\n                vm.openFile(path, using: editor)\n            } else {\n                let full = vm.repoRoot?.appendingPathComponent(path).path ?? path\n                NSWorkspace.shared.open(URL(fileURLWithPath: full))\n            }\n        }\n    }\n\n    func reloadBrowserTreeIfNeeded(force: Bool = false) {\n        requestBrowserTreeReload(force: force)\n    }\n\n    func requestBrowserTreeReload(force: Bool = false) {\n        guard mode == .browser else {\n            browserTreeTask?.cancel()\n            browserTreeTask = nil\n            return\n        }\n        if !force {\n            if isLoadingBrowserTree { return }\n            if !browserNodes.isEmpty && browserTreeError == nil { return }\n        }\n        let root = vm.repoRoot ?? explorerRoot\n        if !FileManager.default.fileExists(atPath: root.path) {\n            browserNodes = []\n            displayedBrowserRows = []\n            browserTreeError = \"Explorer root unavailable.\"\n            return\n        }\n\n        browserTreeTask?.cancel()\n        isLoadingBrowserTree = true\n        browserTreeError = nil\n\n        let limit = browserEntryLimit\n        let viewModel = vm\n        let repoAvailable = vm.repoRoot != nil\n        browserTreeTask = Task {\n            let gitResult = repoAvailable ? await viewModel.listVisiblePaths(limit: limit) : nil\n            if Task.isCancelled {\n                await MainActor.run {\n                    browserTreeTask = nil\n                    isLoadingBrowserTree = false\n                }\n                return\n            }\n            let loadResult: (nodes: [FileNode], truncated: Bool, total: Int, error: String?)\n            if let gitResult {\n                let nodes = buildBrowserTreeFromPaths(gitResult.paths)\n                loadResult = (nodes, gitResult.truncated, gitResult.paths.count, nil)\n            } else {\n                let fallback = buildBrowserTreeFromFileSystem(root: root, limit: limit)\n                loadResult = (fallback.nodes, fallback.truncated, fallback.total, fallback.error)\n            }\n            if Task.isCancelled {\n                await MainActor.run {\n                    browserTreeTask = nil\n                    isLoadingBrowserTree = false\n                }\n                return\n            }\n            await MainActor.run {\n                browserTreeTask = nil\n                isLoadingBrowserTree = false\n                if let error = loadResult.error, loadResult.nodes.isEmpty {\n                    browserTreeError = error\n                    browserNodes = []\n                    displayedBrowserRows = []\n                    browserTreeTruncated = false\n                    browserTotalEntries = 0\n                } else {\n                    browserTreeError = nil\n                    browserNodes = GitReviewTreeBuilder.explorerSort(loadResult.nodes)\n                    browserTreeTruncated = loadResult.truncated\n                    browserTotalEntries = loadResult.total\n                    rebuildBrowserDisplayed()\n                }\n            }\n        }\n    }\n\n    func rebuildBrowserDisplayed() {\n        let query = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n        let matches = query.isEmpty ? Set<String>() : contentSearchMatches\n        let filtered = query.isEmpty\n            ? browserNodes\n            : filteredNodes(browserNodes, query: query, contentMatches: matches)\n        displayedBrowserRows = flattenBrowserNodes(filtered, depth: 0, forceExpand: !query.isEmpty)\n    }\n\n    private func flattenBrowserNodes(_ nodes: [FileNode], depth: Int, forceExpand: Bool) -> [BrowserRow] {\n        var rows: [BrowserRow] = []\n        for node in nodes {\n            let row = BrowserRow(node: node, depth: depth)\n            rows.append(row)\n            if node.isDirectory, let key = node.dirPath ?? (depth == 0 ? node.name : nil) {\n                if forceExpand || expandedDirsBrowser.contains(key) {\n                    let children = GitReviewTreeBuilder.explorerSort(node.children ?? [])\n                    rows.append(contentsOf: flattenBrowserNodes(children, depth: depth + 1, forceExpand: forceExpand))\n                }\n            }\n        }\n        return rows\n    }\n\n    private func toggleBrowserDirectory(_ key: String) {\n        if expandedDirsBrowser.contains(key) {\n            expandedDirsBrowser.remove(key)\n        } else {\n            expandedDirsBrowser.insert(key)\n        }\n        rebuildBrowserDisplayed()\n    }\n\n    private func buildBrowserTreeFromPaths(_ paths: [String]) -> [FileNode] {\n        struct Builder {\n            var children: [String: Builder] = [:]\n            var filePath: String? = nil\n        }\n        var root = Builder()\n        for path in paths {\n            guard !path.isEmpty else { continue }\n            let components = path.split(separator: \"/\").map(String.init)\n            guard !components.isEmpty else { continue }\n            func insert(_ index: Int, current: inout Builder) {\n                let key = components[index]\n                if index == components.count - 1 {\n                    var child = current.children[key, default: Builder()]\n                    child.filePath = path\n                    current.children[key] = child\n                } else {\n                    var child = current.children[key, default: Builder()]\n                    insert(index + 1, current: &child)\n                    current.children[key] = child\n                }\n            }\n            insert(0, current: &root)\n        }\n        func convert(_ builder: Builder, prefix: String?) -> [FileNode] {\n            var nodes: [FileNode] = []\n            for (name, child) in builder.children {\n                let fullPath = prefix.map { \"\\($0)/\\(name)\" } ?? name\n                if let filePath = child.filePath, child.children.isEmpty {\n                    nodes.append(FileNode(name: name, fullPath: filePath, dirPath: nil, children: nil))\n                } else {\n                    let childrenNodes = convert(child, prefix: fullPath)\n                    nodes.append(FileNode(name: name, fullPath: nil, dirPath: fullPath, children: GitReviewTreeBuilder.explorerSort(childrenNodes)))\n                }\n            }\n            return GitReviewTreeBuilder.explorerSort(nodes)\n        }\n        return convert(root, prefix: nil)\n    }\n\n    private func buildBrowserTreeFromFileSystem(root: URL, limit: Int) -> (nodes: [FileNode], truncated: Bool, total: Int, error: String?) {\n        let (paths, truncated, error) = collectFileSystemPaths(root: root, limit: limit)\n        if paths.isEmpty {\n            return ([], truncated, 0, error ?? \"Unable to enumerate repository contents.\")\n        }\n        let nodes = buildBrowserTreeFromPaths(paths)\n        return (nodes, truncated, paths.count, error)\n    }\n\n    private func collectFileSystemPaths(root: URL, limit: Int) -> ([String], Bool, String?) {\n        let fm = FileManager.default\n        let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey]\n        var encounteredError: String?\n        let options: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants]\n        guard let enumerator = fm.enumerator(at: root, includingPropertiesForKeys: keys, options: options, errorHandler: { url, error in\n            encounteredError = error.localizedDescription\n            return true\n        }) else {\n            return ([], false, \"Unable to enumerate repository contents.\")\n        }\n\n        let base = root.path + \"/\"\n        var collected: [String] = []\n        var truncated = false\n\n        while let item = enumerator.nextObject() as? URL {\n            let path = item.path\n            guard path.hasPrefix(base) else { continue }\n            let relative = String(path.dropFirst(base.count))\n            if relative.isEmpty { continue }\n            if relative == \".git\" || relative.hasPrefix(\".git/\") {\n                enumerator.skipDescendants()\n                continue\n            }\n            if let values = try? item.resourceValues(forKeys: Set(keys)), values.isDirectory == true {\n                continue\n            }\n            collected.append(relative)\n            if collected.count >= limit {\n                truncated = true\n                break\n            }\n        }\n        return (collected, truncated, encounteredError)\n    }\n\n    private func handleBrowserSelection(path: String) {\n#if canImport(AppKit)\n        previewImageTask?.cancel()\n        previewImage = nil\n#endif\n        vm.selectedPath = path\n        if let change = vm.changes.first(where: { $0.path == path }) {\n            if change.worktree != nil {\n                vm.selectedSide = .unstaged\n            } else {\n                vm.selectedSide = .staged\n            }\n            vm.showPreviewInsteadOfDiff = mode == .browser ? true : isImagePath(path)\n        } else {\n            vm.selectedSide = .unstaged\n            vm.showPreviewInsteadOfDiff = true\n        }\n        Task {\n            await vm.refreshDetail()\n#if canImport(AppKit)\n            loadPreviewImageIfNeeded()\n#endif\n        }\n    }\n\n#if canImport(AppKit)\n    private func revealBrowserItem(path: String, isDirectory: Bool) {\n        revealInFinder(path: path, isDirectory: isDirectory)\n    }\n#endif\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Detail.swift",
    "content": "import SwiftUI\n#if canImport(AppKit)\nimport AppKit\n#endif\n\nextension GitChangesPanel {\n    // MARK: - Detail view (diff/preview pane)\n    var detailView: some View {\n        detailContainer {\n            if mode == .graph {\n                graphDetailView\n            } else if mode != .diff, let path = vm.selectedPath, isImagePath(path) {\n                // In Explorer mode, show rich preview for images\n                imagePreviewContent\n            } else {\n                // In Diff mode, always render the diff reader (no preview switch)\n                let isDiff = (mode == .diff) ? true : !vm.showPreviewInsteadOfDiff\n                let emptyText: String = {\n                    if mode == .diff {\n                        return vm.selectedPath == nil ? \"Select a file to view diff.\" : \"(No diff)\"\n                    } else {\n                        return vm.selectedPath == nil ? \"Select a file to view preview/diff.\" : (vm.showPreviewInsteadOfDiff ? \"(Empty preview)\" : \"(No diff)\")\n                    }\n                }()\n                AttributedTextView(\n                    text: vm.diffText.isEmpty ? emptyText : vm.diffText,\n                    isDiff: isDiff,\n                    wrap: wrapText,\n                    showLineNumbers: showLineNumbers,\n                    fontSize: 12,\n                    searchQuery: (mode == .diff || mode == .browser) ? headerSearchQuery : \"\"\n                )\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n            }\n        }\n        .id(\"detail:\\(vm.selectedPath ?? \"-\")|\\(vm.selectedSide == .staged ? \"s\" : \"u\")|\\(vm.showPreviewInsteadOfDiff ? \"p\" : \"d\")|wrap:\\(wrapText ? 1 : 0)|ln:\\(showLineNumbers ? 1 : 0)\")\n        .task(id: vm.selectedPath) {\n            await vm.refreshDetail()\n            loadPreviewImageIfNeeded()\n        }\n        .task(id: vm.selectedSide) { await vm.refreshDetail() }\n        .task(id: vm.showPreviewInsteadOfDiff) {\n            await vm.refreshDetail()\n            loadPreviewImageIfNeeded()\n        }\n    }\n\n    // MARK: - Commit box (legacy, for .full presentation)\n    var commitBox: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            Text(\"Commit\")\n                .font(.subheadline)\n                .foregroundStyle(.secondary)\n            if presentation == .full {\n                // Clamp editor height between 1 and 10 lines (≈20pt/line)\n                let line: CGFloat = 20\n                let minH: CGFloat = line\n                let maxH: CGFloat = line * 10\n                VStack(alignment: .leading, spacing: 6) {\n                    TextEditor(text: $vm.commitMessage)\n                        .font(.system(.body))\n                        .codmatePlainTextEditorStyleIfAvailable()\n                        .frame(minHeight: minH)\n                        .frame(height: min(maxH, max(minH, commitEditorHeight)))\n                        .padding(6)\n                        .overlay(\n                            RoundedRectangle(cornerRadius: 6)\n                                .stroke(Color.secondary.opacity(0.25))\n                        )\n                    // Drag handle adjusts preferred editor height within bounds\n                    Rectangle()\n                        .fill(Color.clear)\n                        .frame(height: 6)\n                        .gesture(DragGesture().onChanged { value in\n                            let nh = max(minH, min(maxH, commitEditorHeight + value.translation.height))\n                            commitEditorHeight = nh\n                        })\n                    HStack {\n                        Spacer()\n                        Button(\"Commit\") { showCommitConfirm = true }\n                            .disabled(vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n                    }\n                }\n            } else {\n                HStack(spacing: 6) {\n                    TextField(\"Press Command+Return to commit\", text: $vm.commitMessage)\n                    Button(\"Commit\") { showCommitConfirm = true }\n                        .disabled(vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n                }\n            }\n        }\n        .padding(8)\n        .background(\n            Group {\n                if presentation == .embedded {\n                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                        .fill(Color(nsColor: .underPageBackgroundColor))\n                }\n            }\n        )\n        .overlay(\n            Group {\n                if presentation == .embedded {\n                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                        .stroke(Color.secondary.opacity(0.15))\n                }\n            }\n        )\n    }\n\n    private func detailContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {\n        content()\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n            .background(\n                Group {\n                    // In embedded presentation, use a card-like surface similar to other\n                    // insets. In full panel (project Review right side), keep plain to\n                    // match Tasks detail surface styling.\n                    if presentation == .embedded {\n                        RoundedRectangle(cornerRadius: 6, style: .continuous)\n                            .fill(Color(nsColor: .textBackgroundColor))\n                            .overlay(\n                                RoundedRectangle(cornerRadius: 6)\n                                    .stroke(Color.secondary.opacity(0.15))\n                            )\n                    } else {\n                        Color.clear\n                    }\n                }\n            )\n    }\n\n#if canImport(AppKit)\n    private var imagePreviewContent: some View {\n        GeometryReader { geo in\n            ZStack {\n                if let image = previewImage {\n                    let size = image.size\n                    let widthScale = geo.size.width / max(size.width, 1)\n                    let heightScale = geo.size.height / max(size.height, 1)\n                    let scale = min(1.0, min(widthScale, heightScale))\n                    Image(nsImage: image)\n                        .resizable()\n                        .interpolation(.high)\n                        .frame(width: size.width * scale, height: size.height * scale)\n                } else {\n                    ProgressView()\n                }\n            }\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n    }\n\n    func loadPreviewImageIfNeeded() {\n        previewImageTask?.cancel()\n        previewImage = nil\n        guard let root = vm.repoRoot,\n              let path = vm.selectedPath,\n              isImagePath(path)\n        else { return }\n        let url = root.appendingPathComponent(path)\n        previewImageTask = Task {\n            let image = NSImage(contentsOf: url)\n            if Task.isCancelled { return }\n            await MainActor.run {\n                previewImage = image\n            }\n        }\n    }\n#else\n    private var imagePreviewContent: some View {\n        Color.clear\n    }\n\n    func loadPreviewImageIfNeeded() {}\n#endif\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+DiffTree.swift",
    "content": "import SwiftUI\n#if canImport(AppKit)\nimport AppKit\n#endif\n\nextension GitChangesPanel {\n    @ViewBuilder\n    func treeRows(nodes: [FileNode], depth: Int, scope: TreeScope) -> some View {\n        ForEach(nodes) { node in\n            if node.isDirectory {\n                // Directory row with VS Code-style layout\n                let key = node.dirPath ?? \"\"\n                let hoverKey = scopedHoverKey(for: key, scope: scope)\n                let isExpanded: Bool = {\n                    switch scope {\n                    case .staged: return expandedDirsStaged.contains(key)\n                    case .unstaged: return expandedDirsUnstaged.contains(key)\n                    }\n                }()\n                HStack(spacing: 0) {\n                    // Indentation guides (vertical lines)\n                    ZStack(alignment: .leading) {\n                        Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth)\n                        let guideColor = Color.secondary.opacity(0.15)\n                        ForEach(0..<depth, id: \\.self) { i in\n                            Rectangle().fill(guideColor).frame(width: 1)\n                                .offset(x: CGFloat(i) * indentStep + chevronWidth / 2)\n                        }\n                        // Chevron (disclosure triangle)\n                        HStack(spacing: 0) {\n                            Spacer().frame(width: CGFloat(depth) * indentStep)\n                            Image(systemName: isExpanded ? \"chevron.down\" : \"chevron.right\")\n                                .font(.system(size: 11, weight: .regular))\n                                .foregroundStyle(.secondary)\n                                .frame(width: chevronWidth, height: 20)\n                        }\n                    }\n                    // Folder icon and name\n                    HStack(spacing: 6) {\n                        Image(systemName: \"folder\")\n                            .font(.system(size: 13))\n                            .foregroundStyle(.secondary)\n                        Text(node.name)\n                            .font(.system(size: 13))\n                            .lineLimit(1)\n                        Spacer(minLength: 0)\n                    }\n                    .padding(.trailing, (hoverDirKey == hoverKey) ? (quickActionWidth + trailingPad) : trailingPad)\n                    .overlay(alignment: .trailing) {\n                        if let dir = node.dirPath {\n                            let dirHoverKey = scopedHoverKey(for: dir, scope: scope)\n                            HStack(spacing: hoverButtonSpacing) {\n                                Button(action: {\n                                    Task {\n                                        let paths = filePaths(under: dir)\n                                        guard !paths.isEmpty else { return }\n                                        if scope == .staged { await vm.unstage(paths: paths) }\n                                        else { await vm.stage(paths: paths) }\n                                    }\n                                }) {\n                                    Image(systemName: scope == .staged ? \"minus.circle\" : \"plus.circle\")\n                                }\n                                .buttonStyle(.plain)\n                                .onHover { inside in\n                                    if inside { hoverDirButtonPath = dirHoverKey } else if hoverDirButtonPath == dirHoverKey { hoverDirButtonPath = nil }\n                                }\n                                .frame(width: quickActionWidth, height: quickActionHeight)\n                            }\n                            .foregroundStyle((hoverDirButtonPath == dirHoverKey) ? Color.accentColor : Color.secondary)\n                            .opacity((hoverDirKey == dirHoverKey) ? 1 : 0)\n                        }\n                    }\n                }\n                .frame(height: 22)\n                .contentShape(Rectangle())\n                .background(\n                    RoundedRectangle(cornerRadius: 4)\n                        .fill((hoverDirKey == hoverKey) ? Color.secondary.opacity(0.06) : Color.clear)\n                )\n                .onTapGesture {\n                    if let k = node.dirPath {\n                        switch scope {\n                        case .staged:\n                            if expandedDirsStaged.contains(k) { expandedDirsStaged.remove(k) } else { expandedDirsStaged.insert(k) }\n                        case .unstaged:\n                            if expandedDirsUnstaged.contains(k) { expandedDirsUnstaged.remove(k) } else { expandedDirsUnstaged.insert(k) }\n                        }\n                    }\n                }\n                .onHover { inside in\n                    if let key = node.dirPath {\n                        let dirHover = scopedHoverKey(for: key, scope: scope)\n                        if inside { hoverDirKey = dirHover } else if hoverDirKey == dirHover { hoverDirKey = nil }\n                    }\n                }\n                .contextMenu {\n                    if let dir = node.dirPath {\n                        let allPaths = filePaths(under: dir)\n                    if scope == .staged {\n                        Button { Task { await vm.unstage(paths: allPaths) } } label: {\n                            Label(\"Unstage Folder\", systemImage: \"minus.circle\")\n                        }\n                    } else {\n                        Button { Task { await vm.stage(paths: allPaths) } } label: {\n                            Label(\"Stage Folder\", systemImage: \"plus.circle\")\n                        }\n                    }\n#if canImport(AppKit)\n                    Divider()\n                    Button { copyAbsolutePath(dir) } label: {\n                        Label(\"Copy Path\", systemImage: \"doc.on.doc\")\n                    }\n                    Button { copyRelativePath(dir) } label: {\n                        Label(\"Copy Relative Path\", systemImage: \"doc.on.doc\")\n                    }\n                    Button {\n                        revealInFinder(path: dir, isDirectory: true)\n                    } label: {\n                        Label(\"Reveal in Finder\", systemImage: \"finder\")\n                    }\n#endif\n                    Divider()\n                    Button {\n                        Task { await vm.refreshStatus() }\n                    } label: {\n                        Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                    }\n                    if scope == .unstaged {\n                        Divider()\n                        Button(role: .destructive) {\n                            pendingDiscardPaths = allPaths\n                                pendingDiscardIncludesStaged = false\n                                showDiscardAlert = true\n                            } label: {\n                                Label(\"Discard Folder Changes…\", systemImage: \"trash\")\n                            }\n                        }\n                    }\n                }\n\n                // Expanded children\n                if isExpanded {\n                    AnyView(treeRows(nodes: node.children ?? [], depth: depth + 1, scope: scope))\n                }\n            } else {\n                // File row\n                let path = node.fullPath ?? node.name\n                let isSelected = (vm.selectedPath == path) && ((scope == .staged && vm.selectedSide == .staged) || (scope == .unstaged && vm.selectedSide == .unstaged))\n                let hoverKey = scopedHoverKey(for: path, scope: scope)\n                let quickActionCount = (scope == .staged ? 2 : 3)\n                HStack(spacing: 0) {\n                    // Indentation guides (vertical lines)\n                    ZStack(alignment: .leading) {\n                        Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth)\n                        let guideColor = Color.secondary.opacity(0.15)\n                        ForEach(0..<depth, id: \\.self) { i in\n                            Rectangle().fill(guideColor).frame(width: 1)\n                                .offset(x: CGFloat(i) * indentStep + chevronWidth / 2)\n                        }\n                    }\n                    // File icon and name\n                    HStack(spacing: 6) {\n                        // File type indicator or icon\n                        Circle()\n                            .fill(statusColor(for: path))\n                            .frame(width: 6, height: 6)\n                        let icon = fileTypeIconName(for: path)\n                        Image(systemName: icon.name)\n                            .font(.system(size: 12))\n                            .foregroundStyle(icon.color)\n                        Text(node.name)\n                            .font(.system(size: 13))\n                            .lineLimit(1)\n                        Spacer(minLength: 0)\n                    }\n                    .padding(\n                        .trailing,\n                        (hoverFilePath == hoverKey)\n                            ? (statusBadgeWidth + trailingPad + quickActionWidth * CGFloat(quickActionCount) + hoverButtonSpacing * CGFloat(quickActionCount - 1))\n                            : (statusBadgeWidth + trailingPad)\n                    )\n                    .overlay(alignment: .trailing) {\n                            HStack(spacing: hoverButtonSpacing) {\n                            if hoverFilePath == hoverKey {\n                                Button {\n                                    let editor = preferences.defaultFileEditor\n                                    if EditorApp.installedEditors.contains(editor) {\n                                        vm.openFile(path, using: editor)\n                                    } else {\n                                        let full = vm.repoRoot?.appendingPathComponent(path).path ?? path\n                                        NSWorkspace.shared.open(URL(fileURLWithPath: full))\n                                    }\n                                } label: {\n                                    Image(systemName: \"square.and.pencil\")\n                                        .foregroundStyle((hoverEditPath == hoverKey) ? Color.accentColor : Color.secondary)\n                                }\n                                .buttonStyle(.plain)\n                                .onHover { inside in\n                                    if inside { hoverEditPath = hoverKey } else if hoverEditPath == hoverKey { hoverEditPath = nil }\n                                }\n                                .frame(width: quickActionWidth, height: quickActionHeight)\n\n                                if scope == .unstaged {\n                                    Button(action: {\n                                        pendingDiscardPaths = [path]\n                                        pendingDiscardIncludesStaged = false\n                                        showDiscardAlert = true\n                                    }) {\n                                        Image(systemName: \"arrow.uturn.backward.circle\")\n                                            .foregroundStyle((hoverRevertPath == hoverKey) ? Color.red : Color.secondary)\n                                    }\n                                    .buttonStyle(.plain)\n                                    .onHover { inside in\n                                        if inside { hoverRevertPath = hoverKey } else if hoverRevertPath == hoverKey { hoverRevertPath = nil }\n                                    }\n                                    .frame(width: quickActionWidth, height: quickActionHeight)\n                                }\n\n                                Button(action: {\n                                    Task {\n                                        if scope == .staged { await vm.unstage(paths: [path]) }\n                                        else { await vm.stage(paths: [path]) }\n                                    }\n                                }) {\n                                    Image(systemName: scope == .staged ? \"minus.circle\" : \"plus.circle\")\n                                        .foregroundStyle((hoverStagePath == hoverKey) ? Color.accentColor : Color.secondary)\n                                }\n                                .buttonStyle(.plain)\n                                .onHover { inside in\n                                    if inside { hoverStagePath = hoverKey } else if hoverStagePath == hoverKey { hoverStagePath = nil }\n                                }\n                                .frame(width: quickActionWidth, height: quickActionHeight)\n                            }\n\n                            if let change = vm.changes.first(where: { $0.path == path }) {\n                                statusBadge(for: change)\n                                    .frame(height: quickActionHeight)\n                            }\n                        }\n                    }\n                }\n                .frame(height: 22)\n                .contentShape(Rectangle())\n                .background(\n                    RoundedRectangle(cornerRadius: 4)\n                        .fill(isSelected ? Color.accentColor.opacity(0.15) : ((hoverFilePath == hoverKey) ? Color.secondary.opacity(0.06) : Color.clear))\n                )\n                .onTapGesture {\n                    vm.selectedPath = path\n                    vm.selectedSide = (scope == .staged ? .staged : .unstaged)\n                    // When interacting with the Diff tree (both Diff and History modes),\n                    // ensure the right pane shows the Diff reader.\n                    if mode != .diff { mode = .diff }\n                    if vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = false }\n                    Task { await vm.refreshDetail() }\n                }\n                .onHover { inside in\n                    if inside { hoverFilePath = hoverKey } else if hoverFilePath == hoverKey { hoverFilePath = nil }\n                }\n                .contextMenu {\n                    if scope == .staged {\n                        Button { Task { await vm.unstage(paths: [path]) } } label: {\n                            Label(\"Unstage\", systemImage: \"minus.circle\")\n                        }\n                    } else {\n                        Button { Task { await vm.stage(paths: [path]) } } label: {\n                            Label(\"Stage\", systemImage: \"plus.circle\")\n                        }\n                    }\n                    let editors = EditorApp.installedEditors\n                    if !editors.isEmpty {\n                        Divider()\n                        openInEditorMenu(editors: editors) { editor in\n                            vm.openFile(path, using: editor)\n                        }\n                    }\n#if canImport(AppKit)\n                    Divider()\n                    Button { copyAbsolutePath(path) } label: {\n                        Label(\"Copy Path\", systemImage: \"doc.on.doc\")\n                    }\n                    Button { copyRelativePath(path) } label: {\n                        Label(\"Copy Relative Path\", systemImage: \"doc.on.doc\")\n                    }\n                    Button { revealInFinder(path: path, isDirectory: false) } label: {\n                        Label(\"Reveal in Finder\", systemImage: \"finder\")\n                    }\n#endif\n                    Divider()\n                    Button {\n                        Task { await vm.refreshStatus() }\n                    } label: {\n                        Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                    }\n                    if scope == .unstaged {\n                        Divider()\n                        Button(role: .destructive) {\n                            pendingDiscardPaths = [path]\n                            pendingDiscardIncludesStaged = false\n                            showDiscardAlert = true\n                        } label: {\n                            Label(\"Discard Changes…\", systemImage: \"trash\")\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private func scopedHoverKey(for path: String, scope: TreeScope) -> String {\n        let prefix = (scope == .staged) ? \"S\" : \"U\"\n        return \"\\(prefix)::\\(path)\"\n    }\n}\n\n#if canImport(AppKit)\nextension GitChangesPanel {\n    private func copyAbsolutePath(_ relativePath: String) {\n        let full = vm.repoRoot?.appendingPathComponent(relativePath).path ?? relativePath\n        writeToPasteboard(full)\n    }\n\n    private func copyRelativePath(_ relativePath: String) {\n        writeToPasteboard(relativePath)\n    }\n\n    private func writeToPasteboard(_ string: String) {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(string, forType: .string)\n    }\n}\n#endif\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Graph.swift",
    "content": "import SwiftUI\n\nextension GitChangesPanel {\n  // MARK: - Graph detail view\n  var graphDetailView: some View {\n    graphListView(compactColumns: false) { commit in\n      // Enter History Detail mode when a commit is activated.\n      historyDetailCommit = commit\n    }\n  }\n\n  /// Shared helper to host the graph list with repo attachment and activation callback.\n  func graphListView(\n    compactColumns: Bool,\n    onActivateCommit: @escaping (GitService.GraphCommit?) -> Void\n  ) -> some View {\n    GraphContainer(\n      vm: graphVM,\n      wrapText: wrapText,\n      showLineNumbers: showLineNumbers,\n      compactColumns: compactColumns,\n      onActivateCommit: onActivateCommit\n    )\n    .onAppear {\n      graphVM.attach(to: vm.repoRoot)\n    }\n    .onChange(of: vm.repoRoot) { newVal in\n      graphVM.attach(to: newVal)\n    }\n  }\n\n  // Host for the graph UI\n  struct GraphContainer: View {\n    @ObservedObject var vm: GitGraphViewModel\n    let wrapText: Bool\n    let showLineNumbers: Bool\n    let compactColumns: Bool\n    let onActivateCommit: (GitService.GraphCommit?) -> Void\n    @State private var selection: GitGraphViewModel.CommitRowData.ID? = nil\n    @State private var suppressNextActivation: Bool = false\n\n    init(\n      vm: GitGraphViewModel,\n      wrapText: Bool,\n      showLineNumbers: Bool,\n      compactColumns: Bool,\n      onActivateCommit: @escaping (GitService.GraphCommit?) -> Void\n    ) {\n      self.vm = vm\n      self.wrapText = wrapText\n      self.showLineNumbers = showLineNumbers\n      self.compactColumns = compactColumns\n      self.onActivateCommit = onActivateCommit\n    }\n\n    var body: some View {\n      VStack(spacing: 0) {\n        // Controls + branch scope\n        HStack(spacing: 12) {\n          ViewThatFits(in: .horizontal) {\n            HStack(spacing: 10) {\n              branchSelector\n              remoteBranchesToggle\n            }\n            HStack(spacing: 10) {\n              branchSelector\n            }\n          }\n          Spacer()\n          actionButtons\n        }\n        .padding(.top, 16)\n        .padding(.horizontal, 16)\n        .onChange(of: vm.showAllBranches) { _ in vm.loadCommits() }\n        .onChange(of: vm.branchSearchQuery) { _ in vm.applyBranchFilter() }\n\n        if let error = vm.errorMessage, !error.isEmpty {\n          HStack(spacing: 6) {\n            Image(systemName: \"exclamationmark.triangle.fill\")\n              .foregroundStyle(.orange)\n            Text(error)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .lineLimit(2)\n            Spacer()\n            Button(\"Dismiss\") { vm.clearError() }\n              .buttonStyle(.link)\n              .font(.caption)\n          }\n          .padding(.horizontal, 16)\n          .padding(.bottom, 4)\n        }\n\n        tableView\n        .environment(\\.defaultMinListRowHeight, rowHeight)\n        .tableStyle(.inset(alternatesRowBackgrounds: true))\n        .removeTableSpacing(rowHeight: rowHeight)\n        .padding(.horizontal, 0)\n        .padding(.top, 8)\n        .overlay(alignment: .bottom) {\n          if vm.isLoadingMore {\n            ProgressView()\n              .controlSize(.small)\n              .padding(8)\n              .background(.regularMaterial, in: Capsule())\n              .padding(.bottom, 16)\n          }\n        }\n      }\n      .frame(maxWidth: .infinity, maxHeight: .infinity)\n      .onAppear {\n        syncSelectionFromViewModel()\n      }\n      .onChange(of: vm.rowData) { _ in\n        syncSelectionFromViewModel()\n      }\n      .onChange(of: selection) { newValue in\n        if suppressNextActivation {\n          // Skip the activation corresponding to a programmatic\n          // selection restore (e.g. when the view is recreated\n          // after closing the history detail pane).\n          suppressNextActivation = false\n          return\n        }\n        guard let id = newValue,\n          let row = vm.rowData.first(where: { $0.id == id })\n        else { return }\n        vm.selectCommit(row.commit)\n        if row.isWorkingTree {\n          onActivateCommit(nil)\n        } else {\n          onActivateCommit(row.commit)\n        }\n      }\n    }\n\n    @ViewBuilder\n    private var tableView: some View {\n      if compactColumns {\n        Table(vm.rowData, selection: $selection) {\n          // Graph column\n          TableColumn(\"\") { row in\n            graphColumnContent(for: row)\n          }\n          .width(min: graphColumnWidth, ideal: graphColumnWidth, max: graphColumnWidth)\n\n          // Description\n          TableColumn(\"Description\") { row in\n            descriptionColumnContent(for: row)\n          }\n        }\n      } else {\n        Table(vm.rowData, selection: $selection) {\n          // Graph column\n          TableColumn(\"\") { row in\n            graphColumnContent(for: row)\n          }\n          .width(min: graphColumnWidth, ideal: graphColumnWidth, max: graphColumnWidth)\n\n          // Description\n          TableColumn(\"Description\") { row in\n            descriptionColumnContent(for: row)\n          }\n\n          TableColumn(\"Date\") { row in\n            Text(row.commit.date)\n              .foregroundStyle(.secondary)\n              .lineLimit(1)\n              .frame(maxWidth: .infinity, alignment: .leading)\n          }\n          .width(dateWidth)\n\n          TableColumn(\"Author\") { row in\n            Text(row.commit.author)\n              .foregroundStyle(.secondary)\n              .lineLimit(1)\n              .frame(maxWidth: .infinity, alignment: .leading)\n          }\n          .width(authorWidth)\n\n          TableColumn(\"SHA\") { row in\n            Text(row.commit.shortId)\n              .font(.system(.caption, design: .monospaced))\n              .foregroundStyle(.secondary)\n              .lineLimit(1)\n              .frame(maxWidth: .infinity, alignment: .leading)\n          }\n          .width(shaWidth)\n        }\n      }\n    }\n\n    @ViewBuilder\n    private func graphColumnContent(for row: GitGraphViewModel.CommitRowData) -> some View {\n      let isSelected = (selection == row.id)\n      if let info = row.laneInfo {\n        GraphLaneView(\n          info: info,\n          maxLanes: vm.maxLaneCount,\n          laneSpacing: laneSpacing,\n          verticalWidth: 2,\n          hideTopForCurrentLane: row.isFirst,\n          hideBottomForCurrentLane: row.isLast,\n          headIsHollow: row.isWorkingTree,\n          headSize: 12,\n          isSelected: isSelected\n        )\n        .frame(width: graphColumnWidth, height: rowHeight)\n      } else {\n        GraphGlyph(isSelected: isSelected)\n          .frame(width: graphColumnWidth, height: rowHeight)\n      }\n    }\n\n    private func descriptionColumnContent(for row: GitGraphViewModel.CommitRowData) -> some View {\n      HStack(spacing: 6) {\n        Text(row.commit.subject)\n          .fontWeight(row.isWorkingTree ? .semibold : .regular)\n          .lineLimit(1)\n          .frame(maxWidth: .infinity, alignment: .leading)\n\n        if !row.commit.decorations.isEmpty {\n          ForEach(row.commit.decorations.prefix(3), id: \\.self) { d in\n            Text(d)\n              .font(.system(size: 10, weight: .medium))\n              .padding(.horizontal, 6)\n              .padding(.vertical, 2)\n              .background(Capsule().fill(Color.secondary.opacity(0.15)))\n          }\n        }\n      }\n      .onAppear {\n        if row.isLast {\n          vm.loadMore()\n        }\n      }\n    }\n\n    /// Ensure that the SwiftUI `Table` selection tracks the\n    /// view model's selected commit across layout mode switches\n    /// (e.g. when entering History Detail full-width mode).\n    private func syncSelectionFromViewModel() {\n      guard let current = vm.selectedCommit else { return }\n      // If we don't have a selection yet, or it already matches the\n      // view model, restore it from the current rowData.\n      if selection == nil || selection == current.id {\n        if let row = vm.rowData.first(where: { $0.commit.id == current.id }) {\n          suppressNextActivation = true\n          selection = row.id\n        }\n      }\n    }\n    private var graphColumnWidth: CGFloat {\n      // Graph column width scales with lanes; lane spacing controls horizontal density.\n      // Lane layout is computed before the first rows are built (we suppress the\n      // initial rowData build once in the view model), so maxLaneCount should\n      // reflect the actual width needed for the graph.\n      let lanes = max(vm.maxLaneCount, 1)\n      return max(rowHeight + 4, CGFloat(lanes) * laneSpacing)\n    }\n    private var rowHeight: CGFloat { 28 }\n    private var laneSpacing: CGFloat { rowHeight }\n    private var dateWidth: CGFloat { 110 }\n    private var authorWidth: CGFloat { 120 }\n    private var shaWidth: CGFloat { 80 }\n\n    @ViewBuilder\n    private var branchSelector: some View {\n      VStack(spacing: 4) {\n        HStack(spacing: 6) {\n          Text(\"Branches:\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n          Picker(\n            \"\",\n            selection: Binding<String>(\n              get: { vm.showAllBranches ? \"__all__\" : (vm.selectedBranch ?? \"__current__\") },\n              set: { newVal in\n                if newVal == \"__all__\" {\n                  vm.showAllBranches = true\n                  vm.selectedBranch = nil\n                } else if newVal == \"__current__\" {\n                  vm.showAllBranches = false\n                  vm.selectedBranch = nil\n                } else {\n                  vm.showAllBranches = false\n                  vm.selectedBranch = newVal\n                }\n                vm.loadCommits()\n              })\n          ) {\n            Text(\"Show All\").tag(\"__all__\")\n            Text(\"Current\").tag(\"__current__\")\n            Divider()\n            if vm.fullBranchList.count > 100 {\n              Text(\"Search to filter \\(vm.fullBranchList.count) branches...\").tag(\"__search__\")\n                .foregroundStyle(.secondary)\n                .italic()\n            }\n            ForEach(vm.branches, id: \\.self) { name in\n              Text(name).tag(name)\n            }\n          }\n          .pickerStyle(.menu)\n          .frame(width: 200)\n          .onAppear {\n            if vm.fullBranchList.isEmpty && !vm.isLoadingBranches {\n              vm.loadBranches()\n            }\n          }\n\n          if vm.isLoadingBranches {\n            ProgressView().controlSize(.small)\n          }\n        }\n\n        if !vm.showAllBranches && vm.fullBranchList.count > 100 {\n          HStack(spacing: 4) {\n            Image(systemName: \"magnifyingglass\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n            TextField(\"Filter branches...\", text: $vm.branchSearchQuery)\n              .textFieldStyle(.plain)\n              .font(.caption)\n          }\n          .padding(.horizontal, 6)\n          .padding(.vertical, 3)\n          .background(\n            RoundedRectangle(cornerRadius: 4)\n              .stroke(Color.secondary.opacity(0.2))\n          )\n          .frame(width: 200)\n        }\n      }\n    }\n\n    private var remoteBranchesToggle: some View {\n      Toggle(\n        isOn: $vm.showRemoteBranches\n      ) {\n        Text(\"Show Remote Branches\")\n          .lineLimit(1)\n      }\n      .onChange(of: vm.showRemoteBranches) { _ in\n        vm.loadBranches()\n        vm.loadCommits()\n      }\n    }\n\n    private var actionButtons: some View {\n      HStack(spacing: 8) {\n        Button {\n          vm.triggerRefresh()\n        } label: {\n          Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n            .labelStyle(.titleAndIcon)\n        }\n        .controlSize(.small)\n        .buttonStyle(.bordered)\n        .disabled(vm.isLoading)\n        .help(\"Reload the commit list\")\n\n        Button {\n          vm.fetchRemotes()\n        } label: {\n          Label(\"Fetch\", systemImage: \"arrow.down.circle\")\n            .labelStyle(.titleAndIcon)\n        }\n        .controlSize(.small)\n        .buttonStyle(.bordered)\n        .disabled(vm.historyActionInProgress != nil)\n        .help(\"Fetch all remotes\")\n\n        Button {\n          vm.pullLatest()\n        } label: {\n          Label(\"Pull\", systemImage: \"square.and.arrow.down\")\n            .labelStyle(.titleAndIcon)\n        }\n        .controlSize(.small)\n        .buttonStyle(.bordered)\n        .disabled(vm.historyActionInProgress != nil)\n        .help(\"Pull current branch (fast-forward)\")\n\n        Button {\n          vm.pushCurrent()\n        } label: {\n          Label(\"Push\", systemImage: \"square.and.arrow.up\")\n            .labelStyle(.titleAndIcon)\n        }\n        .controlSize(.small)\n        .buttonStyle(.bordered)\n        .disabled(vm.historyActionInProgress != nil)\n        .help(\"Push current branch\")\n\n        if vm.historyActionInProgress != nil {\n          ProgressView()\n            .controlSize(.small)\n            .padding(.leading, 2)\n        }\n      }\n    }\n  }\n\n  // Detailed view for a single commit: meta info, files list, and diff viewer.\n  struct HistoryCommitDetailView: View {\n    let commit: GitService.GraphCommit\n    @ObservedObject var viewModel: GitGraphViewModel\n    var onClose: () -> Void\n    let wrap: Bool\n    let showLineNumbers: Bool\n    @State private var fileSearch: String = \"\"\n    @State private var showMessageBody: Bool = false\n\n    var body: some View {\n      VSplitView {\n        // Top: meta + files tree (stacked vertically)\n        VSplitView {\n          metaSection\n          filesSection\n        }\n        // Bottom: diff viewer\n        diffSection\n      }\n      .onAppear {\n        viewModel.loadDetail(for: commit)\n      }\n      .onChange(of: commit.id) { _ in\n        viewModel.loadDetail(for: commit)\n      }\n    }\n\n    private var metaSection: some View {\n      VStack(alignment: .leading, spacing: 8) {\n        HStack(alignment: .top, spacing: 8) {\n          VStack(alignment: .leading, spacing: 6) {\n            Text(commit.subject)\n              .font(.headline)\n              .lineLimit(2)\n            HStack(spacing: 12) {\n              Text(commit.shortId)\n                .font(.system(.caption, design: .monospaced))\n                .foregroundStyle(.secondary)\n              if !commit.parents.isEmpty {\n                Text(\"Parents: \\(commit.parents.joined(separator: \", \"))\")\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n              }\n            }\n            HStack(spacing: 12) {\n              Text(commit.author)\n                .font(.caption)\n                .foregroundStyle(.secondary)\n              Text(commit.date)\n                .font(.caption)\n                .foregroundStyle(.secondary)\n            }\n          }\n          Spacer()\n          Button(action: onClose) {\n            Image(systemName: \"xmark.circle.fill\")\n              .font(.system(size: 16, weight: .semibold))\n              .foregroundStyle(.secondary)\n          }\n          .buttonStyle(.plain)\n          .help(\"Close commit details\")\n        }\n        if !commit.decorations.isEmpty {\n          HStack(spacing: 6) {\n            ForEach(commit.decorations.prefix(4), id: \\.self) { deco in\n              Text(deco)\n                .font(.system(size: 10, weight: .medium))\n                .padding(.horizontal, 6)\n                .padding(.vertical, 2)\n                .background(Capsule().fill(Color.secondary.opacity(0.15)))\n            }\n          }\n        }\n        if !viewModel.detailMessage.isEmpty {\n          VStack(alignment: .leading, spacing: 4) {\n            Button {\n              showMessageBody.toggle()\n            } label: {\n              HStack(spacing: 4) {\n                Image(systemName: showMessageBody ? \"chevron.down\" : \"chevron.right\")\n                  .font(.system(size: 11, weight: .semibold))\n                Text(\"Message\")\n                  .font(.caption.weight(.semibold))\n                Spacer()\n              }\n            }\n            .buttonStyle(.plain)\n\n            if showMessageBody {\n              ScrollView(.vertical, showsIndicators: true) {\n                Text(viewModel.detailMessage)\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .frame(maxWidth: .infinity, alignment: .leading)\n                  .textSelection(.enabled)\n                  .padding(.trailing, 2)\n              }\n              .frame(maxHeight: .infinity, alignment: .topLeading)\n            }\n          }\n        }\n      }\n      .padding(16)\n      .frame(minHeight: showMessageBody ? 140 : 110, alignment: .topLeading)\n    }\n\n    private var filesSection: some View {\n      VStack(alignment: .leading, spacing: 4) {\n        HStack(spacing: 8) {\n          HStack(spacing: 6) {\n            Image(systemName: \"magnifyingglass\").foregroundStyle(.secondary)\n            TextField(\"Filter files\", text: $fileSearch)\n              .textFieldStyle(.plain)\n          }\n          .padding(.vertical, 4)\n          .padding(.horizontal, 6)\n          .background(\n            RoundedRectangle(cornerRadius: 8)\n              .stroke(Color.secondary.opacity(0.2))\n          )\n\n          Spacer()\n\n          HStack(spacing: 0) {\n            Button {\n              expandedHistoryDirs.removeAll()\n            } label: {\n              Image(systemName: \"arrow.up.right.and.arrow.down.left\")\n                .font(.system(size: 12))\n                .foregroundStyle(.secondary)\n            }\n            .buttonStyle(.plain)\n            .frame(width: 28, height: 28)\n\n            Button {\n              let nodes = buildHistoryTree(from: filteredDetailFiles)\n              var all: Set<String> = []\n              collectAllDirKeys(nodes: nodes, into: &all)\n              expandedHistoryDirs = all\n            } label: {\n              Image(systemName: \"arrow.down.left.and.arrow.up.right\")\n                .font(.system(size: 12))\n                .foregroundStyle(.secondary)\n            }\n            .buttonStyle(.plain)\n            .frame(width: 28, height: 28)\n          }\n\n          if viewModel.isLoadingDetail && viewModel.detailFiles.isEmpty {\n            ProgressView().controlSize(.small)\n          }\n        }\n        ScrollView {\n          LazyVStack(alignment: .leading, spacing: 0) {\n            if filteredDetailFiles.isEmpty, !viewModel.isLoadingDetail {\n              Text(\"No files changed in this commit.\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n                .padding(.vertical, 8)\n            } else {\n              HistoryTreeView(\n                nodes: buildHistoryTree(from: filteredDetailFiles),\n                depth: 0,\n                expandedDirs: $expandedHistoryDirs,\n                selectedPath: viewModel.selectedDetailFile,\n                onSelectFile: { path in\n                  viewModel.selectedDetailFile = path\n                  viewModel.loadDetailPatch(for: path)\n                }\n              )\n            }\n          }\n        }\n      }.padding(16)\n    }\n\n    private var diffSection: some View {\n      VStack(alignment: .leading, spacing: 4) {\n        HStack {\n          Text(\"Diff\")\n            .font(.subheadline.weight(.semibold))\n          if let file = viewModel.selectedDetailFile {\n            Text(\"— \\(file)\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n          }\n          Spacer()\n          if viewModel.isLoadingDetail {\n            ProgressView().controlSize(.small)\n          }\n        }\n\n        if viewModel.detailFilePatch.isEmpty && !viewModel.isLoadingDetail {\n          Text(\"Select a file to view its diff.\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .padding(.vertical, 8)\n        } else {\n          AttributedTextView(\n            text: viewModel.detailFilePatch,\n            isDiff: true,\n            wrap: wrap,\n            showLineNumbers: showLineNumbers,\n            fontSize: 12\n          )\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n      }\n      .padding(16)\n    }\n\n    // MARK: - History file tree helpers\n\n    private var filteredDetailFiles: [GitService.FileChange] {\n      let q = fileSearch.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !q.isEmpty else { return viewModel.detailFiles }\n      return viewModel.detailFiles.filter {\n        $0.path.localizedCaseInsensitiveContains(q)\n          || ($0.oldPath?.localizedCaseInsensitiveContains(q) ?? false)\n      }\n    }\n\n    struct HistoryFileNode: Identifiable {\n      let id = UUID()\n      let name: String\n      let path: String?\n      let dirPath: String?\n      let change: GitService.FileChange?\n      var children: [HistoryFileNode]?\n      var isDirectory: Bool { dirPath != nil }\n    }\n\n    private func buildHistoryTree(from changes: [GitService.FileChange]) -> [HistoryFileNode] {\n      struct Builder {\n        var children: [String: Builder] = [:]\n        var fileChange: GitService.FileChange? = nil\n      }\n      var root = Builder()\n      for change in changes {\n        let path = change.path\n        guard !path.isEmpty else { continue }\n        let components = path.split(separator: \"/\").map(String.init)\n        guard !components.isEmpty else { continue }\n        func insert(_ index: Int, current: inout Builder) {\n          let key = components[index]\n          if index == components.count - 1 {\n            var child = current.children[key, default: Builder()]\n            child.fileChange = change\n            current.children[key] = child\n          } else {\n            var child = current.children[key, default: Builder()]\n            insert(index + 1, current: &child)\n            current.children[key] = child\n          }\n        }\n        insert(0, current: &root)\n      }\n      func convert(_ builder: Builder, prefix: String?) -> [HistoryFileNode] {\n        var nodes: [HistoryFileNode] = []\n        for (name, child) in builder.children {\n          let fullPath = prefix.map { \"\\($0)/\\(name)\" } ?? name\n          if let change = child.fileChange, child.children.isEmpty {\n            nodes.append(\n              HistoryFileNode(\n                name: name, path: change.path, dirPath: nil, change: change, children: nil)\n            )\n          } else {\n            let childrenNodes = convert(child, prefix: fullPath)\n            nodes.append(\n              HistoryFileNode(\n                name: name,\n                path: nil,\n                dirPath: fullPath,\n                change: nil,\n                children: childrenNodes.sorted {\n                  $0.name.localizedStandardCompare($1.name) == .orderedAscending\n                }\n              )\n            )\n          }\n        }\n        return nodes.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }\n      }\n      return convert(root, prefix: nil)\n    }\n\n    private func collectAllDirKeys(nodes: [HistoryFileNode], into set: inout Set<String>) {\n      for node in nodes {\n        if let dir = node.dirPath {\n          set.insert(dir)\n        }\n        if let children = node.children {\n          collectAllDirKeys(nodes: children, into: &set)\n        }\n      }\n    }\n\n    @State private var expandedHistoryDirs: Set<String> = []\n\n    struct HistoryTreeView: View {\n      let nodes: [HistoryFileNode]\n      let depth: Int\n      @Binding var expandedDirs: Set<String>\n      let selectedPath: String?\n      let onSelectFile: (String) -> Void\n\n      var body: some View {\n        ForEach(nodes) { node in\n          if node.isDirectory {\n            let key = node.dirPath ?? \"\"\n            let isExpanded = expandedDirs.contains(key)\n            directoryRow(node: node, key: key, isExpanded: isExpanded)\n            if isExpanded, let children = node.children {\n              HistoryTreeView(\n                nodes: children,\n                depth: depth + 1,\n                expandedDirs: $expandedDirs,\n                selectedPath: selectedPath,\n                onSelectFile: onSelectFile\n              )\n            }\n          } else if let path = node.path {\n            fileRow(node: node, path: path)\n          }\n        }\n      }\n\n      private func directoryRow(node: HistoryFileNode, key: String, isExpanded: Bool) -> some View {\n        let indentStep: CGFloat = 16\n        let chevronWidth: CGFloat = 16\n        return HStack(spacing: 0) {\n          ZStack(alignment: .leading) {\n            Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth)\n            let guideColor = Color.secondary.opacity(0.15)\n            ForEach(0..<depth, id: \\.self) { i in\n              Rectangle()\n                .fill(guideColor)\n                .frame(width: 1)\n                .offset(x: CGFloat(i) * indentStep + chevronWidth / 2)\n            }\n            HStack(spacing: 0) {\n              Spacer().frame(width: CGFloat(depth) * indentStep)\n              Image(systemName: isExpanded ? \"chevron.down\" : \"chevron.right\")\n                .font(.system(size: 11, weight: .regular))\n                .foregroundStyle(.secondary)\n                .frame(width: chevronWidth, height: 20)\n            }\n          }\n          HStack(spacing: 6) {\n            Image(systemName: \"folder\")\n              .font(.system(size: 13))\n              .foregroundStyle(.secondary)\n            Text(node.name)\n              .font(.system(size: 13))\n              .lineLimit(1)\n            Spacer(minLength: 0)\n          }\n          .padding(.trailing, 8)\n        }\n        .frame(height: 22)\n        .contentShape(Rectangle())\n        .onTapGesture {\n          if let dir = node.dirPath {\n            if expandedDirs.contains(dir) {\n              expandedDirs.remove(dir)\n            } else {\n              expandedDirs.insert(dir)\n            }\n          }\n        }\n      }\n\n      private func fileRow(node: HistoryFileNode, path: String) -> some View {\n        let indentStep: CGFloat = 16\n        let chevronWidth: CGFloat = 16\n        let isSelected = (path == selectedPath)\n        return HStack(spacing: 0) {\n          ZStack(alignment: .leading) {\n            Color.clear.frame(width: CGFloat(depth) * indentStep + chevronWidth)\n            let guideColor = Color.secondary.opacity(0.15)\n            ForEach(0..<depth, id: \\.self) { i in\n              Rectangle()\n                .fill(guideColor)\n                .frame(width: 1)\n                .offset(x: CGFloat(i) * indentStep + chevronWidth / 2)\n            }\n          }\n          HStack(spacing: 6) {\n            let icon = GitFileIcon.icon(for: path)\n            Image(systemName: icon.name)\n              .font(.system(size: 12))\n              .foregroundStyle(icon.color)\n            Text(node.name)\n              .font(.system(size: 13))\n              .lineLimit(1)\n            Spacer(minLength: 0)\n            if let change = node.change {\n              Circle()\n                .fill(Self.statusColor(for: change))\n                .frame(width: 6, height: 6)\n              Self.statusBadge(text: Self.badgeText(for: change))\n            }\n          }\n          .padding(.trailing, 8)\n        }\n        .frame(height: 22)\n        .background(\n          RoundedRectangle(cornerRadius: 4)\n            .fill(isSelected ? Color.accentColor.opacity(0.12) : Color.clear)\n        )\n        .contentShape(Rectangle())\n        .onTapGesture {\n          onSelectFile(path)\n        }\n      }\n\n      // MARK: - Helper methods\n      private static func statusColor(for change: GitService.FileChange) -> Color {\n        guard let code = change.statusCode.first else { return Color.secondary.opacity(0.6) }\n        switch code {\n        case \"A\": return .green\n        case \"M\": return .orange\n        case \"D\": return .red\n        case \"R\": return .purple\n        case \"C\": return .blue\n        case \"T\": return .teal\n        case \"U\": return .gray\n        default: return Color.secondary.opacity(0.6)\n        }\n      }\n\n      private static func badgeText(for change: GitService.FileChange) -> String {\n        guard let first = change.statusCode.first else { return \"?\" }\n        return String(first)\n      }\n\n      private static func statusBadge(text: String) -> some View {\n        Text(text)\n          .font(.system(size: 9, weight: .medium))\n          .foregroundStyle(.secondary)\n          .padding(.horizontal, 4)\n          .padding(.vertical, 1)\n          .background(\n            RoundedRectangle(cornerRadius: 3)\n              .fill(Color.secondary.opacity(0.1))\n          )\n      }\n    }\n  }\n}\n\n// Renders commit lanes and connectors for a single row.\nprivate struct GraphLaneView: View {\n  let info: GitGraphViewModel.LaneInfo\n  let maxLanes: Int\n  let laneSpacing: CGFloat\n  let verticalWidth: CGFloat\n  let hideTopForCurrentLane: Bool\n  let hideBottomForCurrentLane: Bool\n  let headIsHollow: Bool\n  let headSize: CGFloat\n  let isSelected: Bool\n\n  private let dotSize: CGFloat = 8\n  private let lineWidth: CGFloat = 2\n\n  private func x(_ lane: Int) -> CGFloat {\n    CGFloat(lane) * laneSpacing + laneSpacing / 2\n  }\n\n  var body: some View {\n    Canvas { context, size in\n      drawGraph(in: context, size: size)\n    }\n  }\n\n  private func drawGraph(in context: GraphicsContext, size: CGSize) {\n    let baseColor: Color = isSelected ? .white : .accentColor\n    let verticalColor: Color = isSelected ? .white : .accentColor.opacity(0.6)\n\n    let h = size.height\n    // Slightly extend beyond row bounds so vertical lanes visually connect between rows.\n    let top: CGFloat = -2\n    let bottom: CGFloat = h + 2\n    let dotY = h * 0.5\n\n    // Draw vertical lane lines\n    let count = max(info.activeLaneCount, maxLanes)\n    if count > 0 {\n      for i in 0..<count where info.continuingLanes.contains(i) {\n        let xi = x(i)\n        let headRadius: CGFloat =\n          headIsHollow && i == info.laneIndex\n          ? max(ceil(headSize / 2), 5) : ceil(dotSize / 2)\n        let margin: CGFloat = headRadius + 1\n\n        var path = Path()\n\n        if i == info.laneIndex {\n          if !hideTopForCurrentLane && !hideBottomForCurrentLane {\n            path.move(to: CGPoint(x: xi, y: top))\n            path.addLine(to: CGPoint(x: xi, y: bottom))\n          } else if hideTopForCurrentLane && !hideBottomForCurrentLane {\n            path.move(to: CGPoint(x: xi, y: dotY + margin))\n            path.addLine(to: CGPoint(x: xi, y: bottom))\n          } else if !hideTopForCurrentLane && hideBottomForCurrentLane {\n            path.move(to: CGPoint(x: xi, y: top))\n            path.addLine(to: CGPoint(x: xi, y: dotY - margin))\n          }\n        } else if !info.parentLaneIndices.contains(i) && !info.joinLaneIndices.contains(i) {\n          path.move(to: CGPoint(x: xi, y: top))\n          path.addLine(to: CGPoint(x: xi, y: bottom))\n        }\n\n        context.stroke(path, with: .color(verticalColor), lineWidth: verticalWidth)\n      }\n    }\n\n    // Draw join connectors (incoming branches from above)\n    let cx = x(info.laneIndex)\n    let endY = headIsHollow ? dotY - max(ceil(headSize / 2), 5) - 1 : dotY - ceil(dotSize / 2) - 1\n\n    for source in info.joinLaneIndices where source != info.laneIndex {\n      var path = Path()\n      let sx = x(source)\n      path.move(to: CGPoint(x: sx, y: top))\n      path.addCurve(\n        to: CGPoint(x: cx, y: endY),\n        control1: CGPoint(x: sx, y: h * 0.25),\n        control2: CGPoint(x: cx, y: endY - h * 0.25)\n      )\n      context.stroke(path, with: .color(verticalColor), lineWidth: lineWidth)\n    }\n\n    // Draw parent connectors (outgoing branches downward)\n    if !hideBottomForCurrentLane {\n      let startY = headIsHollow ? dotY + max(ceil(headSize / 2), 5) + 1 : dotY\n\n      for parent in info.parentLaneIndices where parent != info.laneIndex {\n        var path = Path()\n        let px = x(parent)\n        path.move(to: CGPoint(x: cx, y: startY))\n        path.addCurve(\n          to: CGPoint(x: px, y: bottom),\n          control1: CGPoint(x: cx, y: startY + h * 0.25),\n          control2: CGPoint(x: px, y: bottom - h * 0.25)\n        )\n        context.stroke(path, with: .color(verticalColor), lineWidth: lineWidth)\n      }\n    }\n\n    // Draw commit dot\n    if headIsHollow {\n      var circle = Path()\n      circle.addEllipse(\n        in: CGRect(\n          x: x(info.laneIndex) - headSize / 2,\n          y: dotY - headSize / 2,\n          width: headSize,\n          height: headSize\n        ))\n      context.stroke(circle, with: .color(baseColor), lineWidth: 2)\n    } else {\n      var circle = Path()\n      circle.addEllipse(\n        in: CGRect(\n          x: x(info.laneIndex) - dotSize / 2,\n          y: dotY - dotSize / 2,\n          width: dotSize,\n          height: dotSize\n        ))\n      context.fill(circle, with: .color(baseColor))\n    }\n  }\n\n}\n\n// ColumnResizer removed: columns use fixed widths; Description fills remaining space.\n\n// MARK: - Graph Glyph\n// Monospace-like graph glyph: a vertical line with a centered dot, mimicking a basic lane.\nprivate struct GraphGlyph: View {\n  let isSelected: Bool\n\n  var body: some View {\n    let lineColor = isSelected ? Color.white.opacity(0.7) : Color.secondary.opacity(0.25)\n    let dotColor = isSelected ? Color.white : Color.accentColor\n\n    ZStack {\n      Rectangle().fill(lineColor).frame(width: 1).padding(.vertical, 2)\n      Circle().fill(dotColor).frame(width: 6, height: 6)\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)\n  }\n}\n\n// MARK: - Scroll Detection\n// No custom scroll detection needed for the Table-based graph;\n// row selection and highlighting are handled by NSTableView under the hood.\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Header.swift",
    "content": "import SwiftUI\n\nextension GitChangesPanel {\n  // MARK: - Header view\n  var header: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      HStack(spacing: 8) {\n        // Mode switcher: Diff | History | Explorer (only show when repo exists)\n        if vm.repoRoot != nil {\n          let items: [SegmentedIconPicker<ReviewPanelState.Mode>.Item] = [\n            .init(title: \"Diff\", systemImage: \"doc.text.magnifyingglass\", tag: .diff),\n            .init(title: \"History\", systemImage: \"clock.arrow.circlepath\", tag: .graph),\n            .init(title: \"Explorer\", systemImage: \"folder\", tag: .browser),\n          ]\n          SegmentedIconPicker(items: items, selection: $mode)\n        }\n\n        Spacer(minLength: 8)\n\n        // Unified search (right-aligned)\n        HStack(spacing: 6) {\n          Image(systemName: \"magnifyingglass\").foregroundStyle(.secondary)\n          TextField(searchPlaceholder, text: $headerSearchQuery)\n            .textFieldStyle(.plain)\n            .onChange(of: headerSearchQuery) { newVal in\n              onHeaderSearchChanged(newVal)\n            }\n        }\n        .padding(.vertical, 4)\n        .padding(.horizontal, 6)\n        .background(\n          RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.2))\n        )\n        .frame(minWidth: 160, maxWidth: 360)\n\n        // Repo authorization toggle (to the left of the edge)\n        let rootURL = vm.repoRoot ?? projectDirectory ?? workingDirectory\n        let authorized =\n          SecurityScopedBookmarks.shared.isSandboxed\n          ? SecurityScopedBookmarks.shared.hasDynamicBookmark(for: rootURL)\n          : true\n        if vm.repoRoot != nil || explorerRootExists,\n          SecurityScopedBookmarks.shared.isSandboxed\n        {\n          Button {\n            if authorized {\n              SecurityScopedBookmarks.shared.removeDynamic(url: rootURL)\n              NotificationCenter.default.post(name: .codMateRepoAuthorizationChanged, object: nil)\n            } else {\n              onRequestAuthorization?()\n            }\n          } label: {\n            Image(systemName: authorized ? \"checkmark.shield\" : \"exclamationmark.shield\")\n              .foregroundStyle(authorized ? .green : .orange)\n          }\n          .buttonStyle(.plain)\n          .help(authorized ? \"Revoke repository authorization\" : \"Authorize repository folder…\")\n        }\n\n        // Hidden keyboard shortcut to trigger commit confirmation via ⌘⏎\n        Button(\"\") {\n          let msg = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)\n          if !msg.isEmpty { showCommitConfirm = true }\n        }\n        .keyboardShortcut(.return, modifiers: .command)\n        .frame(width: 0, height: 0)\n        .opacity(0)\n      }\n      if vm.repoRoot == nil {\n        HStack(spacing: 6) {\n          Image(systemName: \"info.circle\")\n            .foregroundStyle(.secondary)\n          Text(\"Git repository not found. Explorer mode only.\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n          Spacer()\n        }\n      }\n      // Moved authorization controls inline in header path; remove separate row\n      if let err = vm.errorMessage, !err.isEmpty {\n        HStack(spacing: 6) {\n          Image(systemName: \"exclamationmark.triangle.fill\")\n            .foregroundStyle(.orange)\n          Text(err)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n          Spacer()\n        }\n        .padding(.vertical, 3)\n        .padding(.horizontal, 8)\n        .background(\n          RoundedRectangle(cornerRadius: 6)\n            .fill(Color.orange.opacity(0.08))\n        )\n      }\n    }\n  }\n}\n\n// MARK: - Header search helpers\nextension GitChangesPanel {\n  var searchPlaceholder: String {\n    switch mode {\n    case .graph: return \"Search commits\"\n    case .diff: return \"Search diff\"\n    case .browser: return \"Search preview\"\n    }\n  }\n\n  func onHeaderSearchChanged(_ newVal: String) {\n    let trimmed = newVal.trimmingCharacters(in: .whitespacesAndNewlines)\n    switch mode {\n    case .graph:\n      graphVM.searchQuery = trimmed\n      graphVM.applyFilter()\n    case .diff, .browser:\n      // Handled in detailView via AttributedTextView(searchQuery:)\n      break\n    }\n  }\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Helpers.swift",
    "content": "import SwiftUI\n#if canImport(AppKit)\nimport AppKit\n#endif\n\n// Shared helpers for Git file icons.\nenum GitFileIcon {\n    static func icon(for path: String) -> (name: String, color: Color) {\n        let ext = URL(fileURLWithPath: path).pathExtension.lowercased()\n        switch ext {\n        case \"swift\": return (\"swift\", .orange)\n        case \"md\": return (\"doc.text\", .green)\n        case \"json\": return (\"curlybraces\", .teal)\n        case \"yml\", \"yaml\": return (\"list.bullet\", .indigo)\n        case \"js\", \"ts\", \"tsx\", \"jsx\": return (\"chevron.left.slash.chevron.right\", .yellow)\n        case \"png\", \"jpg\", \"jpeg\", \"gif\", \"svg\": return (\"photo\", .purple)\n        case \"sh\", \"zsh\", \"bash\": return (\"terminal\", .gray)\n        default: return (\"doc.plaintext\", .secondary)\n        }\n    }\n}\n\nextension GitChangesPanel {\n    // MARK: - Helper functions for tree manipulation\n    func allDirectoryKeys(nodes: [FileNode]) -> [String] {\n        var keys: [String] = []\n        func walk(_ ns: [FileNode]) {\n            for n in ns {\n                if let d = n.dirPath { keys.append(d); if let cs = n.children { walk(cs) } }\n            }\n        }\n        walk(nodes)\n        return keys\n    }\n\n    func filteredNodes(_ nodes: [FileNode], query: String, contentMatches: Set<String>) -> [FileNode] {\n        let q = query.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !q.isEmpty else { return nodes }\n        func filter(_ ns: [FileNode]) -> [FileNode] {\n            var out: [FileNode] = []\n            for n in ns {\n                if n.isDirectory {\n                    let kids = n.children.map(filter) ?? []\n                    if n.name.localizedCaseInsensitiveContains(q) || !kids.isEmpty {\n                        var dir = n\n                        dir.children = kids\n                        out.append(dir)\n                    }\n                } else if let p = n.fullPath {\n                    let matchesPath = contentMatches.contains(p)\n                    if matchesPath\n                        || n.name.localizedCaseInsensitiveContains(q)\n                        || p.localizedCaseInsensitiveContains(q)\n                    {\n                        out.append(n)\n                    }\n                }\n            }\n            return out\n        }\n        return filter(nodes)\n    }\n\n    func isImagePath(_ path: String) -> Bool {\n        let ext = URL(fileURLWithPath: path).pathExtension.lowercased()\n        return [\"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"tiff\", \"tif\", \"heic\", \"heif\", \"webp\"].contains(ext)\n    }\n\n    // Expand a directory key to concrete file paths present in current change set\n    func filePaths(under dirKey: String) -> [String] {\n        let prefix = dirKey.hasSuffix(\"/\") ? dirKey : (dirKey + \"/\")\n        return vm.changes.map { $0.path }.filter { $0.hasPrefix(prefix) }\n    }\n\n    // All file paths belonging to a specific scope\n    func allPaths(in scope: TreeScope) -> [String] {\n        switch scope {\n        case .staged:\n            return vm.changes.compactMap { ($0.staged != nil) ? $0.path : nil }\n        case .unstaged:\n            return vm.changes.compactMap { ($0.worktree != nil) ? $0.path : nil }\n        }\n    }\n\n    func rebuildNodes() {\n        cachedNodesStaged = vm.treeSnapshot.staged\n        cachedNodesUnstaged = vm.treeSnapshot.unstaged\n        rebuildDisplayed()\n    }\n\n    func rebuildDisplayed() {\n        let trimmed = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n        let matches = trimmed.isEmpty ? Set<String>() : contentSearchMatches\n        displayedStaged = filteredNodes(cachedNodesStaged, query: treeQuery, contentMatches: matches)\n        displayedUnstaged = filteredNodes(cachedNodesUnstaged, query: treeQuery, contentMatches: matches)\n    }\n\n    // MARK: - Status helpers\n    func statusColor(for path: String) -> Color {\n        guard let change = vm.changes.first(where: { $0.path == path }) else {\n            return Color.secondary.opacity(0.3)\n        }\n        // Check if staged or worktree\n        if let _ = change.staged {\n            return Color.green.opacity(0.7)\n        } else if let kind = change.worktree {\n            switch kind {\n            case .modified: return Color.orange.opacity(0.7)\n            case .deleted: return Color.red.opacity(0.7)\n            case .untracked: return Color.green.opacity(0.7)\n            default: return Color.blue.opacity(0.7)\n            }\n        }\n        return Color.secondary.opacity(0.3)\n    }\n\n    // Simple file type icon mapping (shared with History views)\n    func fileTypeIconName(for path: String) -> (name: String, color: Color) {\n        GitFileIcon.icon(for: path)\n    }\n\n    // Helper: Status badge text\n    @ViewBuilder\n    func statusBadge(for change: GitService.Change) -> some View {\n        if let _ = change.staged {\n            badgeView(text: \"S\")\n        } else if let kind = change.worktree {\n            switch kind {\n            case .modified: badgeView(text: \"M\")\n            case .deleted: badgeView(text: \"D\")\n            case .untracked: badgeView(text: \"U\")\n            case .added: badgeView(text: \"A\")\n            }\n        }\n    }\n\n    func badgeView(text: String) -> some View {\n        Text(text)\n            .font(.system(size: 10, weight: .medium))\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(\n                RoundedRectangle(cornerRadius: 3)\n                    .fill(Color.secondary.opacity(0.1))\n            )\n    }\n\n\n    /// Expand all parent directories for a given file path in browser mode\n    func ensureBrowserPathExpanded(_ filePath: String) {\n        // Get all parent directory paths\n        var pathComponents = filePath.split(separator: \"/\").map(String.init)\n        pathComponents.removeLast() // Remove the file name itself\n\n        var currentPath = \"\"\n        for component in pathComponents {\n            if !currentPath.isEmpty {\n                currentPath += \"/\"\n            }\n            currentPath += component\n\n            // Add to expanded set if not already expanded\n            if !expandedDirsBrowser.contains(currentPath) {\n                expandedDirsBrowser.insert(currentPath)\n            }\n        }\n\n        // Rebuild the display to show expanded tree\n        rebuildBrowserDisplayed()\n    }\n\n#if canImport(AppKit)\n    func revealInFinder(path: String, isDirectory: Bool) {\n        let base = vm.repoRoot ?? projectDirectory ?? workingDirectory\n        let url = base.appendingPathComponent(path, isDirectory: isDirectory)\n        NSWorkspace.shared.activateFileViewerSelecting([url])\n    }\n#endif\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+LeftPane.swift",
    "content": "import SwiftUI\n\nextension GitChangesPanel {\n    var leftPane: some View {\n        VStack(spacing: 6) {\n            // Toolbar - Search fills\n            GeometryReader { _ in\n                let spacing: CGFloat = 8\n                HStack(spacing: spacing) {\n                    // Search box expands to fill — match Tasks column styling\n                    HStack(spacing: 6) {\n                        Image(systemName: \"magnifyingglass\")\n                            .foregroundStyle(.secondary)\n                            .padding(.leading, 4)\n                        TextField(\"Search\", text: $treeQuery)\n                            .textFieldStyle(.plain)\n                        if !treeQuery.isEmpty {\n                            Button {\n                                treeQuery = \"\"\n                            } label: {\n                                Image(systemName: \"xmark.circle.fill\")\n                                    .foregroundStyle(.tertiary)\n                            }\n                            .buttonStyle(.plain)\n                        }\n                    }\n                    .padding(.vertical, 6)\n                    .padding(.horizontal, 6)\n                    .background(\n                        RoundedRectangle(cornerRadius: 8, style: .continuous)\n                            .fill(Color(nsColor: .textBackgroundColor))\n                            .overlay(\n                                RoundedRectangle(cornerRadius: 8, style: .continuous)\n                                    .stroke(Color.secondary.opacity(0.25), lineWidth: 1)\n                            )\n                    )\n                    .frame(maxWidth: .infinity)\n\n                    // Collapse/Expand buttons (shared styling with Tasks column)\n                    CollapseExpandButtonGroup(\n                        onCollapse: {\n                            if mode == .browser {\n                                expandedDirsBrowser.removeAll()\n                            } else {\n                                expandedDirsStaged.removeAll()\n                                expandedDirsUnstaged.removeAll()\n                            }\n                        },\n                        onExpand: {\n                            if mode == .browser {\n                                expandedDirsBrowser = Set(allDirectoryKeys(nodes: browserNodes))\n                            } else {\n                                expandedDirsStaged = Set(allDirectoryKeys(nodes: cachedNodesStaged))\n                                expandedDirsUnstaged = Set(allDirectoryKeys(nodes: cachedNodesUnstaged))\n                            }\n                        }\n                    )\n                }\n                .frame(maxWidth: .infinity, alignment: .leading)\n            }\n            .frame(height: 32)\n\n            // Inline commit message (one line, auto-grow; no button)\n            // Show in Diff and History (graph) modes; hide in Explorer.\n            if mode != .browser {\n                GeometryReader { gr in\n                    ZStack(alignment: .topLeading) {\n                        TextEditor(text: $vm.commitMessage)\n                            .font(.system(.body))\n                            .codmatePlainTextEditorStyleIfAvailable()\n                            .frame(minHeight: 20)\n                            .frame(height: min(200, max(20, commitInlineHeight)))\n                            .padding(.leading, 6)\n                            .padding(.top, 6)\n                            .padding(.bottom, 6)\n                            .padding(.trailing, wandReservedTrailing)\n                            .overlay(\n                                RoundedRectangle(cornerRadius: 6)\n                                    .stroke(Color.secondary.opacity(0.25))\n                            )\n                        .onChange(of: vm.commitMessage, initial: true) { _ in\n                            // account for trailing reserve space\n                            let w = max(10, gr.size.width - 12 - wandReservedTrailing)\n                            commitInlineHeight = measureCommitHeight(vm.commitMessage, width: w)\n                        }\n                        if vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n                            Text(\"Press Command+Return to commit\")\n                                .foregroundStyle(.tertiary)\n                                .padding(.top, 6)\n                                .padding(.leading, 10)\n                                .allowsHitTesting(false)\n                        }\n\n                        // Wand button at top-right of the commit message box\n                        HStack { Spacer() }\n                            .overlay(alignment: .topTrailing) {\n                                Button {\n                                    vm.generateCommitMessage(providerId: preferences.commitProviderId, modelId: preferences.commitModelId)\n                                } label: {\n                                    Image(systemName: \"sparkles\")\n                                        .font(.system(size: 15, weight: .semibold))\n                                        .foregroundStyle(hoverWand ? Color.accentColor : Color.secondary)\n                                }\n                                .buttonStyle(.plain)\n                                .frame(width: wandButtonSize, height: wandButtonSize)\n                                .contentShape(Rectangle())\n                                .padding(.top, 4) // keep top-anchored; don't move when TextEditor grows\n                                .padding(.trailing, 4)\n                                .onHover { hoverWand = $0 }\n                                .opacity((vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path) ? 0.4 : 1.0)\n                                .animation((vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path) ? .easeInOut(duration: 0.8).repeatForever(autoreverses: true) : .default, value: vm.isGenerating)\n                                .disabled(vm.isGenerating && vm.generatingRepoPath == vm.repoRoot?.path)\n                                .help(\"AI generate commit message from staged changes\")\n                            }\n                    }\n                }\n                .frame(height: min(200, max(20, commitInlineHeight)) + 12)\n            }\n\n            // Trees in VS Code-style sections\n            ScrollView {\n                // In History (.graph) we still show the Diff tree list.\n                // Only Explorer mode uses the Explorer tree.\n                if mode != .browser {\n                    LazyVStack(alignment: .leading, spacing: 0) {\n                        // Staged section\n                        HStack(spacing: 6) {\n                            Button {\n                                stagedCollapsed.toggle()\n                            } label: {\n                                Image(systemName: stagedCollapsed ? \"chevron.right\" : \"chevron.down\")\n                                    .foregroundStyle(.secondary)\n                                    .font(.system(size: 11))\n                            }\n                            .buttonStyle(.plain)\n                            .frame(width: chevronWidth)\n                            Text(\"Staged Changes (\\(vm.changes.filter { $0.staged != nil }.count))\")\n                                .font(.subheadline)\n                                .foregroundStyle(.secondary)\n                            Spacer(minLength: 0)\n                        }\n                        .contentShape(Rectangle())\n                        .onTapGesture { stagedCollapsed.toggle() }\n                        .onHover { hoverStagedHeader = $0 }\n                        .background(\n                            RoundedRectangle(cornerRadius: 4)\n                                .fill(hoverStagedHeader ? Color.secondary.opacity(0.06) : Color.clear)\n                        )\n                        .frame(height: 22)\n                        .contextMenu {\n                            Button {\n                                let paths = allPaths(in: .staged)\n                                Task { await vm.unstage(paths: paths) }\n                            } label: {\n                                Label(\"Unstage All\", systemImage: \"minus.circle\")\n                            }\n                            Divider()\n                            Button {\n                                Task { await vm.refreshStatus() }\n                            } label: {\n                                Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                            }\n                        }\n                        if !stagedCollapsed {\n                            treeRows(nodes: displayedStaged, depth: 1, scope: .staged)\n                        }\n\n                        // Unstaged section\n                        HStack(spacing: 6) {\n                            Button { unstagedCollapsed.toggle() } label: {\n                                Image(systemName: unstagedCollapsed ? \"chevron.right\" : \"chevron.down\")\n                                    .foregroundStyle(.secondary)\n                                    .font(.system(size: 11))\n                            }\n                            .buttonStyle(.plain)\n                            .frame(width: chevronWidth)\n                            // Show all files with worktree changes, even if they also have staged changes (MM)\n                            Text(\"Changes (\\(vm.changes.filter { $0.worktree != nil }.count))\")\n                                .font(.subheadline)\n                                .foregroundStyle(.secondary)\n                            Spacer(minLength: 0)\n                        }\n                        .contentShape(Rectangle())\n                        .onTapGesture { unstagedCollapsed.toggle() }\n                        .onHover { hoverUnstagedHeader = $0 }\n                        .background(\n                            RoundedRectangle(cornerRadius: 4)\n                                .fill(hoverUnstagedHeader ? Color.secondary.opacity(0.06) : Color.clear)\n                        )\n                        .frame(height: 22)\n                        .contextMenu {\n                            Button {\n                                let paths = allPaths(in: .unstaged)\n                                Task { await vm.stage(paths: paths) }\n                            } label: {\n                                Label(\"Stage All\", systemImage: \"plus.circle\")\n                            }\n                            Divider()\n                            Button {\n                                Task { await vm.refreshStatus() }\n                            } label: {\n                                Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                            }\n                        }\n                        if !unstagedCollapsed {\n                            treeRows(nodes: displayedUnstaged, depth: 1, scope: .unstaged)\n                        }\n                    }\n                } else {\n                    browserTreeView\n                }\n            }\n            // Provide a generic context menu on empty area as well\n            .contextMenu {\n                Button {\n                    let paths = allPaths(in: .unstaged)\n                    Task { await vm.stage(paths: paths) }\n                } label: {\n                    Label(\"Stage All\", systemImage: \"plus.circle\")\n                }\n                Button {\n                    let paths = allPaths(in: .staged)\n                    Task { await vm.unstage(paths: paths) }\n                } label: {\n                    Label(\"Unstage All\", systemImage: \"minus.circle\")\n                }\n                Divider()\n                Button {\n                    Task { await vm.refreshStatus() }\n                } label: {\n                    Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                }\n            }\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n        // Add inner padding to prevent controls from hugging edges\n        .padding(16)\n    }\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Lifecycle.swift",
    "content": "import SwiftUI\n\nextension GitChangesPanel {\n    // MARK: - Lifecycle Modifier\n    struct LifecycleModifier: ViewModifier {\n        @Binding var expandedDirsStaged: Set<String>\n        @Binding var expandedDirsUnstaged: Set<String>\n        @Binding var expandedDirsBrowser: Set<String>\n        @Binding var savedState: ReviewPanelState\n        @Binding var mode: ReviewPanelState.Mode\n        let vm: GitChangesViewModel\n        let treeQuery: String\n        let onSearchQueryChanged: (String) -> Void\n        let onRebuildNodes: () -> Void\n        let onRebuildDisplayed: () -> Void\n        let onEnsureExpandAll: () -> Void\n        let onRebuildBrowserDisplayed: () -> Void\n        let onRefreshBrowserTree: () -> Void\n\n        func body(content: Content) -> some View {\n            var view = AnyView(\n                content.onAppear {\n                    restoreState()\n                    onRebuildNodes()\n                    onRebuildDisplayed()\n                    onRebuildBrowserDisplayed()\n                    onEnsureExpandAll()\n                    onSearchQueryChanged(treeQuery)\n                    if mode == .browser { onRefreshBrowserTree() }\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: vm.treeSnapshot) { _ in\n                    onRebuildNodes()\n                    onEnsureExpandAll()\n                    if mode == .browser { onRefreshBrowserTree() }\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: treeQuery) { newValue in\n                    onSearchQueryChanged(newValue)\n                    onRebuildDisplayed()\n                    onRebuildBrowserDisplayed()\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: expandedDirsStaged) { newVal in\n                    savedState.expandedDirsStaged = newVal\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: expandedDirsUnstaged) { newVal in\n                    savedState.expandedDirsUnstaged = newVal\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: expandedDirsBrowser) { newVal in\n                    savedState.expandedDirsBrowser = newVal\n                    onRebuildBrowserDisplayed()\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: vm.selectedPath) { newVal in\n                    savedState.selectedPath = newVal\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: vm.selectedSide) { newVal in\n                    savedState.selectedSideStaged = (newVal == .staged)\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: vm.showPreviewInsteadOfDiff) { newVal in\n                    savedState.showPreview = newVal\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: vm.commitMessage) { newVal in\n                    savedState.commitMessage = newVal\n                }\n            )\n\n            view = AnyView(\n                view.onChange(of: mode) { newVal in\n                    savedState.mode = newVal\n                    if newVal == .browser {\n                        onRebuildBrowserDisplayed()\n                        onRefreshBrowserTree()\n                    }\n                }\n            )\n\n            // Persist Graph visibility flag when it changes\n            view = AnyView(\n                view.onChange(of: savedState.showGraph) { _ in\n                    // No-op: wiring point retained for completeness\n                }\n            )\n\n            return view\n        }\n\n        private func restoreState() {\n            var initial = savedState\n            // Migrate legacy browser mode to diff mode:\n            // Since the default mode has changed from .browser to .diff,\n            // automatically migrate any saved .browser state to .diff.\n            // User can still manually switch to browser or graph if needed.\n            if initial.mode == .browser {\n                initial.mode = .diff\n                savedState = initial\n            }\n\n            if !initial.expandedDirsStaged.isEmpty || !initial.expandedDirsUnstaged.isEmpty {\n                expandedDirsStaged = initial.expandedDirsStaged\n                expandedDirsUnstaged = initial.expandedDirsUnstaged\n            } else if !initial.expandedDirs.isEmpty {\n                expandedDirsStaged = initial.expandedDirs\n                expandedDirsUnstaged = initial.expandedDirs\n            }\n            if !initial.expandedDirsBrowser.isEmpty {\n                expandedDirsBrowser = initial.expandedDirsBrowser\n            }\n            mode = initial.mode\n            vm.selectedPath = initial.selectedPath\n            if let stagedSide = initial.selectedSideStaged {\n                vm.selectedSide = stagedSide ? .staged : .unstaged\n            }\n            vm.showPreviewInsteadOfDiff = initial.showPreview\n            let savedMessage = initial.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)\n            let liveMessage = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)\n            if !liveMessage.isEmpty && liveMessage != savedMessage {\n                savedState.commitMessage = vm.commitMessage\n            } else {\n                vm.commitMessage = initial.commitMessage\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel+Menus.swift",
    "content": "import SwiftUI\n\n// MARK: - Lightweight menu list for popovers\nstruct PopMenuItem: Identifiable {\n    enum Role { case normal, destructive }\n    let id = UUID()\n    var title: String\n    var role: Role = .normal\n    var action: () -> Void\n}\n\nstruct PopMenuList: View {\n    var items: [PopMenuItem]\n    var tail: [PopMenuItem] = [] // optional trailing group separated by a divider\n    @State private var hovered: UUID? = nil\n\n    var body: some View {\n        VStack(spacing: 0) {\n            groupView(items)\n            if !tail.isEmpty {\n                Divider().padding(.vertical, 4)\n                groupView(tail)\n            }\n        }\n        .padding(6)\n    }\n\n    @ViewBuilder\n    private func groupView(_ group: [PopMenuItem]) -> some View {\n        ForEach(group) { item in\n            Button(action: item.action) {\n                HStack(spacing: 8) {\n                    Text(item.title)\n                        .foregroundStyle(item.role == .destructive ? Color.red : Color.primary)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                }\n                .padding(.horizontal, 8)\n                .frame(height: 24)\n                .background(\n                    RoundedRectangle(cornerRadius: 5)\n                        .fill(hovered == item.id ? Color.accentColor.opacity(0.12) : Color.clear)\n                )\n            }\n            .buttonStyle(.plain)\n            .onHover { inside in hovered = inside ? item.id : (hovered == item.id ? nil : hovered) }\n        }\n    }\n}\n"
  },
  {
    "path": "views/GitChanges/GitChangesPanel.swift",
    "content": "import SwiftUI\n\n#if canImport(AppKit)\n  import AppKit\n#endif\n\nstruct GitChangesPanel: View {\n  enum Presentation { case embedded, full }\n  enum RegionLayout { case combined, leftOnly, rightOnly }\n  let workingDirectory: URL\n  let projectDirectory: URL?\n  var presentation: Presentation = .embedded\n  var regionLayout: RegionLayout = .combined\n  let preferences: SessionPreferencesStore\n  var onRequestAuthorization: (() -> Void)? = nil\n  var refreshToken: Int = 0\n  @Binding var savedState: ReviewPanelState\n  @ObservedObject var vm: GitChangesViewModel\n  // Layout state\n  @State var leftColumnWidth: CGFloat = 0  // 0 = init to 1/4 of container\n  @State var commitEditorHeight: CGFloat = 28\n  // Tree state (keep staged/unstaged expansions independent)\n  @State var expandedDirsStaged: Set<String> = []\n  @State var expandedDirsUnstaged: Set<String> = []\n  @State var treeQuery: String = \"\"\n  // Cached trees for performance\n  @State var cachedNodesStaged: [FileNode] = []\n  @State var cachedNodesUnstaged: [FileNode] = []\n  @State var displayedStaged: [FileNode] = []\n  @State var displayedUnstaged: [FileNode] = []\n  @State var stagedCollapsed: Bool = false\n  @State var unstagedCollapsed: Bool = false\n  @State var commitInlineHeight: CGFloat = 20\n  @State var mode: ReviewPanelState.Mode = .diff\n  @State var expandedDirsBrowser: Set<String> = []\n  @State var browserNodes: [FileNode] = []\n  @State var displayedBrowserRows: [BrowserRow] = []\n  @State var isLoadingBrowserTree: Bool = false\n  @State var browserTreeError: String? = nil\n  @State var browserTreeTruncated: Bool = false\n  @State var browserTotalEntries: Int = 0\n  @State var browserTreeTask: Task<Void, Never>? = nil\n  // Hover state for quick actions\n  @State var hoverFilePath: String? = nil\n  @State var hoverDirKey: String? = nil\n  @State var hoverEditPath: String? = nil\n  @State var hoverRevertPath: String? = nil\n  @State var hoverStagePath: String? = nil\n  @State var hoverDirButtonPath: String? = nil\n  @State var hoverBrowserFilePath: String? = nil\n  @State var hoverBrowserRevealPath: String? = nil\n  @State var hoverBrowserEditPath: String? = nil\n  @State var hoverBrowserStagePath: String? = nil\n  @State var hoverBrowserDirKey: String? = nil\n  @State var hoverStagedHeader: Bool = false\n  @State var hoverUnstagedHeader: Bool = false\n  @State var pendingDiscardPaths: [String] = []\n  @State var pendingDiscardIncludesStaged: Bool = false\n  @State var showDiscardAlert: Bool = false\n  @State var showCommitConfirm: Bool = false\n  // Graph view toggle + model\n  @State var showGraph: Bool = false\n  @StateObject var graphVM = GitGraphViewModel()\n  @State var historyDetailCommit: GitService.GraphCommit? = nil\n  // Use an optional Int for segmented momentary actions: 0=collapse, 1=expand\n  // @State private var treeToggleIndex: Int? = nil\n  // Layout constraints\n  let leftMin: CGFloat = 280\n  let leftMax: CGFloat = 520\n  let commitMinHeight: CGFloat = 140\n  // Indent guide metrics (horizontal):\n  // - indentStep: per-depth indent distance (matches VS Code's 16px)\n  // - chevronWidth: width reserved for disclosure chevron\n  let indentStep: CGFloat = 16\n  let chevronWidth: CGFloat = 16\n  let quickActionWidth: CGFloat = 18\n  let quickActionHeight: CGFloat = 16\n  let trailingPad: CGFloat = 8\n  let hoverButtonSpacing: CGFloat = 8\n  let statusBadgeWidth: CGFloat = 18\n  let browserEntryLimit: Int = 6000\n  let repoContentMatchLimit: Int = 4000\n  // Viewer options (from Settings › Git Review). Defaults: line numbers ON, wrap OFF\n  var wrapText: Bool { preferences.gitWrapText }\n  var showLineNumbers: Bool { preferences.gitShowLineNumbers }\n  // Wand button metrics\n  let wandButtonSize: CGFloat = 24\n  var wandReservedTrailing: CGFloat { wandButtonSize }  // equal-width indent to avoid overlap\n  @State var hoverWand: Bool = false\n  @State private var diffModePreviewPreference: Bool = false\n  @State private var forcedBrowserDueToMissingRepo = false\n  @State var contentSearchMatches: Set<String> = []\n  @State private var contentSearchTask: Task<Void, Never>? = nil\n  @State private var contentSearchQueryVersion: UInt64 = 0\n  // Unified header search\n  @State var headerSearchQuery: String = \"\"\n  #if canImport(AppKit)\n    @State var previewImage: NSImage? = nil\n    @State var previewImageTask: Task<Void, Never>? = nil\n  #endif\n  private let repoSearchService = RepoContentSearchService()\n\n  var body: some View {\n    var view = AnyView(rootContent)\n    view = AnyView(view.onReceive(NotificationCenter.default.publisher(for: .codMateRepoAuthorizationChanged)) { _ in\n      // Only the left/combined pane should trigger repo re-attachment\n      if regionLayout == .combined || regionLayout == .leftOnly {\n        vm.attach(to: workingDirectory)\n      }\n    })\n    view = AnyView(view.alert(\"Discard changes?\", isPresented: $showDiscardAlert) {\n      Button(\"Discard\", role: .destructive) {\n        let paths = pendingDiscardPaths\n        let includeStaged = pendingDiscardIncludesStaged\n        pendingDiscardPaths = []\n        pendingDiscardIncludesStaged = true\n        Task { await vm.discard(paths: paths, includeStaged: includeStaged) }\n      }\n      Button(\"Cancel\", role: .cancel) {\n        pendingDiscardPaths = []\n        pendingDiscardIncludesStaged = true\n      }\n    } message: {\n      let count = pendingDiscardPaths.count\n      if pendingDiscardIncludesStaged {\n        Text(\n          \"This will permanently discard staged and unstaged changes for \\(count) file\\(count == 1 ? \"\" : \"s\").\"\n        )\n      } else {\n        Text(\n          \"This will permanently discard unstaged changes for \\(count) file\\(count == 1 ? \"\" : \"s\"). Staged changes (if any) will be preserved.\"\n        )\n      }\n    })\n    view = AnyView(view.confirmationDialog(\n      \"Commit changes?\",\n      isPresented: $showCommitConfirm,\n      titleVisibility: .visible\n    ) {\n      Button(\"Commit\", role: .destructive) { Task { await vm.commit() } }\n      Button(\"Cancel\", role: .cancel) {}\n    } message: {\n      let msg = vm.commitMessage.trimmingCharacters(in: .whitespacesAndNewlines)\n      if msg.isEmpty {\n        Text(\"This will create a commit for staged changes.\")\n      } else {\n        Text(\"Commit message:\\n\\n\\(msg)\")\n      }\n    })\n    view = AnyView(view.task(id: workingDirectory) {\n      // Avoid double attach from both halves; left/combined is the source of truth\n      if regionLayout == .combined || regionLayout == .leftOnly {\n        vm.attach(to: workingDirectory, fallbackProjectDirectory: projectDirectory)\n      }\n    })\n    view = AnyView(view.task(id: vm.repoRoot?.path) {\n      browserNodes = []\n      displayedBrowserRows = []\n      browserTreeError = nil\n      if (regionLayout == .combined || regionLayout == .leftOnly) && mode == .browser {\n        reloadBrowserTreeIfNeeded(force: true)\n      }\n      let trimmed = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n      if (regionLayout == .combined || regionLayout == .leftOnly) && !trimmed.isEmpty {\n        await MainActor.run {\n          handleTreeQueryChange(treeQuery)\n        }\n      }\n    })\n    view = AnyView(view.onChange(of: mode) { newMode in\n      if newMode == .browser {\n        if regionLayout == .combined || regionLayout == .leftOnly {\n          reloadBrowserTreeIfNeeded()\n          if let selectedPath = vm.selectedPath { ensureBrowserPathExpanded(selectedPath) }\n        }\n      }\n    })\n    view = AnyView(view.onChange(of: refreshToken) { _ in\n      switch mode {\n      case .diff:\n        Task { await vm.refreshStatusIfNeeded(refreshToken: refreshToken) }\n      case .browser:\n        Task { await vm.refreshStatusIfNeeded(refreshToken: refreshToken) }\n        reloadBrowserTreeIfNeeded(force: true)\n      case .graph:\n        graphVM.triggerRefresh()\n      }\n    })\n    view = AnyView(\n      view.modifier(\n        LifecycleModifier(\n          expandedDirsStaged: $expandedDirsStaged,\n          expandedDirsUnstaged: $expandedDirsUnstaged,\n          expandedDirsBrowser: $expandedDirsBrowser,\n          savedState: $savedState,\n          mode: $mode,\n          vm: vm,\n          treeQuery: treeQuery,\n          onSearchQueryChanged: { handleTreeQueryChange($0) },\n          onRebuildNodes: rebuildNodes,\n          onRebuildDisplayed: rebuildDisplayed,\n          onEnsureExpandAll: ensureExpandAllIfNeeded,\n          onRebuildBrowserDisplayed: rebuildBrowserDisplayed,\n          onRefreshBrowserTree: { reloadBrowserTreeIfNeeded(force: false) }\n        )\n      )\n    )\n    view = AnyView(view.onDisappear {\n      contentSearchTask?.cancel()\n      contentSearchTask = nil\n    })\n    view = AnyView(view.onAppear {\n      diffModePreviewPreference = vm.showPreviewInsteadOfDiff\n      // Restore mode on appear\n      mode = savedState.mode\n    })\n    view = AnyView(view.onChange(of: savedState.mode) { newVal in\n      if mode != newVal { mode = newVal }\n    })\n    view = AnyView(view.onChange(of: vm.showPreviewInsteadOfDiff) { newValue in\n      if mode == .diff {\n        diffModePreviewPreference = newValue\n      }\n    })\n    view = AnyView(view.onChange(of: mode) { newMode in\n      switch newMode {\n      case .browser:\n        // Explorer always shows preview on the right\n        if !vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = true }\n      case .diff:\n        // Diff mode must always render diff view\n        if vm.showPreviewInsteadOfDiff { vm.showPreviewInsteadOfDiff = false }\n      case .graph:\n        // no-op; detail rendering managed by Graph container\n        break\n      }\n      savedState.mode = newMode\n    })\n    view = AnyView(view.onChange(of: vm.repoRoot) { newRoot in\n      if newRoot == nil {\n        forcedBrowserDueToMissingRepo = true\n        if mode != .browser {\n          mode = .browser\n        }\n        if !vm.showPreviewInsteadOfDiff {\n          vm.showPreviewInsteadOfDiff = true\n        }\n      } else if forcedBrowserDueToMissingRepo {\n        forcedBrowserDueToMissingRepo = false\n        let target = savedState.mode\n        mode = target\n        if target == .diff {\n          vm.showPreviewInsteadOfDiff = diffModePreviewPreference\n        } else if !vm.showPreviewInsteadOfDiff {\n          vm.showPreviewInsteadOfDiff = true\n        }\n      }\n    })\n    view = AnyView(view.onChange(of: vm.selectedPath) { _ in })\n    view = AnyView(view.onChange(of: leftColumnWidth) { newW in\n      WindowStateStore().saveReviewLeftPaneWidth(newW)\n    })\n    return view\n  }\n\n  private var rootContent: some View {\n    Group {\n      if vm.repoRoot == nil && vm.isResolvingRepo {\n        VStack(spacing: 16) {\n          ProgressView()\n          Text(\"Resolving repository access…\")\n            .font(.headline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if !explorerRootExists && vm.repoRoot == nil {\n        VStack(spacing: 12) {\n          Image(systemName: \"lock.rectangle.on.rectangle\")\n            .font(.system(size: 42))\n            .foregroundStyle(.secondary)\n          Text(\"Git Review Unavailable\")\n            .font(.headline)\n          Text(\n            \"This folder is either not a Git repository or requires permission. Authorize the repository root (the folder containing .git).\"\n          )\n          .font(.subheadline)\n          .multilineTextAlignment(.center)\n          .foregroundStyle(.secondary)\n          .frame(maxWidth: 520)\n          Button(\"Authorize Repository Folder…\") {\n            onRequestAuthorization?()\n          }\n          .buttonStyle(.borderedProminent)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        contentWithPresentation\n      }\n    }\n  }\n\n  private var contentWithPresentation: some View {\n    Group {\n      switch presentation {\n      case .embedded:\n        baseContent\n          .background(.thinMaterial)\n          .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))\n          .padding(8)\n      case .full:\n        baseContent\n      }\n    }\n  }\n\n  // Extracted heavy content to reduce body type-checking complexity\n  private var baseContent: some View {\n    VStack(alignment: .leading, spacing: 0) {\n      switch regionLayout {\n      case .combined:\n        if mode == .graph, historyDetailCommit != nil {\n          // History Details mode: hide left tree, use full width for History list + detail split\n          historyDetailRoot\n        } else {\n          header\n            .padding(.horizontal, 16)\n            .padding(.vertical, 12)\n          Divider()\n          VSplitView {\n            GeometryReader { geo in\n              splitContent(totalWidth: geo.size.width)\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n                .onAppear {\n                  if leftColumnWidth == 0 {\n                    let store = WindowStateStore()\n                    if let saved = store.restoreReviewLeftPaneWidth() {\n                      leftColumnWidth = clampLeftWidth(saved, total: geo.size.width)\n                    } else {\n                      leftColumnWidth = clampLeftWidth(geo.size.width * 0.25, total: geo.size.width)\n                    }\n                  }\n                }\n            }\n          }\n        }\n      case .leftOnly:\n        // Left tree + commit inline; omit header/graph and any right detail\n        leftPane\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      case .rightOnly:\n        // Header + divider + detail (matching Tasks mode layout)\n        VStack(spacing: 0) {\n          header\n            .padding(.horizontal, 16)\n            .padding(.vertical, 12)\n\n          Divider()\n\n          if mode == .graph, historyDetailCommit != nil {\n            historyDetailBody\n              .frame(maxWidth: .infinity, maxHeight: .infinity)\n          } else if mode == .graph {\n            graphDetailView\n              .frame(maxWidth: .infinity, maxHeight: .infinity)\n          } else {\n            detailView\n              .frame(maxWidth: .infinity, maxHeight: .infinity)\n          }\n        }\n      }\n    }\n  }\n\n  // MARK: - History detail layout (History mode, commit details)\n\n  /// Combined layout root when left tree is hidden and full width is used for History list + detail.\n  private var historyDetailRoot: some View {\n    VStack(spacing: 0) {\n      header\n        .padding(.horizontal, 16)\n        .padding(.vertical, 12)\n      Divider()\n      historyDetailBody\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n    }\n  }\n\n  /// Right-side body: horizontally split between compact History list and commit detail.\n  private var historyDetailBody: some View {\n    HSplitView {\n      historyDetailListPane\n      historyDetailPane\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n\n  /// Left pane in History Details mode: compact History list (graph + description only).\n  private var historyDetailListPane: some View {\n    graphListView(compactColumns: true) { commit in\n      historyDetailCommit = commit\n    }\n  }\n\n  /// Right pane in History Details mode: commit meta + files tree + diff viewer.\n  @ViewBuilder\n  private var historyDetailPane: some View {\n    if let commit = historyDetailCommit {\n      HistoryCommitDetailView(\n        commit: commit,\n        viewModel: graphVM,\n        onClose: {\n          historyDetailCommit = nil\n        },\n        wrap: wrapText,\n        showLineNumbers: showLineNumbers\n      )\n    } else {\n      VStack {\n        Text(\"No commit selected\")\n          .foregroundStyle(.secondary)\n      }\n      .frame(maxWidth: .infinity, maxHeight: .infinity)\n    }\n  }\n\n  private func splitContent(totalWidth: CGFloat) -> some View {\n    // Top split: left file tree and right diff/preview, with draggable divider\n    let leftW = effectiveLeftWidth(total: totalWidth)\n    let gutterW: CGFloat = 33  // divider 1pt + 8pt padding each side\n    let rightW = max(totalWidth - gutterW - leftW, 240)\n    return HStack(spacing: 0) {\n      leftPane\n        .frame(width: leftW)\n        .frame(minWidth: leftMin, maxWidth: leftMax)\n      // Visible divider with padding; whole gutter is draggable\n      HStack(spacing: 0) {\n        Color.clear.frame(width: 8)\n        Divider().frame(width: 1)\n        Color.clear.frame(width: 8)\n      }\n      .frame(width: gutterW)\n      .frame(maxHeight: .infinity)\n      .contentShape(Rectangle())\n      .gesture(\n        DragGesture(minimumDistance: 1).onChanged { value in\n          let newW = clampLeftWidth(leftColumnWidth + value.translation.width, total: totalWidth)\n          leftColumnWidth = newW\n        }\n      )\n      .onHover { inside in\n        #if canImport(AppKit)\n          if inside { NSCursor.resizeLeftRight.set() } else { NSCursor.arrow.set() }\n        #endif\n      }\n      Group {\n        if mode == .graph {\n          graphDetailView\n        } else {\n          detailView\n        }\n      }\n      .padding(16)\n      .frame(width: rightW)\n      .frame(maxHeight: .infinity)\n    }\n  }\n\n  @MainActor\n  private func handleTreeQueryChange(_ query: String) {\n    contentSearchTask?.cancel()\n    contentSearchTask = nil\n    let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else {\n      contentSearchMatches = []\n      return\n    }\n    guard let root = vm.repoRoot else {\n      contentSearchMatches = []\n      return\n    }\n    contentSearchMatches = []\n    contentSearchQueryVersion &+= 1\n    let version = contentSearchQueryVersion\n    let service = repoSearchService\n    contentSearchTask = Task {\n      try? await Task.sleep(nanoseconds: 200_000_000)\n      if Task.isCancelled { return }\n      do {\n        let matches = try await service.searchFilesContaining(\n          trimmed, in: root, limit: repoContentMatchLimit)\n        if Task.isCancelled { return }\n        await MainActor.run {\n          if version == contentSearchQueryVersion {\n            contentSearchMatches = matches\n            rebuildDisplayed()\n            rebuildBrowserDisplayed()\n            contentSearchTask = nil\n          }\n        }\n      } catch is CancellationError {\n        // Ignore cancellation; another task will replace it\n      } catch {\n        await MainActor.run {\n          if version == contentSearchQueryVersion {\n            contentSearchMatches = []\n            contentSearchTask = nil\n          }\n        }\n      }\n    }\n  }\n\n  private func ensureExpandAllIfNeeded() {\n    if expandedDirsStaged.isEmpty {\n      expandedDirsStaged = Set(allDirectoryKeys(nodes: cachedNodesStaged))\n    }\n    if expandedDirsUnstaged.isEmpty {\n      expandedDirsUnstaged = Set(allDirectoryKeys(nodes: cachedNodesUnstaged))\n    }\n  }\n\n  // MARK: - Layout helpers\n  private func clampLeftWidth(_ proposed: CGFloat, total: CGFloat) -> CGFloat {\n    let minW = leftMin\n    let maxW = min(leftMax, total - 240)  // keep space for right pane + gutter\n    return max(minW, min(maxW, proposed))\n  }\n  private func effectiveLeftWidth(total: CGFloat) -> CGFloat {\n    let w = (leftColumnWidth == 0) ? total * 0.25 : leftColumnWidth\n    return clampLeftWidth(w, total: total)\n  }\n\n  var explorerRootExists: Bool {\n    FileManager.default.fileExists(atPath: explorerRoot.path)\n  }\n\n  var explorerRoot: URL {\n    projectDirectory ?? workingDirectory\n  }\n\n  // Measure dynamic height for inline commit editor based on width\n  func measureCommitHeight(_ text: String, width: CGFloat) -> CGFloat {\n    #if canImport(AppKit)\n      let font = NSFont.systemFont(ofSize: NSFont.systemFontSize)\n      let s = text.isEmpty ? \" \" : text\n      let rect = (s as NSString).boundingRect(\n        with: NSSize(width: width, height: CGFloat.greatestFiniteMagnitude),\n        options: [.usesLineFragmentOrigin, .usesFontLeading],\n        attributes: [.font: font]\n      )\n      return max(20, ceil(rect.height))\n    #else\n      return 20\n    #endif\n  }\n\n  // MARK: - File tree (grouped by directories)\n  typealias FileNode = GitReviewNode\n\n  // MARK: - TreeScope enum\n  enum TreeScope { case unstaged, staged }\n\n}\n"
  },
  {
    "path": "views/GitReviewSettingsView.swift",
    "content": "import SwiftUI\n\nstruct GitReviewSettingsView: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n\n  @StateObject private var providerCatalog = UnifiedProviderCatalogModel()\n  @State private var draftTemplate: String = \"\"\n  @State private var providerId: String? = nil\n  @State private var modelId: String? = nil\n  @State private var modelList: [String] = []\n  @State private var lastProviderId: String? = nil\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 20) {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(\"Git Review Settings\").font(.title2).fontWeight(.bold)\n        Text(\"Customize Git changes viewer and AI commit generation.\")\n          .font(.subheadline)\n          .foregroundColor(.secondary)\n      }\n\n      VStack(alignment: .leading, spacing: 10) {\n        Text(\"Display\").font(.headline).fontWeight(.semibold)\n        settingsCard {\n          Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            GridRow {\n              VStack(alignment: .leading, spacing: 2) {\n                Label(\"Show Line Numbers\", systemImage: \"list.number\")\n                  .font(.subheadline).fontWeight(.medium)\n                Text(\"Show line numbers in diffs.\")\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n              }\n              Toggle(\"\", isOn: $preferences.gitShowLineNumbers)\n                .labelsHidden().toggleStyle(.switch).controlSize(.small)\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            gridDivider\n            GridRow {\n              VStack(alignment: .leading, spacing: 2) {\n                Label(\"Wrap Long Lines\", systemImage: \"text.line.first.and.arrowtriangle.forward\")\n                  .font(.subheadline).fontWeight(.medium)\n                Text(\"Enable soft wrap in diff viewer.\")\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n              }\n              Toggle(\"\", isOn: $preferences.gitWrapText)\n                .labelsHidden().toggleStyle(.switch).controlSize(.small)\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n          }\n        }\n      }\n\n      VStack(alignment: .leading, spacing: 10) {\n        Text(\"Generate\").font(.headline).fontWeight(.semibold)\n        settingsCard {\n          Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            GridRow {\n              VStack(alignment: .leading, spacing: 2) {\n                Label(\"Commit Model\", systemImage: \"brain\")\n                  .font(.subheadline).fontWeight(.medium)\n                Text(\"Select a model from Auto-Proxy mode.\")\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n              }\n              // Model picker with sanitized names and provider icons\n              SimpleModelPicker(\n                models: modelList,\n                isDisabled: !providerCatalog.isProviderAvailable(providerId),\n                providerId: providerId,\n                providerCatalog: providerCatalog,\n                modelId: $modelId\n              )\n              .frame(maxWidth: .infinity, alignment: .trailing)\n              .onChange(of: modelId) { newVal in\n                preferences.commitModelId = newVal\n              }\n            }\n            gridDivider\n            // Prompt template placed last\n            GridRow {\n              VStack(alignment: .leading, spacing: 2) {\n                Label(\"Commit Message Prompt Template\", systemImage: \"text.bubble\")\n                  .font(.subheadline).fontWeight(.medium)\n                Text(\n                  \"Optional preamble used before the diff when generating commit messages. Leave blank to use the built‑in prompt.\"\n                )\n                .font(.caption)\n                .foregroundStyle(.secondary)\n                .fixedSize(horizontal: false, vertical: true)\n                .padding(.bottom, 8)\n                TextEditor(text: $draftTemplate)\n                  .font(.system(.body))\n                  .frame(height: 320)\n                  .padding(4)\n                  .overlay(\n                    RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.25))\n                  )\n                  .onChange(of: draftTemplate) { newVal in\n                    preferences.commitPromptTemplate = newVal\n                  }\n              }\n              .gridCellColumns(2)\n            }\n          }\n        }\n      }\n\n      // Repository authorization has moved to on-demand prompts in Review.\n      // The settings page no longer manages a global list to reduce clutter.\n    }\n    .onAppear {\n      draftTemplate = preferences.commitPromptTemplate\n      providerId = preferences.commitProviderId\n      modelId = preferences.commitModelId\n      Task { await reloadCatalog() }\n    }\n    // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode\n    .onChange(of: preferences.oauthProvidersEnabled) { _ in\n      Task { await reloadCatalog() }\n    }\n    .onChange(of: preferences.apiKeyProvidersEnabled) { _ in\n      Task {\n        await reloadCatalog(forceRefresh: true)\n      }\n    }\n    .onChange(of: CLIProxyService.shared.isRunning) { _ in\n      Task { await reloadCatalog() }\n    }\n  }\n\n  @ViewBuilder\n  private var gridDivider: some View {\n    Divider()\n  }\n\n  @ViewBuilder\n  private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      content()\n    }\n    .padding(10)\n    .background(Color(nsColor: .separatorColor).opacity(0.35))\n    .cornerRadius(10)\n  }\n\n  private func reloadCatalog(forceRefresh: Bool = false) async {\n    await providerCatalog.reload(preferences: preferences, forceRefresh: forceRefresh)\n    normalizeSelection()\n  }\n\n  private func normalizeSelection() {\n    // Git Review always uses Auto-Proxy mode\n    providerId = UnifiedProviderID.autoProxyId\n    preferences.commitProviderId = UnifiedProviderID.autoProxyId\n\n    modelList = providerCatalog.models(for: providerId)\n    let providerChanged = lastProviderId != nil && lastProviderId != providerId\n    lastProviderId = providerId\n    if providerChanged {\n      modelId = nil\n      preferences.commitModelId = nil\n      return\n    }\n    guard !modelList.isEmpty else {\n      return\n    }\n    let current = preferences.commitModelId\n    let nextModel = (current != nil && modelList.contains(current ?? \"\")) ? current : nil\n    modelId = nextModel\n    if nextModel == nil {\n      preferences.commitModelId = nil\n    }\n  }\n\n}\n\n// Authorized repositories list has been removed from Settings.\n"
  },
  {
    "path": "views/HookEditSheet.swift",
    "content": "import AppKit\nimport SwiftUI\nimport UniformTypeIdentifiers\n\nstruct HookEditSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  let rule: HookRule?\n  let onSave: (HookRule) -> Void\n  let onCancel: () -> Void\n\n  @State private var name: String = \"\"\n  @State private var descriptionText: String = \"\"\n  @State private var enabled: Bool = true\n  @State private var selectedEvent: String = \"\"\n  @State private var customEvent: String = \"\"\n  @State private var matcher: String = \"\"\n  @State private var targets: HookTargets = HookTargets()\n  @State private var commands: [EditableHookCommand] = []\n  @State private var selectedTab: Int = 0\n  @State private var errorMessage: String?\n  @State private var hoveringCommandIds: Set<UUID> = []\n  @State private var pendingDeleteCommand: PendingCommandDelete?\n  @State private var eventPickerPresented: Bool = false\n  @State private var eventQuery: String = \"\"\n  @State private var eventFilter: HookEventFilter = .all\n  @State private var variablePicker: VariablePickerContext?\n  @State private var variableQuery: String = \"\"\n  @State private var variableFilter: HookVariableFilter = .all\n  @State private var wizardActive: Bool = false\n  @State private var didHydrate: Bool = false\n  @FocusState private var focusedField: FocusField?\n  @FocusState private var eventSearchFocused: Bool\n  private let variablePopoverSize: CGSize = CGSize(width: 360, height: 380)\n  private let eventPopoverSize: CGSize = CGSize(width: 360, height: 380)\n  @FocusState private var variableSearchFocused: Bool\n\n  private enum FocusField {\n    case name\n  }\n\n  private let customEventKey = \"__custom__\"\n  private let sheetMaxHeight: CGFloat = 560\n  private let generalRowMinHeight: CGFloat = 28\n\n  var body: some View {\n    if wizardActive {\n      HookWizardSheet(preferences: preferences, onApply: { draft in\n        applyDraft(draft)\n        wizardActive = false\n      }, onCancel: {\n        wizardActive = false\n      })\n    } else {\n      formBody\n    }\n  }\n\n  private var formBody: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      HStack(alignment: .firstTextBaseline) {\n        Text(rule == nil ? \"New Hook\" : \"Edit Hook\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        Spacer()\n        Button {\n          wizardActive = true\n        } label: {\n          Image(systemName: \"sparkles\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"AI Wizard\")\n      }\n\n      if #available(macOS 15.0, *) {\n        TabView(selection: $selectedTab) {\n          Tab(\"General\", systemImage: \"slider.horizontal.3\", value: 0) {\n            SettingsTabContent { generalTab }\n          }\n          Tab(\"Commands\", systemImage: \"terminal\", value: 1) {\n            SettingsTabContent { commandsTab }\n          }\n        }\n      } else {\n        TabView(selection: $selectedTab) {\n          SettingsTabContent { generalTab }\n            .tabItem { Label(\"General\", systemImage: \"slider.horizontal.3\") }\n            .tag(0)\n          SettingsTabContent { commandsTab }\n            .tabItem { Label(\"Commands\", systemImage: \"terminal\") }\n            .tag(1)\n        }\n      }\n\n      if let msg = errorMessage, !msg.isEmpty {\n        Text(msg)\n          .font(.caption)\n          .foregroundStyle(.orange)\n      }\n\n      HStack {\n        if selectedTab == 1 {\n          Button(\"Add Command\") {\n            commands.append(EditableHookCommand())\n          }\n          .buttonStyle(.bordered)\n        }\n        Spacer()\n        Button(\"Cancel\") { onCancel() }\n        Button(rule == nil ? \"Create\" : \"Save\") { save() }\n          .buttonStyle(.borderedProminent)\n          .disabled(!canSave)\n      }\n    }\n    .padding(16)\n    .frame(maxHeight: sheetMaxHeight)\n    .onAppear {\n      if rule == nil {\n        DispatchQueue.main.async {\n          focusedField = .name\n        }\n      }\n    }\n    .alert(item: $pendingDeleteCommand) { item in\n      Alert(\n        title: Text(\"Delete Command?\"),\n        message: Text(\"Remove this command from the hook?\"),\n        primaryButton: .destructive(Text(\"Delete\")) {\n          removeCommand(item.id)\n          pendingDeleteCommand = nil\n        },\n        secondaryButton: .cancel { pendingDeleteCommand = nil }\n      )\n    }\n    .onAppear {\n      if !didHydrate {\n        hydrateFromRule()\n        didHydrate = true\n      }\n    }\n  }\n\n  private var canSave: Bool {\n    let event = effectiveEvent\n    guard !event.isEmpty else { return false }\n    return commands.contains { !$0.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }\n  }\n\n  private var effectiveEvent: String {\n    if selectedEvent == customEventKey {\n      return customEvent.trimmingCharacters(in: .whitespacesAndNewlines)\n    }\n    return selectedEvent.trimmingCharacters(in: .whitespacesAndNewlines)\n  }\n\n  private var eventLabelText: String {\n    if selectedEvent == customEventKey {\n      let trimmed = customEvent.trimmingCharacters(in: .whitespacesAndNewlines)\n      return trimmed.isEmpty ? \"Custom…\" : trimmed\n    }\n    return effectiveEvent.isEmpty ? \"Select event\" : effectiveEvent\n  }\n\n  private var eventPicker: some View {\n    Button {\n      eventPickerPresented = true\n    } label: {\n      HStack(spacing: 6) {\n        Text(eventLabelText)\n          .lineLimit(1)\n        Spacer(minLength: 8)\n        if let descriptor = HookEventCatalog.descriptor(for: effectiveEvent) {\n          eventProviderIcons(for: descriptor)\n        }\n        Image(systemName: \"chevron.down\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n      .padding(.horizontal, 8)\n      .padding(.vertical, 4)\n      .frame(maxWidth: .infinity, alignment: .leading)\n      .contentShape(Rectangle())\n      .background(\n        RoundedRectangle(cornerRadius: 6)\n          .fill(Color(nsColor: .textBackgroundColor))\n      )\n      .overlay(\n        RoundedRectangle(cornerRadius: 6)\n          .stroke(Color.secondary.opacity(0.25))\n      )\n    }\n    .buttonStyle(.plain)\n    .help(HookEventCatalog.detailText(for: effectiveEvent))\n    .popover(isPresented: $eventPickerPresented, arrowEdge: .bottom) {\n      eventPickerView()\n    }\n  }\n\n  @ViewBuilder private var generalTab: some View {\n    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n      GridRow {\n        Text(\"Name\").font(.subheadline).fontWeight(.medium)\n        TextField(\"Optional display name\", text: $name)\n          .focused($focusedField, equals: .name)\n          .frame(minHeight: generalRowMinHeight)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n\n      GridRow {\n        Text(\"Event\").font(.subheadline).fontWeight(.medium)\n        eventPicker\n          .frame(minHeight: generalRowMinHeight)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n\n      if selectedEvent == customEventKey {\n        GridRow {\n          Text(\"Custom Event\").font(.subheadline).fontWeight(.medium)\n          TextField(\"Custom event name\", text: $customEvent)\n            .frame(minHeight: generalRowMinHeight)\n            .frame(maxWidth: .infinity, alignment: .trailing)\n        }\n      }\n\n      GridRow {\n        Text(\"Matcher\").font(.subheadline).fontWeight(.medium)\n        Group {\n          if HookEventCatalog.supportsMatcher(effectiveEvent, targets: targets) {\n            let options = HookEventCatalog.matchers(for: effectiveEvent, targets: targets)\n            HStack(spacing: 6) {\n              TextField(\"Matcher (e.g., Write|Edit)\", text: $matcher)\n              if !options.isEmpty {\n                Menu {\n                  ForEach(options, id: \\.value) { option in\n                    Button(option.value) { matcher = option.value }\n                  }\n                } label: {\n                  Image(systemName: \"chevron.down\")\n                }\n                .menuIndicator(.hidden)\n                .buttonStyle(.borderless)\n              }\n            }\n            .help(HookEventCatalog.matcherDescription(for: effectiveEvent, matcher: matcher) ?? \"\")\n          } else {\n            Text(\"Not applicable for this event\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .lineLimit(1)\n              .truncationMode(.tail)\n          }\n        }\n        .frame(minHeight: generalRowMinHeight)\n        .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n\n      GridRow {\n        Text(\"Description\").font(.subheadline).fontWeight(.medium)\n        descriptionEditor(text: $descriptionText)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n\n      GridRow {\n        Text(\"Targets\").font(.subheadline).fontWeight(.medium)\n        HStack(spacing: 12) {\n          Toggle(\n            \"Codex\",\n            isOn: Binding(\n              get: { targets.codex },\n              set: { targets.codex = $0 }\n            )\n          )\n          .toggleStyle(.switch)\n          .controlSize(.small)\n          .disabled(!preferences.isCLIEnabled(.codex))\n\n          Toggle(\n            \"Claude Code\",\n            isOn: Binding(\n              get: { targets.claude },\n              set: { targets.claude = $0 }\n            )\n          )\n          .toggleStyle(.switch)\n          .controlSize(.small)\n          .disabled(!preferences.isCLIEnabled(.claude))\n\n          Toggle(\n            \"Gemini\",\n            isOn: Binding(\n              get: { targets.gemini },\n              set: { targets.gemini = $0 }\n            )\n          )\n          .toggleStyle(.switch)\n          .controlSize(.small)\n          .disabled(!preferences.isCLIEnabled(.gemini))\n        }\n        .frame(minHeight: generalRowMinHeight)\n        .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n    }\n  }\n\n  @ViewBuilder private var commandsTab: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      ScrollView {\n        VStack(alignment: .leading, spacing: 12) {\n          ForEach(Array(commands.enumerated()), id: \\.element.id) { index, _ in\n            commandCard(index: index, id: commands[index].id)\n          }\n        }\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n        .padding(.top, 4)\n      }\n      .frame(maxHeight: .infinity)\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n  }\n\n  private func hydrateFromRule() {\n    guard let rule else {\n      selectedEvent = HookEventCatalog.canonicalEvents.first ?? \"Stop\"\n      commands = [EditableHookCommand()]\n      targets = HookTargets()\n      enabled = true\n      descriptionText = \"\"\n      return\n    }\n\n    name = rule.name\n    descriptionText = rule.description ?? \"\"\n    enabled = rule.enabled\n    if let descriptor = HookEventCatalog.descriptor(for: rule.event) {\n      selectedEvent = descriptor.name\n      customEvent = \"\"\n    } else {\n      selectedEvent = customEventKey\n      customEvent = rule.event\n    }\n    matcher = rule.matcher ?? \"\"\n    targets = rule.targets ?? HookTargets()\n    commands = rule.commands.map { EditableHookCommand(from: $0) }\n    if commands.isEmpty { commands = [EditableHookCommand()] }\n  }\n\n  private func applyDraft(_ draft: HookWizardDraft) {\n    name = draft.name ?? \"\"\n    descriptionText = draft.description ?? \"\"\n    enabled = true\n    if let descriptor = HookEventCatalog.descriptor(for: draft.event) {\n      selectedEvent = descriptor.name\n      customEvent = \"\"\n    } else {\n      selectedEvent = customEventKey\n      customEvent = draft.event\n    }\n    matcher = draft.matcher ?? \"\"\n    targets = draft.targets ?? HookTargets()\n    commands = draft.commands.map { EditableHookCommand(from: $0) }\n    if commands.isEmpty { commands = [EditableHookCommand()] }\n  }\n\n  private func removeCommand(_ id: UUID) {\n    commands.removeAll { $0.id == id }\n    hoveringCommandIds.remove(id)\n    if commands.isEmpty { commands = [EditableHookCommand()] }\n  }\n\n  private func confirmDeleteCommand(_ id: UUID) {\n    pendingDeleteCommand = PendingCommandDelete(id: id)\n  }\n\n  private func commandCard(index: Int, id: UUID) -> some View {\n    GroupBox {\n      VStack(alignment: .leading, spacing: 8) {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {\n          GridRow {\n            Text(\"Command\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n            HStack(spacing: 8) {\n              TextField(\"Select executable or type path\", text: $commands[index].command)\n              Button {\n                chooseCommandPath(for: id)\n              } label: {\n                Image(systemName: \"folder\")\n              }\n              .buttonStyle(.borderless)\n              .help(\"Choose executable\")\n            }\n            .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n\n          GridRow {\n            Text(\"Args\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n            HStack(alignment: .top, spacing: 8) {\n              placeholderEditor(\n                text: $commands[index].argsText,\n                placeholder: \"one argument per line\",\n                height: 88\n              )\n              variableInsertButton(commandId: id, target: .args)\n                .padding(.top, 4)\n            }\n            .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n\n          GridRow {\n            Text(\"Env\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n            HStack(alignment: .top, spacing: 8) {\n              placeholderEditor(\n                text: $commands[index].envText,\n                placeholder: \"KEY=VALUE, one per line\",\n                height: 88\n              )\n              variableInsertButton(commandId: id, target: .env)\n                .padding(.top, 4)\n            }\n            .frame(maxWidth: .infinity, alignment: .trailing)\n          }\n\n          GridRow {\n            Text(\"Timeout\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n            HStack(spacing: 8) {\n              TextField(\"ms\", text: $commands[index].timeoutMsText)\n                .frame(width: 180, alignment: .trailing)\n              Spacer()\n              Button(role: .destructive) {\n                confirmDeleteCommand(id)\n              } label: {\n                Image(systemName: \"trash\")\n              }\n              .buttonStyle(.borderless)\n              .help(\"Remove command\")\n              .opacity(hoveringCommandIds.contains(id) ? 1 : 0)\n              .scaleEffect(hoveringCommandIds.contains(id) ? 1.0 : 0.92)\n              .offset(y: hoveringCommandIds.contains(id) ? 0 : 2)\n              .allowsHitTesting(hoveringCommandIds.contains(id))\n              .animation(.easeInOut(duration: 0.12), value: hoveringCommandIds.contains(id))\n            }\n          }\n        }\n      }\n      .padding(4)\n    }\n    .onHover { hovering in\n      if hovering {\n        hoveringCommandIds.insert(id)\n      } else {\n        hoveringCommandIds.remove(id)\n      }\n    }\n  }\n\n  private func descriptionEditor(text: Binding<String>) -> some View {\n    ZStack(alignment: .topLeading) {\n      if text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n        Text(\"Describe what this hook is for…\")\n          .font(.caption)\n          .foregroundStyle(.tertiary)\n          .padding(.top, 6)\n          .padding(.leading, 4)\n      }\n      TextEditor(text: text)\n        .font(.body)\n        .frame(height: 64)\n        .scrollContentBackground(.hidden)\n        .background(Color(nsColor: .textBackgroundColor))\n    }\n    .overlay(\n      RoundedRectangle(cornerRadius: 6)\n        .stroke(Color.secondary.opacity(0.15))\n    )\n  }\n\n  private func placeholderEditor(\n    text: Binding<String>,\n    placeholder: String,\n    height: CGFloat = 44\n  ) -> some View {\n    ZStack(alignment: .topLeading) {\n      if text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n        Text(placeholder)\n          .font(.caption)\n          .foregroundStyle(.tertiary)\n          .padding(.top, 6)\n          .padding(.leading, 4)\n      }\n      TextEditor(text: text)\n        .font(.system(.caption, design: .monospaced))\n        .frame(height: height)\n        .scrollContentBackground(.hidden)\n        .background(Color(nsColor: .textBackgroundColor))\n    }\n    .overlay(\n      RoundedRectangle(cornerRadius: 6)\n        .stroke(Color.secondary.opacity(0.15))\n    )\n    .frame(maxWidth: .infinity, alignment: .leading)\n  }\n\n  private func variableInsertButton(commandId: UUID, target: VariableInsertTarget) -> some View {\n    Button {\n      variablePicker = VariablePickerContext(commandId: commandId, target: target)\n    } label: {\n      Image(systemName: \"curlybraces.square\")\n    }\n    .buttonStyle(.borderless)\n    .help(\"Insert variable\")\n    .popover(\n      isPresented: popoverBinding(for: commandId, target: target),\n      arrowEdge: .bottom\n    ) {\n      variablePickerView(commandId: commandId, target: target)\n    }\n  }\n\n  private func popoverBinding(for commandId: UUID, target: VariableInsertTarget) -> Binding<Bool> {\n    Binding(\n      get: { variablePicker?.commandId == commandId && variablePicker?.target == target },\n      set: { isPresented in\n        if isPresented {\n          variablePicker = VariablePickerContext(commandId: commandId, target: target)\n        } else if variablePicker?.commandId == commandId && variablePicker?.target == target {\n          variablePicker = nil\n        }\n      }\n    )\n  }\n\n  private func variablePickerView(commandId: UUID, target: VariableInsertTarget) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Hook variables\")\n        .font(.headline)\n      Text(\"Selecting a variable inserts it into the field.\")\n        .font(.footnote)\n        .foregroundStyle(.secondary)\n      Picker(\"Filter\", selection: $variableFilter) {\n        ForEach(HookVariableFilter.allCases) { filter in\n          Text(filter.title).tag(filter)\n        }\n      }\n      .labelsHidden()\n      .pickerStyle(.segmented)\n      .controlSize(.small)\n      TextField(\"Search variables\", text: $variableQuery)\n        .textFieldStyle(.roundedBorder)\n        .focused($variableSearchFocused)\n      ScrollView {\n        VStack(alignment: .leading, spacing: 0) {\n          let rows = filteredVariables(for: target)\n          ForEach(rows.indices, id: \\.self) { idx in\n            variableRow(rows[idx], index: idx, commandId: commandId, target: target)\n          }\n          if rows.isEmpty {\n            Text(\"No variables match this filter.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .padding(.vertical, 8)\n              .frame(maxWidth: .infinity, alignment: .center)\n          }\n        }\n      }\n      .frame(minHeight: 160, maxHeight: .infinity)\n      .layoutPriority(1)\n    }\n    .padding(12)\n    .frame(width: variablePopoverSize.width, height: variablePopoverSize.height, alignment: .topLeading)\n    .onAppear {\n      DispatchQueue.main.async { variableSearchFocused = true }\n    }\n  }\n\n  private func eventPickerView() -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(\"Hook events\")\n        .font(.headline)\n      Text(\"Select the event that should trigger this hook.\")\n        .font(.footnote)\n        .foregroundStyle(.secondary)\n      Picker(\"Filter\", selection: $eventFilter) {\n        ForEach(HookEventFilter.allCases) { filter in\n          Text(filter.title).tag(filter)\n        }\n      }\n      .labelsHidden()\n      .pickerStyle(.segmented)\n      .controlSize(.small)\n      TextField(\"Search events\", text: $eventQuery)\n        .textFieldStyle(.roundedBorder)\n        .focused($eventSearchFocused)\n      ScrollView {\n        VStack(alignment: .leading, spacing: 0) {\n          let rows = filteredEvents()\n          ForEach(rows.indices, id: \\.self) { idx in\n            eventRow(rows[idx], index: idx)\n          }\n          if rows.isEmpty {\n            Text(\"No events match this filter.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .padding(.vertical, 8)\n              .frame(maxWidth: .infinity, alignment: .center)\n          }\n        }\n      }\n      .frame(minHeight: 160, maxHeight: .infinity)\n      .layoutPriority(1)\n      Divider()\n      Button(\"Custom…\") {\n        selectedEvent = customEventKey\n        eventPickerPresented = false\n      }\n      .buttonStyle(.borderless)\n    }\n    .padding(12)\n    .frame(width: eventPopoverSize.width, height: eventPopoverSize.height, alignment: .topLeading)\n    .onAppear {\n      DispatchQueue.main.async { eventSearchFocused = true }\n    }\n  }\n\n  private func filteredEvents() -> [HookEventDescriptor] {\n    let candidates = HookEventCatalog.all.filter { matchesEventFilter($0) }\n    let trimmed = eventQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return candidates }\n    let query = trimmed.lowercased()\n    return candidates.filter {\n      if $0.name.lowercased().contains(query) { return true }\n      if $0.description.lowercased().contains(query) { return true }\n      if let note = $0.note?.lowercased(), note.contains(query) { return true }\n      return false\n    }\n  }\n\n  private func matchesEventFilter(_ event: HookEventDescriptor) -> Bool {\n    switch eventFilter {\n    case .all:\n      return true\n    case .common:\n      return event.providers.contains(.claude) && event.providers.contains(.gemini)\n    case .codex:\n      return event.providers.contains(.codex)\n    case .claude:\n      return event.providers.contains(.claude)\n    case .gemini:\n      return event.providers.contains(.gemini)\n    }\n  }\n\n  private func eventRow(_ event: HookEventDescriptor, index: Int) -> some View {\n    let detail = HookEventCatalog.detailText(for: event.name)\n    return Button {\n      selectedEvent = event.name\n      customEvent = \"\"\n      eventPickerPresented = false\n    } label: {\n      HStack(spacing: 8) {\n        eventProviderIcons(for: event)\n        VStack(alignment: .leading, spacing: 2) {\n          Text(event.name)\n            .font(.system(.caption, design: .monospaced))\n            .lineLimit(1)\n          Text(detail)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n            .truncationMode(.tail)\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n      }\n      .padding(.leading, 8)\n      .padding(.trailing, 12)\n      .padding(.vertical, 6)\n      .frame(minHeight: 44)\n      .frame(maxWidth: .infinity, alignment: .leading)\n      .background(index % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear)\n      .contentShape(Rectangle())\n      .help(\"\\(event.name) — \\(detail)\")\n    }\n    .buttonStyle(.plain)\n  }\n\n  private func filteredVariables(for target: VariableInsertTarget) -> [HookVariableDescriptor] {\n    let candidates = HookCommandVariableCatalog.all.filter {\n      matchesTarget($0, target: target) && matchesFilter($0)\n    }\n    let trimmed = variableQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return candidates }\n    let query = trimmed.lowercased()\n    return candidates.filter { matchesQuery($0, query: query) }\n  }\n\n  private func variableRow(\n    _ variable: HookVariableDescriptor,\n    index: Int,\n    commandId: UUID,\n    target: VariableInsertTarget\n  ) -> some View {\n    let detail = variableDetailText(variable)\n    return Button {\n      insertVariable(variable, into: target, commandId: commandId)\n      variablePicker = nil\n    } label: {\n      HStack(spacing: 8) {\n        providerIcons(for: variable)\n        VStack(alignment: .leading, spacing: 2) {\n          HStack(alignment: .firstTextBaseline, spacing: 6) {\n            Text(variable.name)\n              .font(.system(.caption, design: .monospaced))\n              .lineLimit(1)\n            Spacer(minLength: 8)\n            kindBadge(variable.kind)\n          }\n          Text(detail)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n            .truncationMode(.tail)\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n      }\n      .padding(.leading, 8)\n      .padding(.trailing, 12)\n      .padding(.vertical, 6)\n      .frame(minHeight: 44)\n      .frame(maxWidth: .infinity, alignment: .leading)\n      .background(index % 2 == 0 ? Color.secondary.opacity(0.06) : Color.clear)\n      .contentShape(Rectangle())\n      .help(\"\\(variable.name) — \\(detail)\")\n    }\n    .buttonStyle(.plain)\n  }\n\n  private func providerIcons(for variable: HookVariableDescriptor) -> some View {\n    HStack(spacing: 4) {\n      providerIcon(.codex, variable: variable)\n      providerIcon(.claude, variable: variable)\n      providerIcon(.gemini, variable: variable)\n    }\n  }\n\n  private func providerIcon(_ provider: HookVariableProvider, variable: HookVariableDescriptor) -> some View {\n    let supported = variable.providers.contains(provider)\n    let supportText = supported ? \"Supported\" : \"Not supported\"\n    return providerIcon(provider, supported: supported)\n      .help(\"\\(provider.displayName) · \\(supportText)\")\n  }\n\n  private func eventProviderIcons(for event: HookEventDescriptor) -> some View {\n    HStack(spacing: 4) {\n      eventProviderIcon(.codex, event: event)\n      eventProviderIcon(.claude, event: event)\n      eventProviderIcon(.gemini, event: event)\n    }\n  }\n\n  private func eventProviderIcon(_ provider: HookVariableProvider, event: HookEventDescriptor) -> some View {\n    let supported = event.providers.contains(provider)\n    let supportText = supported ? \"Supported\" : \"Not supported\"\n    return providerIcon(provider, supported: supported)\n      .help(\"\\(provider.displayName) · \\(supportText)\")\n  }\n\n  private func providerIcon(_ provider: HookVariableProvider, supported: Bool) -> some View {\n    let opacity: Double = supported ? 1.0 : 0.2\n    let saturation: Double = supported ? 1.0 : 0.0\n    let grayscale: Double = supported ? 0.0 : 1.0\n    return ProviderIconView(\n      provider: usageProvider(for: provider),\n      size: 12,\n      cornerRadius: 2,\n      saturation: saturation,\n      opacity: opacity\n    )\n    .grayscale(grayscale)\n  }\n\n  private func usageProvider(for provider: HookVariableProvider) -> UsageProviderKind {\n    switch provider {\n    case .codex: return .codex\n    case .claude: return .claude\n    case .gemini: return .gemini\n    }\n  }\n\n  private func variableDetailText(_ variable: HookVariableDescriptor) -> String {\n    if let note = variable.note, !note.isEmpty {\n      return \"\\(variable.description) (\\(note))\"\n    }\n    return variable.description\n  }\n\n  private func matchesFilter(_ variable: HookVariableDescriptor) -> Bool {\n    switch variableFilter {\n    case .all:\n      return true\n    case .common:\n      return variable.providers.contains(.claude) && variable.providers.contains(.gemini)\n    case .codex:\n      return variable.providers.contains(.codex)\n    case .claude:\n      return variable.providers.contains(.claude)\n    case .gemini:\n      return variable.providers.contains(.gemini)\n    }\n  }\n\n  private func matchesTarget(_ variable: HookVariableDescriptor, target: VariableInsertTarget) -> Bool {\n    switch target {\n    case .args:\n      return true\n    case .env:\n      return variable.kind == .env\n    }\n  }\n\n  private func matchesQuery(_ variable: HookVariableDescriptor, query: String) -> Bool {\n    if variable.name.lowercased().contains(query) { return true }\n    if variable.description.lowercased().contains(query) { return true }\n    if let note = variable.note?.lowercased(), note.contains(query) { return true }\n    return false\n  }\n\n  private func kindBadge(_ kind: HookVariableKind) -> some View {\n    Text(kind.shortLabel)\n      .font(.system(size: 9, weight: .semibold))\n      .foregroundStyle(.secondary)\n      .padding(.horizontal, 5)\n      .padding(.vertical, 1)\n      .background(Color.secondary.opacity(0.12))\n      .clipShape(Capsule())\n  }\n\n  private func insertVariable(_ variable: HookVariableDescriptor, into target: VariableInsertTarget, commandId: UUID) {\n    let token = variableInsertToken(variable)\n    updateCommand(commandId) { editable in\n      switch target {\n      case .args:\n        editable.argsText = appendToken(token, to: editable.argsText, separator: \"\\n\")\n      case .env:\n        editable.envText = appendToken(token, to: editable.envText, separator: \"\\n\")\n      }\n    }\n  }\n\n  private func updateCommand(_ commandId: UUID, mutate: (inout EditableHookCommand) -> Void) {\n    guard let index = commands.firstIndex(where: { $0.id == commandId }) else { return }\n    mutate(&commands[index])\n  }\n\n  private func appendToken(_ token: String, to text: String, separator: String) -> String {\n    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return token }\n    if text.hasSuffix(separator) { return text + token }\n    return text + separator + token\n  }\n\n  private func variableInsertToken(_ variable: HookVariableDescriptor) -> String {\n    switch variable.kind {\n    case .env:\n      return \"$\\(variable.name)\"\n    case .stdin:\n      return stdinToken(for: variable.name)\n    }\n  }\n\n  private func stdinToken(for name: String) -> String {\n    let objectFields: Set<String> = [\"tool_input\", \"tool_response\", \"llm_request\", \"llm_response\", \"details\", \"mcp_context\"]\n    let flag = objectFields.contains(name) ? \"-c\" : \"-r\"\n    return \"$(jq \\(flag) '.\\(name)')\"\n  }\n\n  private func chooseCommandPath(for id: UUID) {\n    let panel = NSOpenPanel()\n    panel.canChooseFiles = true\n    panel.canChooseDirectories = false\n    panel.allowsMultipleSelection = false\n    panel.treatsFilePackagesAsDirectories = false\n    panel.prompt = \"Choose\"\n    panel.message = \"Choose an executable to run for this hook\"\n    panel.allowedContentTypes = [.executable]\n    panel.begin { response in\n      guard response == .OK, let url = panel.url else { return }\n      guard FileManager.default.isExecutableFile(atPath: url.path) else {\n        errorMessage = \"Selected file is not executable.\"\n        return\n      }\n      guard let index = commands.firstIndex(where: { $0.id == id }) else { return }\n      commands[index].command = url.path\n      errorMessage = nil\n    }\n  }\n\n  private func parseLines(_ text: String) -> [String] {\n    text\n      .split(whereSeparator: \\.isNewline)\n      .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) }\n      .filter { !$0.isEmpty }\n  }\n\n  private func parseEnv(_ text: String) -> [String: String] {\n    var env: [String: String] = [:]\n    for line in parseLines(text) {\n      guard let eq = line.firstIndex(of: \"=\") else { continue }\n      let key = String(line[..<eq]).trimmingCharacters(in: .whitespacesAndNewlines)\n      let value = String(line[line.index(after: eq)...]).trimmingCharacters(\n        in: .whitespacesAndNewlines)\n      guard !key.isEmpty else { continue }\n      env[key] = value\n    }\n    return env\n  }\n\n  private func save() {\n    errorMessage = nil\n\n    let event = effectiveEvent\n    guard !event.isEmpty else {\n      errorMessage = \"Event is required.\"\n      return\n    }\n\n    let finalMatcher: String? = {\n      let trimmed = matcher.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard HookEventCatalog.supportsMatcher(event, targets: targets) else { return nil }\n      return trimmed.isEmpty ? nil : trimmed\n    }()\n\n    let finalCommands: [HookCommand] = commands.compactMap { editable in\n      let program = editable.command.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !program.isEmpty else { return nil }\n      let args = parseLines(editable.argsText)\n      let env = parseEnv(editable.envText)\n      let timeout = Int(editable.timeoutMsText.trimmingCharacters(in: .whitespacesAndNewlines))\n      return HookCommand(\n        command: program,\n        args: args.isEmpty ? nil : args,\n        env: env.isEmpty ? nil : env,\n        timeoutMs: (timeout ?? 0) > 0 ? timeout : nil\n      )\n    }\n    guard !finalCommands.isEmpty else {\n      errorMessage = \"At least one command is required.\"\n      return\n    }\n\n    var finalName = name.trimmingCharacters(in: .whitespacesAndNewlines)\n    if finalName.isEmpty {\n      finalName = HookEventCatalog.defaultName(\n        event: event, matcher: finalMatcher, command: finalCommands.first)\n    }\n    let finalDescriptionText = descriptionText.trimmingCharacters(in: .whitespacesAndNewlines)\n    let finalDescription = finalDescriptionText.isEmpty ? nil : finalDescriptionText\n\n    let resolvedTargets = targets.allEnabled ? nil : targets\n    let now = Date()\n    let out = HookRule(\n      id: rule?.id ?? UUID().uuidString,\n      name: finalName,\n      description: finalDescription,\n      event: event,\n      matcher: finalMatcher,\n      commands: finalCommands,\n      enabled: enabled,\n      targets: resolvedTargets,\n      source: rule?.source ?? \"user\",\n      createdAt: rule?.createdAt ?? now,\n      updatedAt: now\n    )\n    onSave(out)\n  }\n}\n\nprivate enum VariableInsertTarget: Sendable {\n  case args\n  case env\n}\n\nprivate enum HookVariableFilter: String, CaseIterable, Identifiable {\n  case all\n  case common\n  case codex\n  case claude\n  case gemini\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .all: return \"All\"\n    case .common: return \"Common\"\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude\"\n    case .gemini: return \"Gemini\"\n    }\n  }\n}\n\nprivate enum HookEventFilter: String, CaseIterable, Identifiable {\n  case all\n  case common\n  case codex\n  case claude\n  case gemini\n\n  var id: String { rawValue }\n\n  var title: String {\n    switch self {\n    case .all: return \"All\"\n    case .common: return \"Common\"\n    case .codex: return \"Codex\"\n    case .claude: return \"Claude\"\n    case .gemini: return \"Gemini\"\n    }\n  }\n}\n\nprivate struct VariablePickerContext: Identifiable {\n  let id = UUID()\n  let commandId: UUID\n  let target: VariableInsertTarget\n}\n\nprivate struct PendingCommandDelete: Identifiable {\n  let id: UUID\n}\n\nprivate struct EditableHookCommand: Identifiable {\n  let id: UUID\n  var command: String\n  var argsText: String\n  var envText: String\n  var timeoutMsText: String\n\n  init(\n    id: UUID = UUID(),\n    command: String = \"\",\n    argsText: String = \"\",\n    envText: String = \"\",\n    timeoutMsText: String = \"\"\n  ) {\n    self.id = id\n    self.command = command\n    self.argsText = argsText\n    self.envText = envText\n    self.timeoutMsText = timeoutMsText\n  }\n\n  init(from command: HookCommand) {\n    self.id = UUID()\n    self.command = command.command\n    self.argsText = (command.args ?? []).joined(separator: \"\\n\")\n    self.envText = (command.env ?? [:]).map { \"\\($0.key)=\\($0.value)\" }.sorted().joined(\n      separator: \"\\n\")\n    self.timeoutMsText = command.timeoutMs.map(String.init) ?? \"\"\n  }\n}\n"
  },
  {
    "path": "views/HooksSettingsView.swift",
    "content": "import SwiftUI\n\nstruct HooksSettingsView: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  @StateObject private var vm = HooksViewModel()\n  @State private var searchFocused = false\n  @State private var pendingAction: PendingHookAction?\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      headerRow\n      contentRow\n    }\n    .sheet(isPresented: $vm.showAddSheet) {\n      HookEditSheet(\n        preferences: preferences,\n        rule: nil,\n        onSave: { rule in\n          Task {\n            await vm.addRule(rule)\n            vm.showAddSheet = false\n          }\n        },\n        onCancel: { vm.showAddSheet = false }\n      )\n      .frame(minWidth: 760, minHeight: 520)\n    }\n    .sheet(isPresented: $vm.showImportSheet) {\n      HooksImportSheet(\n        candidates: $vm.importCandidates,\n        isImporting: vm.isImporting,\n        statusMessage: vm.importStatusMessage,\n        title: \"Import Hooks\",\n        subtitle: \"Scan Home for existing Codex/Claude/Gemini hooks and import into CodMate.\",\n        onCancel: { vm.cancelImport() },\n        onImport: { Task { await vm.importSelectedHooks() } }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .sheet(item: $vm.editingRule) { rule in\n      HookEditSheet(\n        preferences: preferences,\n        rule: rule,\n        onSave: { updated in\n          Task {\n            await vm.updateRule(updated)\n            vm.editingRule = nil\n          }\n        },\n        onCancel: { vm.editingRule = nil }\n      )\n      .frame(minWidth: 760, minHeight: 520)\n    }\n    .alert(item: $pendingAction) { action in\n      Alert(\n        title: Text(\"Delete Hook?\"),\n        message: Text(\"Remove \\\"\\(action.rule.name)\\\" from the hooks list?\"),\n        primaryButton: .destructive(Text(\"Delete\")) {\n          Task {\n            await vm.deleteRule(id: action.rule.id)\n            pendingAction = nil\n          }\n        },\n        secondaryButton: .cancel { pendingAction = nil }\n      )\n    }\n    .task { await vm.load() }\n  }\n\n  private var headerRow: some View {\n    HStack(spacing: 8) {\n      Spacer(minLength: 0)\n      ToolbarSearchField(\n        placeholder: \"Search hooks\",\n        text: $vm.searchText,\n        onFocusChange: { focused in searchFocused = focused },\n        onSubmit: {}\n      )\n      .frame(width: 240)\n\n      Button {\n        vm.showAddSheet = true\n      } label: {\n        Label(\"Add\", systemImage: \"plus\")\n      }\n      Button {\n        vm.beginImportFromHome()\n      } label: {\n        Label(\"Import\", systemImage: \"tray.and.arrow.down\")\n      }\n    }\n  }\n\n  private var contentRow: some View {\n    HStack(alignment: .top, spacing: 12) {\n      hooksList\n        .frame(minWidth: 260, maxWidth: 320)\n      detailPanel\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n\n  private var hooksList: some View {\n    Group {\n      if vm.isLoading {\n        VStack(spacing: 8) {\n          ProgressView()\n          Text(\"Loading hooks…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if vm.filteredRules.isEmpty {\n        VStack(spacing: 10) {\n          Image(systemName: \"link\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"No Hooks\")\n            .font(.title3)\n            .fontWeight(.medium)\n          Text(\"Add a hook to get started.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n            .multilineTextAlignment(.center)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        List(selection: $vm.selectedRuleId) {\n          ForEach(vm.filteredRules) { rule in\n            HookRuleRow(\n              preferences: preferences,\n              rule: rule,\n              isSelected: vm.selectedRuleId == rule.id,\n              onSelect: { vm.selectedRuleId = rule.id },\n              onEdit: { vm.editingRule = rule },\n              onDelete: { confirmDelete(rule) },\n              onToggleEnabled: { value in vm.updateRuleEnabled(id: rule.id, value: value) },\n              onToggleTarget: { target, value in vm.updateRuleTarget(id: rule.id, target: target, value: value) }\n            )\n            .tag(rule.id as String?)\n          }\n        }\n        .listStyle(.inset)\n        .scrollContentBackground(.hidden)\n      }\n    }\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private var detailPanel: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      if let rule = vm.selectedRule {\n        HookDetailPane(\n          rule: rule,\n          warnings: vm.syncWarnings.filter { $0.provider == .codex && rule.isEnabled(for: .codex) }\n            + vm.syncWarnings.filter { $0.provider == .claude && rule.isEnabled(for: .claude) }\n            + vm.syncWarnings.filter { $0.provider == .gemini && rule.isEnabled(for: .gemini) },\n          onSync: { Task { await vm.applyToProviders() } },\n          onEdit: { vm.editingRule = rule },\n          onDelete: { confirmDelete(rule) }\n        )\n        .id(rule.id)\n      } else {\n        VStack(spacing: 12) {\n          Image(systemName: \"link\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"Select a hook to view details\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      }\n\n      if !vm.syncWarnings.isEmpty {\n        Divider()\n        VStack(alignment: .leading, spacing: 6) {\n          Label(\"Apply warnings\", systemImage: \"exclamationmark.triangle\")\n            .font(.subheadline.weight(.medium))\n            .foregroundStyle(.orange)\n          ForEach(vm.syncWarnings) { warning in\n            Text(\"\\(warning.provider.displayName): \\(warning.message)\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n        }\n      }\n\n      if let msg = vm.errorMessage, !msg.isEmpty {\n        Divider()\n        Text(msg)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n    }\n    .padding(12)\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private func confirmDelete(_ rule: HookRule) {\n    pendingAction = PendingHookAction(rule: rule)\n  }\n}\n\nprivate struct PendingHookAction: Identifiable {\n  let id = UUID()\n  let rule: HookRule\n}\n\nprivate struct HookRuleRow: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  let rule: HookRule\n  let isSelected: Bool\n  var onSelect: () -> Void\n  var onEdit: () -> Void\n  var onDelete: () -> Void\n  var onToggleEnabled: (Bool) -> Void\n  var onToggleTarget: (HookTarget, Bool) -> Void\n\n  var body: some View {\n    HStack(alignment: .center, spacing: 8) {\n      Toggle(\n        \"\",\n        isOn: Binding(\n          get: { rule.enabled },\n          set: { value in onToggleEnabled(value) }\n        )\n      )\n      .labelsHidden()\n      .controlSize(.small)\n\n      VStack(alignment: .leading, spacing: 4) {\n        Text(rule.name.isEmpty ? rule.event : rule.name)\n          .font(.body.weight(.medium))\n          .lineLimit(1)\n        Text(ruleSummary(rule))\n          .font(.caption)\n          .foregroundStyle(.secondary)\n          .lineLimit(2)\n      }\n      Spacer(minLength: 8)\n      HStack(spacing: 6) {\n        ForEach(HookTarget.allCases, id: \\.self) { target in\n          MCPServerTargetToggle(\n            provider: target.usageProvider,\n            isOn: Binding(\n              get: { rule.targets?.isEnabled(for: target) ?? true },\n              set: { value in onToggleTarget(target, value) }\n            ),\n            disabled: !preferences.isCLIEnabled(target.baseKind)\n          )\n        }\n      }\n    }\n    .padding(.vertical, 4)\n    .contentShape(Rectangle())\n    .onTapGesture { onSelect() }\n    .contextMenu {\n      Button(\"Edit\") { onEdit() }\n      Button(\"Delete\", role: .destructive) { onDelete() }\n    }\n  }\n\n  private func ruleSummary(_ rule: HookRule) -> String {\n    let event = rule.event.isEmpty ? \"Event\" : rule.event\n    if let matcher = rule.matcher, !matcher.isEmpty {\n      return \"\\(event) · \\(matcher) · \\(rule.commands.count) command(s)\"\n    }\n    return \"\\(event) · \\(rule.commands.count) command(s)\"\n  }\n}\n\nprivate struct HookDetailPane: View {\n  let rule: HookRule\n  let warnings: [HookSyncWarning]\n  var onSync: () -> Void\n  var onEdit: () -> Void\n  var onDelete: () -> Void\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n      ScrollView {\n        VStack(alignment: .leading, spacing: 16) {\n          commandsSection\n          if !warnings.isEmpty {\n            providerWarningsSection\n          }\n        }\n      }\n    }\n  }\n\n  private var header: some View {\n    HStack(alignment: .top, spacing: 12) {\n      VStack(alignment: .leading, spacing: 4) {\n        Text(rule.name.isEmpty ? rule.event : rule.name)\n          .font(.title3.weight(.semibold))\n        Text(descriptionText.isEmpty ? \"No description provided\" : descriptionText)\n          .font(.subheadline)\n          .foregroundStyle(descriptionText.isEmpty ? .tertiary : .secondary)\n          .lineLimit(3)\n          .help(descriptionText.isEmpty ? \"No description provided\" : descriptionText)\n        Text(detailSubtitle)\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n      }\n      Spacer()\n      HStack(spacing: 8) {\n        Button {\n          onSync()\n        } label: {\n          Image(systemName: \"arrow.triangle.2.circlepath\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Apply hooks to AI CLI providers\")\n\n        Button {\n          onEdit()\n        } label: {\n          Image(systemName: \"pencil\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Edit\")\n\n        Button(role: .destructive) {\n          onDelete()\n        } label: {\n          Image(systemName: \"trash\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Delete\")\n      }\n    }\n  }\n\n  private var commandsSection: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      Text(\"Commands\")\n        .font(.headline)\n      ForEach(Array(rule.commands.enumerated()), id: \\.offset) { (_, cmd) in\n        VStack(alignment: .leading, spacing: 2) {\n          Text(cmd.command)\n            .font(.caption)\n            .textSelection(.enabled)\n          if let args = cmd.args, !args.isEmpty {\n            Text(\"Args: \\(args.joined(separator: \" \"))\")\n              .font(.caption2)\n              .foregroundStyle(.secondary)\n              .textSelection(.enabled)\n          }\n          if let timeout = cmd.timeoutMs {\n            Text(\"Timeout: \\(timeout)ms\")\n              .font(.caption2)\n              .foregroundStyle(.secondary)\n          }\n        }\n        .padding(.vertical, 4)\n      }\n    }\n  }\n\n  private var providerWarningsSection: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      Divider()\n      Label(\"Provider warnings\", systemImage: \"exclamationmark.triangle\")\n        .font(.subheadline.weight(.medium))\n        .foregroundStyle(.orange)\n      ForEach(warnings) { w in\n        Text(\"\\(w.provider.displayName): \\(w.message)\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n          .fixedSize(horizontal: false, vertical: true)\n      }\n    }\n  }\n\n  private var detailSubtitle: String {\n    if let matcher = rule.matcher, !matcher.isEmpty {\n      return \"\\(rule.event) · matcher: \\(matcher)\"\n    }\n    return rule.event\n  }\n\n  private var descriptionText: String {\n    rule.description?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n  }\n}\n"
  },
  {
    "path": "views/LiveFileSizeText.swift",
    "content": "import SwiftUI\n\n/// Shows a session file's size and refreshes on file system events only.\nstruct LiveFileSizeText: View {\n    let url: URL\n\n    @State private var text: String = \"—\"\n    @State private var monitor: DirectoryMonitor? = nil\n\n    var body: some View {\n        Text(text)\n            .font(.callout)\n            .foregroundStyle(.secondary)\n            .onAppear { start() }\n            .onDisappear { stop() }\n            .task(id: url) { restart() }\n            .help(\"Current session file size\")\n    }\n\n    private func restart() {\n        stop(); start()\n    }\n\n    private func start() {\n        // Event-driven: refresh on writes/renames/deletes/extend\n        monitor?.cancel()\n        monitor = DirectoryMonitor(url: url) {\n            // UI updates must happen on main thread\n            Task { @MainActor in refresh() }\n        }\n        // Initial paint\n        refresh()\n    }\n\n    private func stop() {\n        monitor?.cancel(); monitor = nil\n    }\n\n    private func refresh() {\n        text = fileSize(url).map(formatBytes) ?? \"—\"\n    }\n\n    private func fileSize(_ url: URL) -> UInt64? {\n        if let attrs = try? FileManager.default.attributesOfItem(atPath: url.path),\n           let number = attrs[.size] as? NSNumber {\n            return number.uint64Value\n        }\n        return nil\n    }\n\n    private func formatBytes(_ bytes: UInt64) -> String {\n        let f = ByteCountFormatter()\n        f.allowedUnits = [.useKB, .useMB]\n        f.countStyle = .file\n        return f.string(fromByteCount: Int64(bytes))\n    }\n}\n"
  },
  {
    "path": "views/LocalAuthProviderIconView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct LocalAuthProviderIconView: View {\n  let provider: LocalAuthProvider\n  var size: CGFloat = 12\n  var cornerRadius: CGFloat = 2\n  var saturation: Double = 1.0\n  var opacity: Double = 1.0\n\n  @Environment(\\.colorScheme) private var colorScheme\n\n  var body: some View {\n    Group {\n      if let image = processedIcon {\n        Image(nsImage: image)\n          .resizable()\n          .interpolation(.high)\n          .aspectRatio(contentMode: .fit)\n          .frame(width: size, height: size)\n          .clipShape(RoundedRectangle(cornerRadius: cornerRadius))\n          .saturation(saturation)\n          .opacity(opacity)\n      } else {\n        Circle()\n          .fill(accent(for: provider))\n          .frame(width: dotSize, height: dotSize)\n          .saturation(saturation)\n          .opacity(opacity)\n      }\n    }\n    .frame(width: size, height: size, alignment: .center)\n    .id(colorScheme) // Force refresh when colorScheme changes\n  }\n\n  private var dotSize: CGFloat {\n    max(6, size * 0.75)\n  }\n\n  /// Computed property that depends on colorScheme, ensuring real-time theme updates\n  private var processedIcon: NSImage? {\n    let name = iconName(for: provider)\n    \n    // Use unified resource processing with theme adaptation\n    // This computed property depends on colorScheme, so SwiftUI will recompute it when theme changes\n    let isDarkMode = colorScheme == .dark\n    return ProviderIconResource.processedImage(\n      named: name,\n      size: NSSize(width: size, height: size),\n      isDarkMode: isDarkMode\n    )\n  }\n\n  private func iconName(for provider: LocalAuthProvider) -> String {\n    switch provider {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    case .antigravity: return \"AntigravityIcon\"\n    case .qwen: return \"QwenIcon\"\n    }\n  }\n\n  private func accent(for provider: LocalAuthProvider) -> Color {\n    switch provider {\n    case .codex: return Color.accentColor\n    case .claude: return Color(nsColor: .systemPurple)\n    case .gemini: return Color(nsColor: .systemTeal)\n    case .antigravity: return Color(nsColor: .systemIndigo)\n    case .qwen: return Color(nsColor: .systemOrange)\n    }\n  }\n\n}\n"
  },
  {
    "path": "views/MCPServerTargetToggle.swift",
    "content": "import SwiftUI\n\nstruct MCPServerTargetToggle: View {\n    let provider: UsageProviderKind\n    @Binding var isOn: Bool\n    var disabled: Bool\n\n    var body: some View {\n        Button {\n            if !disabled {\n                isOn.toggle()\n            }\n        } label: {\n            HStack(spacing: 4) {\n                providerIcon\n            }\n            .padding(.horizontal, 4)\n            .padding(.vertical, 4)\n        }\n        .buttonStyle(.plain)\n        .help(helpText)\n    }\n\n    @ViewBuilder\n    private var providerIcon: some View {\n        let active = isOn && !disabled\n        ProviderIconView(\n            provider: provider,\n            size: 14,\n            cornerRadius: 3,\n            saturation: active ? 1.0 : 0.0,\n            opacity: active ? 1.0 : 0.2\n        )\n    }\n\n    private var helpText: String {\n        let name = provider.displayName\n        if disabled {\n            return \"\\(name) integration (server disabled)\"\n        }\n        return isOn ? \"Disable for \\(name)\" : \"Enable for \\(name)\"\n    }\n}\n"
  },
  {
    "path": "views/MCPServersSettingsView.swift",
    "content": "import SwiftUI\nimport UniformTypeIdentifiers\nimport AppKit\n\nstruct MCPServersSettingsPane: View {\n    @StateObject private var vm = MCPServersViewModel()\n    @ObservedObject var preferences: SessionPreferencesStore\n    @State private var showImportConfirmation = false\n    @State private var showNewSheet = false\n    // New unified editor sheet\n    @State private var showEditorSheet = false\n    @State private var editorIsEditingExisting = false\n    var openMCPMateDownload: () -> Void\n    var showHeader: Bool = true\n    @State private var pendingDeleteName: String? = nil\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            if showHeader {\n                Text(\"MCP Servers\").font(.title2).fontWeight(.bold)\n                Text(\"Manage MCP servers. Add via Uni‑Import or configure capabilities.\")\n                    .font(.subheadline)\n                    .foregroundColor(.secondary)\n            }\n\n            // List header with Add + Import button (match Providers style)\n            HStack {\n                Spacer()\n                Button { editorIsEditingExisting = false; vm.startNewForm(); showEditorSheet = true } label: { Label(\"Add\", systemImage: \"plus\") }\n                Button { vm.beginImportFromHome() } label: { Label(\"Import\", systemImage: \"tray.and.arrow.down\") }\n            }\n\n            serversList\n            Spacer(minLength: 0)\n        }\n        .onAppear { Task { await vm.loadServers() } }\n        .onChange(of: showNewSheet) { newVal in\n            if newVal == false {\n                Task { await vm.loadServers() }\n            }\n        }\n        .onChange(of: showEditorSheet) { newVal in\n            if newVal == false {\n                Task { await vm.loadServers() }\n            }\n        }\n        .sheet(isPresented: $showNewSheet) {\n            NewMCPServerSheet(vm: vm, onClose: { showNewSheet = false })\n                .frame(minWidth: 640, minHeight: 420)\n        }\n        .sheet(isPresented: $showEditorSheet) {\n            MCPServerEditorSheet(\n                vm: vm,\n                preferences: preferences,\n                isEditing: editorIsEditingExisting,\n                onClose: { showEditorSheet = false }\n            )\n                .frame(minWidth: 760, minHeight: 480)\n        }\n        .sheet(isPresented: $vm.showImportSheet) {\n            MCPImportSheet(\n                candidates: $vm.importCandidates,\n                isImporting: vm.isImporting,\n                statusMessage: vm.importStatusMessage,\n                title: \"Import MCP Servers\",\n                subtitle: \"Scan Home for existing Codex/Claude/Gemini MCP servers and import into CodMate.\",\n                onCancel: { vm.cancelImport() },\n                onImport: { Task { await vm.importSelectedServers() } }\n            )\n            .frame(minWidth: 760, minHeight: 480)\n        }\n    }\n\n    // Extracted: Import view used inside New window\n    private var mcpImportTab: some View {\n        VStack(alignment: .leading, spacing: 14) {\n            Text(\"Uni-Import\").font(.headline).fontWeight(.semibold)\n            Text(\"Paste or drop JSON/TOML payloads to stage MCP servers before importing.\")\n                .font(.caption)\n                .foregroundColor(.secondary)\n\n            ZStack {\n                RoundedRectangle(cornerRadius: 8)\n                    .stroke(style: StrokeStyle(lineWidth: 1, dash: [5]))\n                    .foregroundStyle(.quaternary)\n                    .frame(height: 120)\n                VStack(spacing: 6) {\n                    Image(systemName: \"square.and.arrow.down\").font(.title3)\n                    Text(\"Drop text files or snippets here\")\n                        .font(.caption)\n                        .foregroundColor(.secondary)\n                }\n            }\n            .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL], isTargeted: nil) { providers in\n                handleImportProviders(providers)\n            }\n\n            HStack(spacing: 8) {\n                PasteButton(payloadType: String.self) { strings in\n                    if let text = strings.first(where: { !$0.isEmpty }) {\n                        vm.loadText(text)\n                    }\n                }\n                .buttonBorderShape(.roundedRectangle)\n                .controlSize(.small)\n\n                Button {\n                    vm.clearImport()\n                } label: {\n                    Label(\"Clear\", systemImage: \"xmark.circle\")\n                }\n                .buttonStyle(.bordered)\n                .controlSize(.small)\n                .disabled(vm.importText.isEmpty && vm.drafts.isEmpty && vm.importError == nil)\n            }\n\n            if vm.isParsing {\n                HStack(spacing: 6) {\n                    ProgressView().controlSize(.small)\n                    Text(\"Parsing input…\")\n                        .font(.caption)\n                        .foregroundColor(.secondary)\n                }\n            } else if let err = vm.importError {\n                Label(err, systemImage: \"exclamationmark.triangle\")\n                    .font(.caption)\n                    .foregroundColor(.red)\n            } else if !vm.drafts.isEmpty {\n                Label(\"Detected \\(vm.drafts.count) server(s). Review details below.\", systemImage: \"checkmark.circle\")\n                    .font(.caption)\n                    .foregroundColor(.green)\n            }\n\n            TextEditor(text: Binding(get: { vm.importText }, set: { _ in }))\n                .font(.system(.body, design: .monospaced))\n                .frame(minHeight: 200)\n                .disabled(true)\n                .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))\n\n            if !vm.drafts.isEmpty {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"Detected: \\(vm.drafts.count) server(s)\").font(.subheadline).fontWeight(.medium)\n                    ForEach(Array(vm.drafts.enumerated()), id: \\.offset) { (_, draft) in\n                        VStack(alignment: .leading, spacing: 4) {\n                            HStack(spacing: 8) {\n                                Image(systemName: draft.kind == .stdio ? \"terminal\" : (draft.kind == .sse ? \"dot.radiowaves.left.and.right\" : \"globe\"))\n                                Text(draft.name ?? \"(unnamed)\")\n                                    .font(.subheadline)\n                                Spacer()\n                            }\n                            if let url = draft.url, !url.isEmpty {\n                                Text(url)\n                                    .font(.caption)\n                                    .foregroundColor(.secondary)\n                                    .lineLimit(1)\n                                    .truncationMode(.middle)\n                            }\n                            if let description = draft.meta?.description, !description.isEmpty {\n                                Text(description)\n                                    .font(.caption)\n                                    .foregroundColor(.secondary)\n                            }\n                        }\n                        .padding(.vertical, 4)\n                    }\n                }\n\n                Button(action: { showImportConfirmation = true }) {\n                    Label(\"Import\", systemImage: \"tray.and.arrow.down.fill\")\n                }\n                .buttonStyle(.borderedProminent)\n                .disabled(vm.isParsing)\n            }\n        }\n        .padding(8)\n    }\n\n    private var serversList: some View {\n        Group {\n            if vm.servers.isEmpty {\n                VStack(spacing: 12) {\n                    Image(systemName: \"server.rack\").font(.system(size: 48)).foregroundStyle(.secondary)\n                    Text(\"No MCP Servers\")\n                        .font(.title3).fontWeight(.medium)\n                    Text(\"Click Add to import a server.\")\n                        .font(.subheadline).foregroundStyle(.secondary)\n                }\n                .frame(maxWidth: .infinity)\n                .frame(minHeight: 200)\n            } else {\n                List(selection: $vm.selectedServerName) {\n                    ForEach(vm.servers) { s in\n                        HStack(alignment: .center, spacing: 0) {\n                            Toggle(\"\", isOn: Binding(get: { s.enabled }, set: { v in Task { await vm.setServerEnabled(s, v) } }))\n                                .toggleStyle(.switch)\n                                .labelsHidden()\n                                .controlSize(.small)\n                                .padding(.trailing, 8)\n                            HStack(alignment: .center, spacing: 8) {\n                                Image(systemName: s.kind == .stdio ? \"terminal\" : (s.kind == .sse ? \"dot.radiowaves.left.and.right\" : \"globe\"))\n                                Text(s.name).font(.body.weight(.medium))\n                            }\n                            .frame(minWidth: 120, alignment: .leading)\n                            Spacer(minLength: 16)\n                            VStack(alignment: .leading, spacing: 2) {\n                                if let desc = s.meta?.description, !desc.isEmpty { Text(desc).font(.caption).foregroundStyle(.secondary) }\n                                HStack(spacing: 12) {\n                                    if let url = s.url, !url.isEmpty { Label(url, systemImage: \"link\").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) }\n                                    if let cmd = s.command, !cmd.isEmpty { Label(cmd, systemImage: \"terminal\").font(.caption).foregroundStyle(.secondary).lineLimit(1).truncationMode(.middle) }\n                                }\n                            }\n                            .frame(maxWidth: .infinity, alignment: .leading)\n                            HStack(spacing: 6) {\n                                MCPServerTargetToggle(\n                                    provider: .codex,\n                                    isOn: Binding(\n                                        get: { vm.isServerEnabled(s, for: .codex) },\n                                        set: { value in Task { await vm.setServerTargetEnabled(s, target: .codex, enabled: value) } }\n                                    ),\n                                    disabled: !s.enabled || !preferences.isCLIEnabled(.codex)\n                                )\n                                MCPServerTargetToggle(\n                                    provider: .claude,\n                                    isOn: Binding(\n                                        get: { vm.isServerEnabled(s, for: .claude) },\n                                        set: { value in Task { await vm.setServerTargetEnabled(s, target: .claude, enabled: value) } }\n                                    ),\n                                    disabled: !s.enabled || !preferences.isCLIEnabled(.claude)\n                                )\n                                MCPServerTargetToggle(\n                                    provider: .gemini,\n                                    isOn: Binding(\n                                        get: { vm.isServerEnabled(s, for: .gemini) },\n                                        set: { value in Task { await vm.setServerTargetEnabled(s, target: .gemini, enabled: value) } }\n                                    ),\n                                    disabled: !s.enabled || !preferences.isCLIEnabled(.gemini)\n                                )\n                            }\n                            .padding(.trailing, 8)\n                            Button {\n                                editorIsEditingExisting = true\n                                vm.startEditForm(from: s)\n                                showEditorSheet = true\n                            } label: { Image(systemName: \"pencil\").font(.body) }\n                            .buttonStyle(.borderless)\n                            .help(\"Edit server\")\n                        }\n                        .padding(.vertical, 8)\n                        .tag(s.name as String?)\n                        .contextMenu {\n                            Button(\"Edit…\") {\n                                editorIsEditingExisting = true\n                                vm.startEditForm(from: s)\n                                showEditorSheet = true\n                            }\n                            Divider()\n                            Button(role: .destructive) { pendingDeleteName = s.name } label: { Text(\"Delete\") }\n                        }\n                    }\n                }\n                .scrollContentBackground(.hidden)\n                .frame(minHeight: 200, maxHeight: .infinity, alignment: .top)\n            }\n        }\n        .task { await vm.loadServers() }\n        .padding(.horizontal, -8)\n        .alert(\"Delete MCP Server?\", isPresented: Binding(get: { pendingDeleteName != nil }, set: { if !$0 { pendingDeleteName = nil } })) {\n            Button(\"Delete\", role: .destructive) {\n                if let name = pendingDeleteName { Task { await vm.deleteServer(named: name) } }\n                pendingDeleteName = nil\n            }\n            Button(\"Cancel\", role: .cancel) { pendingDeleteName = nil }\n        } message: {\n            if let name = pendingDeleteName { Text(\"Are you sure you want to delete \\\"\\(name)\\\"? This action cannot be undone.\") } else { Text(\"\") }\n        }\n    }\n\n    private var mcpAdvancedTab: some View {\n        VStack(alignment: .leading, spacing: 16) {\n            HStack(alignment: .center, spacing: 12) {\n                Image(\"MCPMateLogo\")\n                    .resizable()\n                    .frame(width: 48, height: 48)\n                    .cornerRadius(12)\n                VStack(alignment: .leading, spacing: 0) {\n                    Text(\"MCPMate\").font(.headline)\n                    Text(\"A 'Maybe All-in-One' MCP service manager for developers and creators.\")\n                        .font(.subheadline).fontWeight(.semibold)\n                }\n            }\n            Text(\"MCPMate offers advanced MCP server management beyond CodMate's basic import and enable/disable controls.\")\n                .font(.body).foregroundColor(.secondary)\n            Text(\"Download MCPMate to configure MCP servers alongside CodMate.\")\n                .font(.subheadline).foregroundColor(.secondary)\n            Button(action: openMCPMateDownload) { Label(\"Download MCPMate\", systemImage: \"arrow.down.circle.fill\").labelStyle(.titleAndIcon) }\n                .buttonStyle(.borderedProminent)\n                .controlSize(.large)\n                .font(.body.weight(.semibold))\n        }\n        .padding(8)\n    }\n\n    private func handleImportProviders(_ providers: [NSItemProvider]) -> Bool {\n        var handled = false\n        for provider in providers {\n            if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.canLoadObject(ofClass: String.self) {\n                _ = provider.loadObject(ofClass: String.self) { string, _ in\n                    guard let string = string else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(string)\n                    }\n                }\n                handled = true\n            }\n        }\n        return handled\n    }\n\n    private func readText(from representation: (any NSSecureCoding)?) -> String? {\n        if let string = representation as? String { return string }\n        if let url = representation as? URL {\n            return try? String(contentsOf: url, encoding: .utf8)\n        }\n        if let data = representation as? Data {\n            if let url = URL(dataRepresentation: data, relativeTo: nil) {\n                return try? String(contentsOf: url, encoding: .utf8)\n            }\n            return String(data: data, encoding: .utf8)\n        }\n        return nil\n    }\n}\n\n// MARK: - New MCP Server Sheet (Import + Form placeholder)\nprivate struct NewMCPServerSheet: View {\n    @ObservedObject var vm: MCPServersViewModel\n    var onClose: () -> Void\n    @State private var showImportConfirmation = false\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack(alignment: .firstTextBaseline) {\n                Text(\"New MCP Server\").font(.title3).fontWeight(.semibold)\n                Spacer()\n                Button(\"Close\") { onClose() }.buttonStyle(.borderless)\n            }\n            SettingsTabContent { mcpImportContent }\n            HStack {\n                Spacer()\n                Button(\"Import\") { showImportConfirmation = true }\n                    .buttonStyle(.borderedProminent)\n                    .disabled(vm.isParsing || (vm.drafts.isEmpty && vm.importText.isEmpty))\n            }\n        }\n        .padding(12)\n        .alert(\"Import Servers?\", isPresented: $showImportConfirmation) {\n            Button(\"Import\", role: .none) { Task { await vm.importDrafts(); onClose() } }\n            Button(\"Discard Drafts\", role: .destructive) { vm.clearImport() }\n            Button(\"Cancel\", role: .cancel) {}\n        } message: { Text(\"Import \\(vm.drafts.count) server(s) into CodMate?\") }\n    }\n\n    @ViewBuilder\n    private var mcpImportContent: some View {\n        VStack(alignment: .leading, spacing: 14) {\n            Text(\"Uni-Import\").font(.headline).fontWeight(.semibold)\n            Text(\"Paste or drop JSON/TOML payloads to stage MCP servers before importing.\")\n                .font(.caption).foregroundColor(.secondary)\n            ZStack {\n                RoundedRectangle(cornerRadius: 8).stroke(style: StrokeStyle(lineWidth: 1, dash: [5])).foregroundStyle(.quaternary).frame(height: 120)\n                VStack(spacing: 6) {\n                    Image(systemName: \"square.and.arrow.down\").font(.title3)\n                    Text(\"Drop text files or snippets here\").font(.caption).foregroundColor(.secondary)\n                }\n            }\n            .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL], isTargeted: nil) { providers in\n                handleDropProviders(providers)\n            }\n            HStack(spacing: 8) {\n                PasteButton(payloadType: String.self) { strings in if let text = strings.first(where: { !$0.isEmpty }) { vm.loadText(text) } }\n                    .buttonBorderShape(.roundedRectangle).controlSize(.small)\n                Button { vm.clearImport() } label: { Label(\"Clear\", systemImage: \"xmark.circle\") }\n                    .buttonStyle(.bordered).controlSize(.small)\n                    .disabled(vm.importText.isEmpty && vm.drafts.isEmpty && vm.importError == nil)\n            }\n            if vm.isParsing {\n                HStack(spacing: 6) { ProgressView().controlSize(.small); Text(\"Parsing input…\").font(.caption).foregroundColor(.secondary) }\n            } else if let err = vm.importError {\n                Label(err, systemImage: \"exclamationmark.triangle\").font(.caption).foregroundColor(.red)\n            } else if !vm.drafts.isEmpty {\n                Label(\"Detected \\(vm.drafts.count) server(s). Review details below.\", systemImage: \"checkmark.circle\").font(.caption).foregroundColor(.green)\n            }\n            TextEditor(text: Binding(get: { vm.importText }, set: { _ in }))\n                .font(.system(.body, design: .monospaced)).frame(minHeight: 200).disabled(true)\n                .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))\n            if !vm.drafts.isEmpty {\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(\"Detected: \\(vm.drafts.count) server(s)\").font(.subheadline).fontWeight(.medium)\n                    ForEach(Array(vm.drafts.enumerated()), id: \\.offset) { (_, draft) in\n                        VStack(alignment: .leading, spacing: 4) {\n                            HStack(spacing: 8) {\n                                Image(systemName: draft.kind == .stdio ? \"terminal\" : (draft.kind == .sse ? \"dot.radiowaves.left.and.right\" : \"globe\"))\n                                Text(draft.name ?? \"—\").font(.subheadline).fontWeight(.medium)\n                                Spacer()\n                            }\n                            if let desc = draft.meta?.description { Text(desc).font(.caption).foregroundColor(.secondary) }\n                        }\n                        .padding(.vertical, 4)\n                    }\n                }\n                .controlSize(.small)\n            }\n        }\n    }\n    // Local drop handler (sheet scope)\n    private func handleDropProviders(_ providers: [NSItemProvider]) -> Bool {\n        var handled = false\n        for provider in providers {\n            if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in\n                    guard let data = data,\n                          let url = data as? URL,\n                          let text = try? String(contentsOf: url, encoding: .utf8)\n                    else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n        }\n        return handled\n    }\n\n    private func handleImportProviders(_ providers: [NSItemProvider]) -> Bool {\n        var handled = false\n        for provider in providers {\n            if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(text)\n                    }\n                }\n                handled = true\n                continue\n            }\n            if provider.canLoadObject(ofClass: String.self) {\n                _ = provider.loadObject(ofClass: String.self) { string, _ in\n                    guard let string = string else { return }\n                    handled = true\n                    DispatchQueue.main.async {\n                        vm.loadText(string)\n                    }\n                }\n                handled = true\n            }\n        }\n        return handled\n    }\n\n    private func readText(from representation: (any NSSecureCoding)?) -> String? {\n        if let string = representation as? String { return string }\n        if let url = representation as? URL {\n            return try? String(contentsOf: url, encoding: .utf8)\n        }\n        if let data = representation as? Data {\n            if let url = URL(dataRepresentation: data, relativeTo: nil) {\n                return try? String(contentsOf: url, encoding: .utf8)\n            }\n            return String(data: data, encoding: .utf8)\n        }\n        return nil\n    }\n}\n\n// MARK: - Unified Editor Sheet (JSON + Form)\nprivate struct MCPServerEditorSheet: View {\n    @ObservedObject var vm: MCPServersViewModel\n    @ObservedObject var preferences: SessionPreferencesStore\n    var isEditing: Bool\n    var onClose: () -> Void\n    @State private var selectedTab: Int = 0 // 0=Form, 1=JSON\n    @State private var isDropTargeted: Bool = false\n    @State private var breathing: Bool = false\n    @State private var wizardActive: Bool = false\n    @FocusState private var focusedField: FocusField?\n\n    private enum FocusField {\n        case name\n    }\n\n    var body: some View {\n        if wizardActive {\n            MCPWizardSheet(preferences: preferences, onApply: { draft in\n                applyDraft(draft)\n                wizardActive = false\n            }, onCancel: {\n                wizardActive = false\n            })\n        } else {\n            VStack(alignment: .leading, spacing: 12) {\n                HStack(alignment: .firstTextBaseline) {\n                    Text(isEditing ? \"Edit MCP Server\" : \"New MCP Server\").font(.title3).fontWeight(.semibold)\n                    Spacer()\n                    Button {\n                        wizardActive = true\n                    } label: {\n                        Image(systemName: \"sparkles\")\n                    }\n                    .buttonStyle(.borderless)\n                    .help(\"AI Wizard\")\n                }\n                if !isEditing {\n                    SettingsTabContent { importArea }\n                }\n                if #available(macOS 15.0, *) {\n                    TabView(selection: $selectedTab) {\n                        Tab(\"Form\", systemImage: \"slider.horizontal.3\", value: 0) {\n                            SettingsTabContent { formTab }\n                        }\n                        Tab(\"JSON\", systemImage: \"doc.text\", value: 1) {\n                            SettingsTabContent { jsonConfigTab }\n                        }\n                    }\n                } else {\n                    TabView(selection: $selectedTab) {\n                        SettingsTabContent { formTab }\n                            .tabItem { Label(\"Form\", systemImage: \"slider.horizontal.3\") }\n                            .tag(0)\n                        SettingsTabContent { jsonConfigTab }\n                            .tabItem { Label(\"JSON\", systemImage: \"doc.text\") }\n                            .tag(1)\n                    }\n                }\n                if let msg = vm.testMessage, !msg.isEmpty {\n                    Text(msg)\n                        .font(.caption)\n                        .foregroundStyle(msg.hasPrefix(\"Connected\") ? .green : .red)\n                }\n                HStack {\n                    if vm.testInProgress {\n                        Button(\"Stop\") { vm.cancelTest() }\n                            .buttonStyle(.bordered)\n                    } else {\n                        Button(\"Test\") { vm.startTest() }\n                            .buttonStyle(.bordered)\n                    }\n                    Spacer()\n                    Button(\"Cancel\") { onClose() }\n                    Button(isEditing ? \"Save\" : \"Create\") {\n                        Task { if await vm.saveForm() { onClose() } }\n                    }\n                    .buttonStyle(.borderedProminent)\n                    .disabled(!vm.formCanSave())\n                }\n            }\n            .padding(16)\n            .onAppear {\n                if !isEditing {\n                    DispatchQueue.main.async {\n                        focusedField = .name\n                    }\n                }\n            }\n        }\n    }\n\n    // MARK: - Import area (top, new-only)\n    @ViewBuilder private var importArea: some View {\n        VStack(alignment: .leading, spacing: 14) {\n            ZStack {\n                // No border/background\n                VStack(spacing: 10) {\n                    Image(systemName: \"target\")\n                        .font(.system(size: 48))\n                        .scaleEffect(breathing ? 1.08 : 1.0)\n                        .brightness(breathing ? 0.2 : -0.2)\n                        .foregroundStyle(breathing ? Color.accentColor.opacity(0.8) : Color.secondary)\n                    Text(\"Paste or drop JSON payloads to stage MCP servers; detected entries will autofill the form below.\")\n                        .font(.caption)\n                        .foregroundStyle(breathing ? Color.accentColor.opacity(0.85) : Color.secondary)\n                        .multilineTextAlignment(.center)\n                        .scaleEffect(breathing ? 1.02 : 1.0)\n                        .brightness(breathing ? 0.2 : -0.2)\n                    Group {\n                        if vm.isParsing {\n                            HStack(spacing: 6) { ProgressView().controlSize(.small); Text(\"Parsing input…\").font(.caption).foregroundStyle(.secondary) }\n                        } else if let err = vm.importError {\n                            Label(err, systemImage: \"exclamationmark.triangle\").font(.caption).foregroundStyle(.red)\n                        } else if !vm.drafts.isEmpty {\n                            Label(\"Detected \\(vm.drafts.count) server(s)\", systemImage: \"checkmark.circle\").font(.caption).foregroundStyle(.green)\n                        }\n                    }\n                    // paste/clear moved to context menu\n                }\n                .frame(maxWidth: .infinity)\n                .frame(height: 140)\n            }\n            .contentShape(Rectangle())\n            .allowsHitTesting(true)\n            .frame(maxWidth: .infinity, minHeight: 140)\n            // Native NSView-based drop catcher for precise hover (drag-in) detection\n            .overlay(\n                DropCatcher(\n                    isTargeted: $isDropTargeted,\n                    onString: { vm.loadText($0) },\n                    onURL: { url in if let text = try? String(contentsOf: url, encoding: .utf8) { vm.loadText(text) } }\n                )\n            )\n            .contextMenu {\n                Button(\"Paste JSON\") {\n                    let pb = NSPasteboard.general\n                    if let s = pb.string(forType: .string), !s.isEmpty { vm.loadText(s) }\n                }\n                Button(\"Clean\") { vm.clearImport() }\n            }\n            // SwiftUI drop as fallback (kept minimal to avoid conflicting hover state)\n            .onDrop(of: [UTType.json, UTType.plainText, UTType.fileURL, UTType.text], isTargeted: .constant(false)) { providers in\n                handleDropProviders(providers)\n            }\n            .onChange(of: isDropTargeted) { now in\n                if now {\n                    withAnimation(.easeInOut(duration: 0.9).repeatForever(autoreverses: true)) {\n                        breathing = true\n                    }\n                } else {\n                    withAnimation(.easeOut(duration: 0.2)) { breathing = false }\n                }\n            }\n            .onChange(of: vm.isParsing) { parsing in\n                // Stop breathing once parsing finishes (drop completed)\n                if parsing == false {\n                    isDropTargeted = false\n                    withAnimation(.easeOut(duration: 0.2)) { breathing = false }\n                }\n            }\n            .onChange(of: vm.drafts.count) { _ in\n                // Any detected entries imply drop completed; stop highlight\n                isDropTargeted = false\n                withAnimation(.easeOut(duration: 0.2)) { breathing = false }\n            }\n            .onChange(of: vm.importError) { _ in\n                // Error also ends the hover state; stop highlight\n                isDropTargeted = false\n                withAnimation(.easeOut(duration: 0.2)) { breathing = false }\n            }\n        }\n    }\n\n    // MARK: - Form Tab (primary)\n    @ViewBuilder private var formTab: some View {\n        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n            GridRow {\n                Text(\"Name\").font(.subheadline).fontWeight(.medium)\n                TextField(\"server-id\", text: $vm.formName)\n                    .focused($focusedField, equals: .name)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            GridRow {\n                Text(\"Kind\").font(.subheadline).fontWeight(.medium)\n                Picker(\"\", selection: $vm.formKind) {\n                    Text(\"stdio\").tag(MCPServerKind.stdio)\n                    Text(\"sse\").tag(MCPServerKind.sse)\n                    Text(\"streamable_http\").tag(MCPServerKind.streamable_http)\n                }\n                .labelsHidden()\n                .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            // Network endpoint (visible for non-stdio kinds)\n            if vm.formKind != .stdio {\n                GridRow { Text(\"URL\").font(.subheadline).fontWeight(.medium); TextField(\"https://…\", text: $vm.formURL).frame(maxWidth: .infinity, alignment: .trailing) }\n            }\n            // Process endpoint (visible for stdio)\n            if vm.formKind == .stdio {\n                GridRow { Text(\"Command\").font(.subheadline).fontWeight(.medium); TextField(\"/usr/local/bin/mcp-server\", text: $vm.formCommand).frame(maxWidth: .infinity, alignment: .trailing) }\n                GridRow {\n                    Text(\"Args\").font(.subheadline).fontWeight(.medium)\n                    TextEditor(text: $vm.formArgs)\n                        .font(.system(.caption, design: .monospaced))\n                        .frame(height: 80)\n                        .frame(maxWidth: .infinity, alignment: .trailing)\n                }\n            }\n            // Env (both kinds)\n            GridRow {\n                Text(\"Env\").font(.subheadline).fontWeight(.medium)\n                TextEditor(text: $vm.formEnvText)\n                    .font(.system(.caption, design: .monospaced))\n                    .frame(height: 80)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n            }\n            // Headers (only for network kinds)\n            if vm.formKind != .stdio {\n                GridRow {\n                    Text(\"Headers\").font(.subheadline).fontWeight(.medium)\n                    TextEditor(text: $vm.formHeadersText)\n                        .font(.system(.caption, design: .monospaced))\n                        .frame(height: 80)\n                        .frame(maxWidth: .infinity, alignment: .trailing)\n                }\n            }\n            if isEditing {\n                GridRow {\n                    Text(\"Targets\").font(.subheadline).fontWeight(.medium)\n                    HStack(spacing: 12) {\n                        Toggle(\"Codex\", isOn: $vm.formTargetsCodex)\n                            .toggleStyle(.switch)\n                            .controlSize(.small)\n                        Toggle(\"Claude Code\", isOn: $vm.formTargetsClaude)\n                            .toggleStyle(.switch)\n                            .controlSize(.small)\n                        Toggle(\"Gemini\", isOn: $vm.formTargetsGemini)\n                            .toggleStyle(.switch)\n                            .controlSize(.small)\n                    }\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                }\n            }\n            // Enabled is controlled in list view only\n        }\n    }\n\n    // MARK: - JSON config Tab (preview)\n    @ViewBuilder private var jsonConfigTab: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Text(\"Server JSON preview (read-only)\").font(.caption).foregroundStyle(.secondary)\n            ScrollView {\n                Text(vm.formJSONPreview())\n                    .font(.system(.body, design: .monospaced))\n                    .textSelection(.enabled)\n                    .frame(maxWidth: .infinity, alignment: .topLeading)\n                    .padding(8)\n            }\n            .frame(minHeight: 220)\n            .overlay(RoundedRectangle(cornerRadius: 6).stroke(.quaternary))\n        }\n    }\n\n    // Local drop handler for JSON tab\n    private func handleDropProviders(_ providers: [NSItemProvider]) -> Bool {\n        var handled = false\n        for provider in providers {\n            if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { data, _ in\n                    guard let data = data,\n                          let url = data as? URL,\n                          let text = try? String(contentsOf: url, encoding: .utf8)\n                    else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.json.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.json.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n            if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n                provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { data, _ in\n                    guard let text = readText(from: data) else { return }\n                    handled = true\n                    DispatchQueue.main.async { vm.loadText(text) }\n                }\n                handled = true\n                continue\n            }\n        }\n        return handled\n    }\n\n    private func readText(from representation: (any NSSecureCoding)?) -> String? {\n        if let string = representation as? String { return string }\n        if let url = representation as? URL { return try? String(contentsOf: url, encoding: .utf8) }\n        if let data = representation as? Data {\n            if let url = URL(dataRepresentation: data, relativeTo: nil) { return try? String(contentsOf: url, encoding: .utf8) }\n            return String(data: data, encoding: .utf8)\n        }\n        return nil\n    }\n\n    private func applyDraft(_ draft: MCPWizardDraft) {\n        vm.formName = draft.name\n        vm.formKind = draft.kind\n        vm.formURL = draft.url ?? \"\"\n        vm.formCommand = draft.command ?? \"\"\n        vm.formArgs = (draft.args ?? []).joined(separator: \"\\n\")\n        vm.formEnvText = formatPairs(draft.env)\n        vm.formHeadersText = formatPairs(draft.headers)\n        if let targets = draft.targets {\n            vm.formTargetsCodex = targets.codex\n            vm.formTargetsClaude = targets.claude\n            vm.formTargetsGemini = targets.gemini\n        }\n    }\n\n    private func formatPairs(_ dict: [String: String]?) -> String {\n        guard let dict, !dict.isEmpty else { return \"\" }\n        return dict.keys.sorted().map { \"\\($0)=\\(dict[$0]!)\" }.joined(separator: \"\\n\")\n    }\n}\n\n// MARK: - NSViewRepresentable Drop Catcher\nprivate struct DropCatcher: NSViewRepresentable {\n    @Binding var isTargeted: Bool\n    var onString: (String) -> Void\n    var onURL: (URL) -> Void\n\n    func makeNSView(context: Context) -> NSView {\n        let v = DropCatcherView()\n        v.onString = onString\n        v.onURL = onURL\n        v.onHoverChange = { targeted in\n            DispatchQueue.main.async { self.isTargeted = targeted }\n        }\n        return v\n    }\n    func updateNSView(_ nsView: NSView, context: Context) {}\n\n    final class DropCatcherView: NSView {\n        var onString: ((String) -> Void)?\n        var onURL: ((URL) -> Void)?\n        var onHoverChange: ((Bool) -> Void)?\n\n        override init(frame frameRect: NSRect) {\n            super.init(frame: frameRect)\n            registerForDraggedTypes([\n                .fileURL,\n                .URL,\n                .string\n            ])\n        }\n        required init?(coder: NSCoder) { fatalError(\"init(coder:) has not been implemented\") }\n\n        override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {\n            onHoverChange?(true)\n            return .copy\n        }\n        override func draggingExited(_ sender: NSDraggingInfo?) {\n            onHoverChange?(false)\n        }\n        override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {\n            onHoverChange?(false)\n            let pb = sender.draggingPasteboard\n            if let urls = pb.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], let url = urls.first {\n                onURL?(url); return true\n            }\n            if let str = pb.string(forType: .string), !str.isEmpty {\n                onString?(str); return true\n            }\n            return false\n        }\n    }\n}\n"
  },
  {
    "path": "views/ModelListEditorSheet.swift",
    "content": "import SwiftUI\n\nstruct ModelListEditorSheet: View {\n  let title: String\n  let description: String\n  let availableModels: [String]\n  let onSave: ([String]) -> Void\n  let onReset: (() -> Void)?\n\n  @State private var draft: [String]\n  @Environment(\\.dismiss) private var dismiss\n\n  init(\n    title: String,\n    description: String,\n    availableModels: [String],\n    models: [String],\n    onSave: @escaping ([String]) -> Void,\n    onReset: (() -> Void)? = nil\n  ) {\n    self.title = title\n    self.description = description\n    self.availableModels = availableModels\n    self.onSave = onSave\n    self.onReset = onReset\n    self._draft = State(initialValue: models)\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 16) {\n      Text(title).font(.title2).fontWeight(.semibold)\n      Text(description)\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      VStack(alignment: .leading, spacing: 8) {\n        ForEach(draft.indices, id: \\.self) { index in\n          HStack(spacing: 8) {\n            TextField(\"model-id\", text: Binding(\n              get: { draft[index] },\n              set: { draft[index] = $0 }\n            ))\n            Button(role: .destructive) {\n              draft.remove(at: index)\n            } label: {\n              Image(systemName: \"minus.circle\")\n            }\n            .buttonStyle(.borderless)\n            .help(\"Remove\")\n          }\n        }\n        if draft.isEmpty {\n          Text(\"No models selected yet.\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n      }\n      .padding(10)\n      .background(Color(nsColor: .separatorColor).opacity(0.35))\n      .cornerRadius(10)\n\n      HStack(spacing: 8) {\n        Menu {\n          if !availableModels.isEmpty {\n            Section(\"Available\") {\n              ForEach(availableModels, id: \\.self) { model in\n                Button(model) { draft.append(model) }\n                  .disabled(draft.contains(model))\n              }\n            }\n            Divider()\n          }\n          Button(\"Custom…\") { draft.append(\"\") }\n        } label: {\n          Label(\"Add\", systemImage: \"plus\")\n        }\n        if let onReset {\n          Button(\"Reset to Auto\") {\n            onReset()\n            dismiss()\n          }\n        }\n        Spacer()\n        Button(\"Cancel\", role: .cancel) { dismiss() }\n        Button(\"Save\") {\n          onSave(Self.sanitize(draft))\n          dismiss()\n        }\n        .buttonStyle(.borderedProminent)\n      }\n    }\n    .padding(16)\n    .frame(minWidth: 520)\n  }\n\n  private static func sanitize(_ list: [String]) -> [String] {\n    var seen = Set<String>()\n    var out: [String] = []\n    for item in list {\n      let trimmed = item.trimmingCharacters(in: .whitespacesAndNewlines)\n      guard !trimmed.isEmpty else { continue }\n      if seen.insert(trimmed).inserted {\n        out.append(trimmed)\n      }\n    }\n    return out\n  }\n}\n"
  },
  {
    "path": "views/NewTaskSheet.swift",
    "content": "import SwiftUI\n\nstruct NewTaskSheet: View {\n  @Environment(\\.dismiss) var dismiss\n  @ObservedObject var viewModel: SessionListViewModel\n\n  @State private var title: String = \"\"\n  @State private var description: String = \"\"\n  @State private var selectedType: TaskType = .other\n  @State private var selectedProvider: ProjectSessionSource = .codex\n  @State private var selectedProjectId: String = \"\"\n  @State private var isCreating: Bool = false\n\n  var body: some View {\n    Form {\n      Section(\"Task Details\") {\n        TextField(\"Task Title\", text: $title, prompt: Text(\"Enter task title\"))\n          .textFieldStyle(.roundedBorder)\n\n        TextEditor(text: $description)\n          .frame(minHeight: 80)\n          .overlay(alignment: .topLeading) {\n            if description.isEmpty {\n              Text(\"Enter task description (optional)\")\n                .foregroundColor(.secondary)\n                .padding(.leading, 5)\n                .padding(.top, 8)\n                .allowsHitTesting(false)\n            }\n          }\n          .overlay(\n            RoundedRectangle(cornerRadius: 6)\n              .stroke(Color.gray.opacity(0.2), lineWidth: 1)\n          )\n      }\n\n      Section(\"Task Type\") {\n        Picker(\"Type\", selection: $selectedType) {\n          ForEach(TaskType.allCases) { type in\n            Label {\n              Text(type.displayName)\n            } icon: {\n              Image(systemName: type.icon)\n            }\n            .tag(type)\n          }\n        }\n        .pickerStyle(.menu)\n\n        Text(selectedType.descriptionTemplate)\n          .font(.caption)\n          .foregroundColor(.secondary)\n      }\n\n      Section(\"Provider\") {\n        Picker(\"Default Provider\", selection: $selectedProvider) {\n          ForEach(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }) { provider in\n            Text(provider.displayName)\n              .tag(provider)\n          }\n        }\n        .pickerStyle(.segmented)\n      }\n\n      Section(\"Project\") {\n        Picker(\"Project\", selection: $selectedProjectId) {\n          Text(\"None\").tag(\"\")\n          ForEach(viewModel.projects) { project in\n            Text(project.name).tag(project.id)\n          }\n        }\n        .pickerStyle(.menu)\n      }\n    }\n    .formStyle(.grouped)\n    .frame(width: 500, height: 480)\n    .toolbar {\n      ToolbarItem(placement: .cancellationAction) {\n        Button(\"Cancel\") {\n          dismiss()\n        }\n      }\n      ToolbarItem(placement: .confirmationAction) {\n        Button(\"Create\") {\n          createTask()\n        }\n        .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isCreating)\n      }\n    }\n    .onAppear {\n      let enabled = ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }\n      if !enabled.contains(selectedProvider), let first = enabled.first {\n        selectedProvider = first\n      }\n      // Set default project if one is selected\n      if let firstSelected = viewModel.selectedProjectIDs.first {\n        selectedProjectId = firstSelected\n      } else if selectedProjectId.isEmpty, let firstProject = viewModel.projects.first {\n        selectedProjectId = firstProject.id\n      }\n    }\n  }\n\n  private func createTask() {\n    let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmedTitle.isEmpty else { return }\n\n    isCreating = true\n\n    let task = CodMateTask(\n      title: trimmedTitle,\n      description: description.trimmingCharacters(in: .whitespacesAndNewlines),\n      taskType: selectedType,\n      projectId: selectedProjectId.isEmpty ? \"none\" : selectedProjectId,\n      status: .pending,\n      primaryProvider: selectedProvider\n    )\n\n    Task {\n      await viewModel.createTask(task)\n      await MainActor.run {\n        dismiss()\n      }\n    }\n  }\n}\n\n#Preview {\n  NewTaskSheet(\n    viewModel: SessionListViewModel(\n      preferences: SessionPreferencesStore()\n    )\n  )\n}\n"
  },
  {
    "path": "views/OverviewActivityChart.swift",
    "content": "import Charts\nimport SwiftUI\n\nstruct OverviewActivityChart: View {\n    let data: ActivityChartData\n    let enabledSources: Set<SessionSource.Kind>\n    var onSelectDate: ((Date) -> Void)?\n\n    @State private var selectedMetric: Metric = .count\n    @State private var hiddenSources: Set<SessionSource.Kind> = []\n    @State private var hoverDate: Date?\n    @State private var hoverLocation: CGPoint = .zero\n    @AppStorage(\"overviewChartBarWidth\") private var barWidth: Double = 32.0\n    @State private var isHoveringZoomControls = false\n    @State private var isHoveringChartArea = false\n    @State private var hoverExitTask: Task<Void, Never>? = nil\n\n    private let minBarWidth: CGFloat = 16.0\n    private let maxBarWidth: CGFloat = 64.0\n    private let chartCoordinateSpaceName = \"OverviewActivityChart\"\n\n    enum Metric: String, CaseIterable, Identifiable {\n        case count = \"Sessions\"\n        case duration = \"Duration\"\n        case tokens = \"Tokens\"\n\n        var id: String { rawValue }\n    }\n\n    // All available sources for the legend\n    private let allSources: [SessionSource.Kind] = [.codex, .claude, .gemini]\n\n    init(\n      data: ActivityChartData,\n      enabledSources: Set<SessionSource.Kind>,\n      onSelectDate: ((Date) -> Void)? = nil\n    ) {\n      self.data = data\n      self.enabledSources = enabledSources\n      self.onSelectDate = onSelectDate\n      let disabledSources = Set(allSources).subtracting(enabledSources)\n      _hiddenSources = State(initialValue: disabledSources)\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            headerView\n\n            if data.points.isEmpty {\n                emptyStateView\n            } else {\n                chartContainer\n            }\n        }\n        .zIndex(1)\n        .onChange(of: enabledSourcesHash) { _ in\n          let disabledSources = Set(allSources).subtracting(enabledSources)\n          hiddenSources.formUnion(disabledSources)\n        }\n    }\n\n    // MARK: - Header\n    private var headerView: some View {\n        HStack {\n            // Left: Metric Picker + Zoom\n            HStack(spacing: 8) {\n                Picker(\"Metric\", selection: $selectedMetric) {\n                    ForEach(Metric.allCases) { metric in\n                        Text(metric.rawValue).tag(metric)\n                    }\n                }\n                .pickerStyle(.segmented)\n                .labelsHidden()\n                .fixedSize()\n                .controlSize(.small)\n\n                // Zoom Controls\n                HStack(spacing: 0) {\n                    Button {\n                        withAnimation(.spring(response: 0.3)) {\n                            barWidth = max(Double(minBarWidth), barWidth - 8)\n                        }\n                    } label: {\n                        Image(systemName: \"minus\")\n                            .frame(width: 16, height: 16)\n                    }\n                    .disabled(barWidth <= Double(minBarWidth))\n                    .buttonStyle(.plain)\n                    .contentShape(Rectangle())\n\n                    Button {\n                        withAnimation(.spring(response: 0.3)) {\n                            barWidth = min(Double(maxBarWidth), barWidth + 8)\n                        }\n                    } label: {\n                        Image(systemName: \"plus\")\n                            .frame(width: 16, height: 16)\n                    }\n                    .disabled(barWidth >= Double(maxBarWidth))\n                    .buttonStyle(.plain)\n                    .contentShape(Rectangle())\n                }\n                .background(Color.primary.opacity(isHoveringZoomControls ? 0.15 : 0.05))\n                .cornerRadius(4)\n                .opacity(zoomControlsOpacity)\n                .onHover { isHovering in\n                    withAnimation(.easeInOut(duration: 0.2)) {\n                        isHoveringZoomControls = isHovering\n                    }\n                }\n            }\n\n            Spacer()\n\n            // Right: Legend\n            HStack(spacing: 12) {\n                ForEach(legendSources, id: \\.self) { source in\n                    HStack(spacing: 4) {\n                        Circle()\n                            .fill(color(for: source))\n                            .frame(width: 8, height: 8)\n                            .opacity(hiddenSources.contains(source) ? 0.3 : 1.0)\n\n                        Text(source.rawValue.capitalized)\n                            .font(.caption)\n                            .foregroundStyle(hiddenSources.contains(source) ? .secondary : .primary)\n                    }\n                    .onTapGesture {\n                        withAnimation(.easeInOut(duration: 0.2)) {\n                            if hiddenSources.contains(source) {\n                                hiddenSources.remove(source)\n                            } else {\n                                hiddenSources.insert(source)\n                            }\n                        }\n                    }\n                    .contentShape(Rectangle())  // Improve tap area\n                    .help(\"Toggle \\(source.rawValue.capitalized)\")\n                }\n            }\n        }\n    }\n\n    // MARK: - Chart\n    private var chartContainer: some View {\n        GeometryReader { geometry in\n            let stepWidth: CGFloat = CGFloat(barWidth)\n            let uniqueDates = Set(data.points.map { $0.date }).sorted()\n            // uniqueXCount is no longer used for width calculation\n            // let uniqueXCount = uniqueDates.count\n            // requiredWidth is calculated later based on time span\n            let chartAreaWidth = geometry.size.width  // Full width now\n\n            // Determine Y-axis domain (Value) - Filtered\n            let maxVal = maxYValue(for: uniqueDates)\n            let yScale = 0...maxVal\n\n            // Determine X-axis domain (Time) - FULL (Unfiltered)\n            // This ensures the axis doesn't shrink if data points are hidden\n            // We use the min/max of the actual data available in this view model snapshot\n            let minDate = uniqueDates.first ?? Date()\n            let maxDate = uniqueDates.last ?? Date()\n\n            // Adjust domain to center bars (add 0.5 unit padding on each side)\n            let calendar = Calendar.current\n            let (adjMin, adjMax): (Date, Date) = {\n                if data.unit == .day {\n                    let min = calendar.date(byAdding: .hour, value: -12, to: minDate) ?? minDate\n                    let max = calendar.date(byAdding: .hour, value: 12, to: maxDate) ?? maxDate\n                    return (min, max)\n                } else {\n                    let min = calendar.date(byAdding: .minute, value: -30, to: minDate) ?? minDate\n                    let max = calendar.date(byAdding: .minute, value: 30, to: maxDate) ?? maxDate\n                    return (min, max)\n                }\n            }()\n            let xDomain = adjMin...adjMax\n\n            // Calculate required width based on TIME SPAN, not point count\n            let component: Calendar.Component = data.unit == .day ? .day : .hour\n            let diff =\n                calendar.dateComponents([component], from: minDate, to: maxDate).value(\n                    for: component) ?? 0\n            let totalSlots = max(1, diff + 1)  // Inclusive count\n            let requiredWidth = CGFloat(totalSlots) * stepWidth\n\n            // Scrollable Chart Area\n            ZStack(alignment: .topLeading) {\n                scrollContainer {\n                    ZStack {\n                        HStack(spacing: 0) {\n                            if requiredWidth < chartAreaWidth {\n                                Spacer(minLength: 0)\n                            }\n\n                            chartContent(yScale: yScale, xDomain: xDomain)\n                                .frame(width: requiredWidth, height: 160)\n                                .chartOverlay { proxy in\n                                    GeometryReader { geo in\n                                        Rectangle().fill(.clear).contentShape(Rectangle())\n                                            .onContinuousHover { phase in\n                                                switch phase {\n                                                case .active(let location):\n                                                    hoverExitTask?.cancel()\n                                                    hoverExitTask = nil\n                                                    if !isHoveringChartArea {\n                                                        withAnimation(.easeInOut(duration: 0.2)) {\n                                                            isHoveringChartArea = true\n                                                        }\n                                                    }\n                                                    let chartFrame = geo.frame(\n                                                        in: .named(chartCoordinateSpaceName))\n                                                    hoverLocation = CGPoint(\n                                                        x: chartFrame.minX + location.x,\n                                                        y: chartFrame.minY + location.y\n                                                    )\n                                                    // Convert location to X value (Date)\n                                                    if let date = proxy.value(\n                                                        atX: location.x, as: Date.self)\n                                                    {\n                                                        // Snap to closest bin\n                                                        hoverDate = snapDate(\n                                                            date, dates: uniqueDates)\n                                                    }\n                                                case .ended:\n                                                    hoverDate = nil\n                                                    hoverExitTask?.cancel()\n                                                    hoverExitTask = Task {\n                                                        try? await Task.sleep(\n                                                            nanoseconds: 250_000_000)\n                                                        await MainActor.run {\n                                                            withAnimation(.easeInOut(duration: 0.2))\n                                                            {\n                                                                isHoveringChartArea = false\n                                                            }\n                                                        }\n                                                    }\n                                                }\n                                            }\n                                            .onTapGesture(count: 1, coordinateSpace: .local) {\n                                                location in\n                                                if let date = proxy.value(\n                                                    atX: location.x, as: Date.self),\n                                                    let snapped = snapDate(date, dates: uniqueDates)\n                                                {\n                                                    onSelectDate?(snapped)\n                                                }\n                                            }\n                                    }\n                                }\n                        }\n                        .frame(minWidth: chartAreaWidth, alignment: .trailing)\n                    }\n                }\n\n                if let hoverDate, let points = pointsByDate[hoverDate] {\n                    tooltip(for: hoverDate, points: points, in: geoSize(from: chartAreaWidth))\n                        .zIndex(10)\n                }\n            }\n        }\n        .frame(height: 160)\n        .coordinateSpace(name: chartCoordinateSpaceName)\n    }\n\n    @ViewBuilder\n    private var emptyStateView: some View {\n        ZStack {\n            if #available(macOS 14.0, *) {\n                ContentUnavailableView {\n                    Label(\"No Activity\", systemImage: \"chart.bar\")\n                } description: {\n                    Text(\"No sessions found in this time range.\")\n                }\n            } else {\n                UnavailableStateView(\n                    \"No Activity\",\n                    systemImage: \"chart.bar\",\n                    description: \"No sessions found in this time range.\",\n                    titleFont: .callout\n                )\n            }\n        }\n        .frame(maxWidth: .infinity, minHeight: 160, maxHeight: 160, alignment: .center)\n    }\n\n    @ViewBuilder\n    private func scrollContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {\n        if #available(macOS 14.0, *) {\n            ScrollView(.horizontal, showsIndicators: true) {\n                content()\n            }\n            .defaultScrollAnchor(.trailing)\n        } else {\n            ScrollView(.horizontal, showsIndicators: true) {\n                content()\n            }\n        }\n    }\n\n    private func geoSize(from width: CGFloat) -> CGSize {\n        CGSize(width: width, height: 160)\n    }\n\n    private func chartContent(yScale: ClosedRange<Double>, xDomain: ClosedRange<Date>) -> some View\n    {\n        let labelWidth = CGFloat(barWidth)\n        return Chart {\n            // Baseline axis line\n            RuleMark(y: .value(\"Baseline\", 0))\n                .foregroundStyle(Color.secondary.opacity(0.5))\n                .lineStyle(StrokeStyle(lineWidth: 1))\n\n            ForEach(visiblePoints) { point in\n                BarMark(\n                    // Use the actual bucket start to align bar centers with axis ticks.\n                    x: .value(\"Date\", point.date),\n                    y: .value(\"Value\", value(for: point)),\n                    width: .fixed(barWidth * 0.8)  // Use a ratio of the stepWidth for the bar itself\n                )\n                .foregroundStyle(by: .value(\"Source\", point.source.rawValue.capitalized))\n            }\n        }\n        .chartLegend(.hidden)\n        .chartXScale(domain: xDomain)  // FIX: Lock X-axis domain\n        .chartXAxis {\n            AxisMarks(values: .stride(by: data.unit == .day ? .day : .hour)) { value in\n                if let date = value.as(Date.self) {\n                    // Custom Month Separator logic\n                    if data.unit == .day, isFirstDayOfMonth(date) {\n                        AxisTick(length: 5, stroke: StrokeStyle(lineWidth: 1.5))\n                            .foregroundStyle(.primary)\n                        AxisValueLabel {\n                            Text(date.formatted(.dateTime.month(.abbreviated)))\n                                .font(.system(size: 10, weight: .bold))\n                                .frame(width: labelWidth, alignment: .center)\n                                .offset(x: -labelWidth / 2)\n                                .zIndex(1)\n                        }\n                    } else {\n                        AxisGridLine()\n                            .foregroundStyle(.clear)  // Hide regular grid lines\n\n                        AxisTick(length: 3, stroke: StrokeStyle(lineWidth: 1))\n\n                        AxisValueLabel {\n                            Text(\n                                date.formatted(\n                                    data.unit == .day ? .dateTime.day() : .dateTime.hour()\n                                )\n                            )\n                            .font(.caption2)\n                            .foregroundStyle(.secondary)\n                            .frame(width: labelWidth, alignment: .center)\n                            .offset(x: -labelWidth / 2)\n                            .zIndex(1)\n                        }\n                    }\n                }\n            }\n        }\n        .chartYAxis(.hidden)  // Hide internal Y axis\n        .chartYScale(domain: yScale)\n        .chartForegroundStyleScale([\n            \"Codex\": color(for: .codex),\n            \"Claude\": color(for: .claude),\n            \"Gemini\": color(for: .gemini),\n        ])\n    }\n\n    // MARK: - Tooltip\n    @ViewBuilder\n    private func tooltip(for date: Date, points: [ActivityChartDataPoint], in containerSize: CGSize)\n        -> some View\n    {\n        // Filter points based on hidden sources\n        let visible = points.filter { !hiddenSources.contains($0.source) }\n        if !visible.isEmpty {\n            let total = visible.reduce(0) { $0 + value(for: $1) }\n            let dateString =\n                data.unit == .day\n                ? date.formatted(date: .abbreviated, time: .omitted)\n                : date.formatted(date: .omitted, time: .shortened)\n\n            let tooltipWidth: CGFloat = 140\n            // Estimate height based on items + padding\n            let tooltipHeight: CGFloat = CGFloat(40 + (visible.count * 15) + 20)\n\n            // Always keep the tooltip above the hover point.\n            let finalY = hoverLocation.y - (tooltipHeight / 2) - 16\n\n            // Determine X Position (Clamp to edges)\n            let halfWidth = tooltipWidth / 2\n            let rawX = hoverLocation.x\n            let finalX = max(halfWidth, min(rawX, containerSize.width - halfWidth))\n\n            VStack(alignment: .leading, spacing: 6) {\n                Text(dateString)\n                    .font(.caption).bold()\n                    .padding(.bottom, 2)\n\n                ForEach(visible.sorted { value(for: $0) > value(for: $1) }) { point in\n                    HStack {\n                        Circle().fill(color(for: point.source)).frame(width: 6, height: 6)\n                        Text(point.source.rawValue.capitalized).font(.caption2)\n                        Spacer()\n                        Text(formatValue(value(for: point)))\n                            .font(.caption2.monospacedDigit())\n                    }\n                }\n\n                Divider()\n\n                HStack {\n                    Text(\"Total\").font(.caption2).bold()\n                    Spacer()\n                    Text(formatValue(total))\n                        .font(.caption2.monospacedDigit()).bold()\n                }\n            }\n            .padding(8)\n            .background(.regularMaterial)\n            .cornerRadius(8)\n            .shadow(radius: 4)\n            .frame(width: tooltipWidth)\n            .position(x: finalX, y: finalY)\n            .allowsHitTesting(false)\n        }\n    }\n\n    // MARK: - Helpers\n    private var legendSources: [SessionSource.Kind] {\n        allSources.filter { enabledSources.contains($0) }\n    }\n\n    private var enabledSourcesHash: Int {\n        var hasher = Hasher()\n        enabledSources.sorted { $0.rawValue < $1.rawValue }.forEach { hasher.combine($0.rawValue) }\n        return hasher.finalize()\n    }\n\n    private var visiblePoints: [ActivityChartDataPoint] {\n        data.points.filter { !hiddenSources.contains($0.source) }\n    }\n\n    private var pointsByDate: [Date: [ActivityChartDataPoint]] {\n        Dictionary(grouping: data.points, by: { $0.date })\n    }\n\n    private func maxYValue(for dates: [Date]) -> Double {\n        var maxVal: Double = 0\n        let grouped = pointsByDate\n        for date in dates {\n            let points = grouped[date] ?? []\n            let filtered = points.filter { !hiddenSources.contains($0.source) }\n            let sum = filtered.reduce(0) { $0 + value(for: $1) }\n            if sum > maxVal { maxVal = sum }\n        }\n        // Add some headroom\n        return maxVal == 0 ? 10 : maxVal * 1.1\n    }\n\n    private func value(for point: ActivityChartDataPoint) -> Double {\n        switch selectedMetric {\n        case .count:\n            return Double(point.sessionCount)\n        case .duration:\n            return point.duration / 3600  // Hours\n        case .tokens:\n            return Double(point.totalTokens)\n        }\n    }\n\n    private func formatValue(_ val: Double) -> String {\n        switch selectedMetric {\n        case .count:\n            return String(Int(val))\n        case .duration:\n            return String(format: \"%.1fh\", val)\n        case .tokens:\n            return \"\\(TokenFormatter.short(Int(val.rounded())))\"\n        }\n    }\n\n    private func color(for source: SessionSource.Kind) -> Color {\n        switch source {\n        case .codex: return .purple\n        case .claude: return .orange\n        case .gemini: return .blue\n        }\n    }\n\n    private func isFirstDayOfMonth(_ date: Date) -> Bool {\n        let calendar = Calendar.current\n        let day = calendar.component(.day, from: date)\n        return day == 1\n    }\n\n    private func snapDate(_ target: Date, dates: [Date]) -> Date? {\n        // Find closest date in the dataset\n        // Since bars are discrete, finding the date with min distance is enough\n        guard !dates.isEmpty else { return nil }\n        // Optimization: since sorted, binary search or linear scan if small\n        return dates.min(by: {\n            abs($0.timeIntervalSince(target)) < abs($1.timeIntervalSince(target))\n        })\n    }\n\n    private var zoomControlsOpacity: Double {\n        guard isHoveringChartArea || isHoveringZoomControls else { return 0 }\n        return isHoveringZoomControls ? 1.0 : 0.85\n    }\n}\n"
  },
  {
    "path": "views/OverviewCard.swift",
    "content": "import SwiftUI\n\nstruct OverviewCard<Content: View>: View {\n  private let content: Content\n\n  init(@ViewBuilder content: () -> Content) {\n    self.content = content()\n  }\n\n  var body: some View {\n    content\n      .frame(maxWidth: .infinity, alignment: .leading)\n      .padding(16)\n      .background(\n        RoundedRectangle(cornerRadius: 14, style: .continuous)\n          .fill(Color(nsColor: .controlBackgroundColor))\n      )\n      .overlay(\n        RoundedRectangle(cornerRadius: 14, style: .continuous)\n          .stroke(Color.primary.opacity(0.07), lineWidth: 1)\n      )\n  }\n}\n"
  },
  {
    "path": "views/PathTreeView.swift",
    "content": "import SwiftUI\n\nstruct PathTreeView: View {\n    let root: PathTreeNode?\n    @Binding var selectedPath: String?\n\n    var body: some View {\n        if let root, let children = root.children, !children.isEmpty {\n            List(selection: $selectedPath) {\n                OutlineGroup(children, children: \\.children) { node in\n                    PathTreeRowView(node: node)\n                        .tag(node.id)\n                        .listRowInsets(EdgeInsets())  // Let internal padding manage spacing\n                }\n            }\n            .listStyle(.sidebar)\n            .environment(\\.defaultMinListRowHeight, 16)\n            .environment(\\.controlSize, .small)\n        } else {\n            if #available(macOS 14.0, *) {\n                ContentUnavailableView(\"No Directories\", systemImage: \"folder\")\n            } else {\n                UnavailableStateView(\n                    \"No Directories\",\n                    systemImage: \"folder\",\n                    titleFont: .callout\n                )\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n            }\n        }\n    }\n}\n\nprivate struct PathTreeRowView: View, Equatable {\n    let node: PathTreeNode\n\n    static func == (lhs: PathTreeRowView, rhs: PathTreeRowView) -> Bool {\n        lhs.node == rhs.node\n    }\n\n    var body: some View {\n        HStack(spacing: 8) {\n            Image(systemName: \"folder\")\n                .foregroundStyle(.secondary)\n                .font(.caption)\n\n            Text(node.name.isEmpty ? \"/\" : node.name)\n                .font(.caption)\n                .lineLimit(1)\n\n            Spacer(minLength: 4)\n\n            if node.count > 0 {\n                Text(\"\\(node.count)\")\n                    .font(.caption2.monospacedDigit())\n                    .foregroundStyle(.tertiary)\n            }\n        }\n        .frame(height: 16)\n        .padding(.vertical, 8)  // Match All Sessions row vertical padding\n        .padding(.trailing, 8)  // Ensure right padding consistent; no extra leading padding\n        .contentShape(Rectangle())\n    }\n}\n\n#Preview {\n    // Mock path tree data\n    let mockRoot = PathTreeNode(\n        id: \"/Users/developer\",\n        name: \"developer\",\n        count: 15,\n        children: [\n            PathTreeNode(\n                id: \"/Users/developer/projects\",\n                name: \"projects\",\n                count: 8,\n                children: [\n                    PathTreeNode(\n                        id: \"/Users/developer/projects/codmate\", name: \"codmate\", count: 3,\n                        children: nil),\n                    PathTreeNode(\n                        id: \"/Users/developer/projects/other\", name: \"other\", count: 5,\n                        children: nil),\n                ]\n            ),\n            PathTreeNode(\n                id: \"/Users/developer/documents\",\n                name: \"documents\",\n                count: 4,\n                children: [\n                    PathTreeNode(\n                        id: \"/Users/developer/documents/notes\", name: \"notes\", count: 2,\n                        children: nil),\n                    PathTreeNode(\n                        id: \"/Users/developer/documents/reports\", name: \"reports\", count: 2,\n                        children: nil),\n                ]\n            ),\n            PathTreeNode(id: \"/Users/developer/desktop\", name: \"desktop\", count: 3, children: nil),\n        ]\n    )\n\n    return PathTreeView(root: mockRoot, selectedPath: .constant(nil))\n        .frame(width: 250, height: 300)\n        .padding()\n}\n\n#Preview(\"Empty State\") {\n    PathTreeView(root: nil, selectedPath: .constant(nil))\n        .frame(width: 250, height: 200)\n        .padding()\n}\n"
  },
  {
    "path": "views/ProjectAgentsView.swift",
    "content": "import SwiftUI\n\nstruct ProjectAgentsView: View {\n    let projectDirectory: String\n    let preferences: SessionPreferencesStore\n    var refreshToken: Int = 0\n\n    @State private var markdownContent: String = \"\"\n    @State private var isLoading: Bool = true\n    @State private var errorMessage: String?\n    @State private var viewMode: ViewMode = .preview\n\n    enum ViewMode {\n        case code\n        case preview\n    }\n\n    // Markdown content should always wrap for readability\n    private var wrapText: Bool { preferences.gitWrapText }\n    private var showLineNumbers: Bool { preferences.gitShowLineNumbers }\n\n    var body: some View {\n        VStack(spacing: 0) {\n            // Header with mode switcher\n            HStack(spacing: 12) {\n                Image(systemName: \"book.pages\")\n                    .foregroundStyle(.secondary)\n                Text(\"Agents.md\")\n                    .font(.headline)\n\n                Spacer()\n\n                // Mode switcher - Preview first, Code second\n                Picker(\"\", selection: $viewMode) {\n                    Text(\"Preview\").tag(ViewMode.preview)\n                    Text(\"Code\").tag(ViewMode.code)\n                }\n                .pickerStyle(.segmented)\n                .frame(width: 140)\n                .controlSize(.small)\n                .labelsHidden()\n            }\n            .padding(.horizontal, 16)\n            .padding(.vertical, 12)\n            .padding(.trailing, 0) // Align segment with window edge like other workspace modes\n\n            Divider()\n\n            // Content area\n            if isLoading {\n                VStack(spacing: 12) {\n                    ProgressView()\n                    Text(\"Loading Agents.md...\")\n                        .font(.subheadline)\n                        .foregroundStyle(.secondary)\n                }\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n            } else if let error = errorMessage {\n                VStack(spacing: 12) {\n                    Image(systemName: \"exclamationmark.triangle\")\n                        .font(.system(size: 32))\n                        .foregroundStyle(.orange)\n                    Text(error)\n                        .font(.subheadline)\n                        .foregroundStyle(.secondary)\n                        .multilineTextAlignment(.center)\n                }\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n                .padding()\n            } else {\n                contentView\n            }\n        }\n        .onAppear {\n            loadAgentsFile()\n        }\n        .onChange(of: projectDirectory) { _ in\n            loadAgentsFile()\n        }\n        .onChange(of: refreshToken) { _ in\n            loadAgentsFile()\n        }\n    }\n\n    @ViewBuilder\n    private var contentView: some View {\n        switch viewMode {\n        case .code:\n            codeView\n        case .preview:\n            previewView\n        }\n    }\n\n    private var codeView: some View {\n        detailContainer {\n            AttributedTextView(\n                text: markdownContent.isEmpty ? \"No content\" : markdownContent,\n                isDiff: false,\n                wrap: true, // Markdown should always wrap for readability\n                showLineNumbers: showLineNumbers,\n                fontSize: 12\n            )\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n        .id(\"agents-code:\\(projectDirectory)|wrap:1|ln:\\(showLineNumbers ? 1 : 0)\")\n    }\n\n    private func detailContainer<Content: View>(@ViewBuilder content: () -> Content) -> some View {\n        content()\n            .frame(maxWidth: .infinity, maxHeight: .infinity)\n            .background(\n                RoundedRectangle(cornerRadius: 6, style: .continuous)\n                    .fill(Color(nsColor: .textBackgroundColor))\n                    .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.15)))\n            )\n            .padding(16)\n    }\n\n    private var previewView: some View {\n        detailContainer {\n            ScrollView {\n                // Use AttributedString for better Markdown rendering\n                if let attributed = try? AttributedString(markdown: markdownContent, options: AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace)) {\n                    Text(attributed)\n                        .textSelection(.enabled)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding()\n                } else {\n                    // Fallback to basic Text rendering\n                    Text(.init(markdownContent))\n                        .textSelection(.enabled)\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding()\n                }\n            }\n        }\n    }\n\n    private func loadAgentsFile() {\n        isLoading = true\n        errorMessage = nil\n\n        let agentsURL = URL(fileURLWithPath: projectDirectory).appendingPathComponent(\"Agents.md\")\n\n        // Check if file exists\n        guard FileManager.default.fileExists(atPath: agentsURL.path) else {\n            isLoading = false\n            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.\"\n            markdownContent = \"\"\n            return\n        }\n\n        // Read file content\n        do {\n            let content = try String(contentsOf: agentsURL, encoding: .utf8)\n            markdownContent = content\n            isLoading = false\n        } catch {\n            isLoading = false\n            errorMessage = \"Failed to read Agents.md:\\n\\(error.localizedDescription)\"\n            markdownContent = \"\"\n        }\n    }\n}\n"
  },
  {
    "path": "views/ProjectOverviewView.swift",
    "content": "import SwiftUI\n\nstruct ProjectOverviewView: View {\n  @ObservedObject var viewModel: ProjectOverviewViewModel\n  var project: Project\n  var preferences: SessionPreferencesStore\n  var onSelectSession: (SessionSummary) -> Void\n  var onResumeSession: (SessionSummary) -> Void  // Keeping this for consistency, though not used in ProjectOverviewViewModel directly\n  var onFocusToday: () -> Void  // Keeping this for consistency, though not used in ProjectOverviewViewModel directly\n  var onSelectDate: (Date) -> Void\n  var onEditProject: (Project) -> Void\n\n  private func columns(for width: CGFloat) -> [GridItem] {\n    let minWidth: CGFloat = 220\n    let spacing: CGFloat = 16\n    let availableWidth = width - 48  // 24 horizontal padding * 2\n    let count = max(1, Int((availableWidth + spacing) / (minWidth + spacing)))\n    // Cap at 4 columns to match the max number of items per section (4)\n    var targetCount = min(4, count)\n\n    // Optimization: Avoid 3 columns for 4-item grids to prevent \"3 on top, 1 on bottom\" layout.\n    // Since we mostly have sets of 4 items (Hero, Projects), a 2x2 grid looks better than 3+1.\n    if targetCount == 3 {\n      targetCount = 2\n    }\n\n    return Array(repeating: GridItem(.flexible(), spacing: spacing), count: targetCount)\n  }\n\n  var body: some View {\n    GeometryReader { geometry in\n      let cols = columns(for: geometry.size.width)\n      ScrollView {\n        VStack(alignment: .leading, spacing: 20) {\n          headerSection\n\n          if shouldShowChartPlaceholder {\n            OverviewChartPlaceholder()\n          } else {\n            OverviewActivityChart(\n              data: snapshot.activityChartData,\n              enabledSources: enabledSources,\n              onSelectDate: onSelectDate\n            )\n          }\n\n          heroSection(columns: cols)\n          efficiencySection(columns: cols)\n          recentSection\n        }\n        .padding(.horizontal, 24)\n        .padding(.vertical, 24)\n        .frame(maxWidth: .infinity, alignment: .center)\n      }\n    }\n  }\n\n  private var snapshot: ProjectOverviewSnapshot { viewModel.snapshot }\n  private var enabledSources: Set<SessionSource.Kind> {\n    Set([\n      preferences.isCLIEnabled(.codex) ? SessionSource.Kind.codex : nil,\n      preferences.isCLIEnabled(.claude) ? SessionSource.Kind.claude : nil,\n      preferences.isCLIEnabled(.gemini) ? SessionSource.Kind.gemini : nil,\n    ].compactMap { $0 })\n  }\n  private var shouldShowChartPlaceholder: Bool {\n    viewModel.isLoading && snapshot.activityChartData.points.isEmpty\n  }\n\n  private var headerSection: some View {\n    VStack(alignment: .leading, spacing: 6) {\n      HStack(alignment: .center, spacing: 8) {\n        Text(projectDisplayName)\n          .font(.largeTitle.weight(.semibold))\n          .lineLimit(1)\n          .truncationMode(.tail)\n        Spacer()\n        if canEditProject {\n          Button {\n            onEditProject(project)\n          } label: {\n            Image(systemName: \"gearshape\")\n              .imageScale(.medium)\n          }\n          .buttonStyle(.plain)\n          .accessibilityLabel(\"Edit Project\")\n          .help(\"Edit Project\")\n        }\n      }\n\n      Text(\"Updated \\(snapshot.lastUpdated.formatted(date: .abbreviated, time: .shortened))\")\n        .font(.caption)\n        .foregroundStyle(.secondary)\n\n\n    }\n  }\n\n  private func heroSection(columns: [GridItem]) -> some View {\n    VStack(alignment: .leading, spacing: 16) {\n      LazyVGrid(columns: columns, spacing: 16) {\n        heroMetric(\n          title: \"Sessions\",\n          value: snapshot.totalSessions.formatted(),\n          detail: \"In selected range\"\n        )\n        heroMetric(\n          title: \"Messages\",\n          value: (snapshot.userMessages + snapshot.assistantMessages).formatted(),\n          detail: \"\\(snapshot.userMessages) user · \\(snapshot.assistantMessages) assistant\"\n        )\n        heroMetric(\n          title: \"Active Time\",\n          value: Self.durationFormatter.string(from: snapshot.totalDuration) ?? \"—\",\n          detail: \"Tokens \\(TokenFormatter.short(snapshot.totalTokens))\"\n        )\n        heroMetric(\n          title: \"Tool Invocations\",\n          value: snapshot.totalToolInvocations.formatted(),\n          detail: \"Tools used\"\n        )\n      }\n      .frame(maxWidth: .infinity, alignment: .leading)\n    }\n  }\n\n  private var projectDisplayName: String {\n    let trimmed = project.name.trimmingCharacters(in: .whitespacesAndNewlines)\n    return trimmed.isEmpty ? \"Untitled Project\" : trimmed\n  }\n\n  private var projectOverviewLine: String {\n    if let overview = project.overview?.trimmingCharacters(in: .whitespacesAndNewlines),\n      !overview.isEmpty\n    {\n      return overview\n    }\n    return \"Project Overview\"\n  }\n\n  private var canEditProject: Bool {\n    project.id != SessionListViewModel.otherProjectId\n  }\n\n  private func heroMetric(title: String, value: String, detail: String) -> some View {\n    OverviewCard {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(title).font(.subheadline).foregroundStyle(.secondary)\n        Text(value).font(.title2.monospacedDigit()).fontWeight(.semibold)\n        Text(detail).font(.caption).foregroundStyle(.secondary)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func efficiencySection(columns: [GridItem]) -> some View {\n    if !snapshot.sourceStats.isEmpty {\n      VStack(alignment: .leading, spacing: 10) {\n        LazyVGrid(columns: columns, spacing: 16) {\n          ForEach(snapshot.sourceStats) { stat in\n            OverviewCard {\n              VStack(alignment: .leading, spacing: 8) {\n                HStack(alignment: .firstTextBaseline) {\n                  Text(stat.displayName).font(.headline)\n                  Spacer()\n                  Text(\"\\(stat.sessionCount) sessions\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                }\n\n                VStack(alignment: .leading, spacing: 4) {\n                  Label {\n                    Text(\"Total \\(TokenFormatter.short(stat.totalTokens)) tokens\")\n                  } icon: {\n                    Image(systemName: \"text.quote\")\n                  }\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n\n                  Label {\n                    Text(\"Avg \\(Self.durationFormatter.string(from: stat.avgDuration) ?? \"—\")\")\n                  } icon: {\n                    Image(systemName: \"clock\")\n                  }\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                }\n                .padding(.top, 4)\n              }\n            }\n          }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private var recentSection: some View {\n    RecentSessionsListView(\n      sessions: snapshot.recentSessions,\n      emptyMessage: \"No sessions in this project for the selected range.\",\n      onSelectSession: onSelectSession\n    )\n  }\n\n  private static let durationFormatter: DateComponentsFormatter = {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = [.hour, .minute]\n    formatter.unitsStyle = .abbreviated\n    formatter.zeroFormattingBehavior = .dropLeading\n    return formatter\n  }()\n}\n\nprivate struct OverviewChartPlaceholder: View {\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      HStack(alignment: .center, spacing: 8) {\n        RoundedRectangle(cornerRadius: 4)\n          .fill(Color.secondary.opacity(0.2))\n          .frame(width: 140, height: 18)\n        Spacer()\n        HStack(spacing: 10) {\n          ForEach(0..<3, id: \\.self) { _ in\n            HStack(spacing: 4) {\n              Circle()\n                .fill(Color.secondary.opacity(0.2))\n                .frame(width: 8, height: 8)\n              RoundedRectangle(cornerRadius: 3)\n                .fill(Color.secondary.opacity(0.2))\n                .frame(width: 36, height: 8)\n            }\n          }\n        }\n      }\n\n      RoundedRectangle(cornerRadius: 12)\n        .fill(Color.secondary.opacity(0.08))\n        .frame(height: 160)\n    }\n    .redacted(reason: .placeholder)\n  }\n}\n"
  },
  {
    "path": "views/ProjectSpecificOverviewContainerView.swift",
    "content": "import SwiftUI\n\nstruct ProjectSpecificOverviewContainerView: View {\n    @ObservedObject var sessionListViewModel: SessionListViewModel\n    var project: Project\n    var preferences: SessionPreferencesStore\n    var refreshToken: Int = 0\n    var onSelectSession: (SessionSummary) -> Void\n    var onResumeSession: (SessionSummary) -> Void\n    var onFocusToday: () -> Void\n    var onEditProject: (Project) -> Void\n\n    @StateObject private var projectOverviewViewModel: ProjectOverviewViewModel\n\n    init(\n      sessionListViewModel: SessionListViewModel,\n      project: Project,\n      preferences: SessionPreferencesStore,\n      refreshToken: Int = 0,\n      onSelectSession: @escaping (SessionSummary) -> Void,\n      onResumeSession: @escaping (SessionSummary) -> Void,\n      onFocusToday: @escaping () -> Void,\n      onEditProject: @escaping (Project) -> Void\n    ) {\n        self.sessionListViewModel = sessionListViewModel\n        self.project = project\n        self.preferences = preferences\n        self.refreshToken = refreshToken\n        self.onSelectSession = onSelectSession\n        self.onResumeSession = onResumeSession\n        self.onFocusToday = onFocusToday\n        self.onEditProject = onEditProject\n        _projectOverviewViewModel = StateObject(wrappedValue: ProjectOverviewViewModel(sessionListViewModel: sessionListViewModel, project: project))\n    }\n    \n    var body: some View {\n        ProjectOverviewView(\n            viewModel: projectOverviewViewModel,\n            project: project,\n            preferences: preferences,\n            onSelectSession: onSelectSession,\n            onResumeSession: onResumeSession,\n            onFocusToday: onFocusToday,\n            onSelectDate: { date in\n                sessionListViewModel.setSelectedDay(date)\n            },\n            onEditProject: onEditProject\n        )\n        // Update the project in the ViewModel if it changes from outside\n        .onChange(of: project) { newProject in\n            projectOverviewViewModel.updateProject(newProject)\n        }\n        .onChange(of: refreshToken) { _ in\n            projectOverviewViewModel.forceRefresh()\n        }\n    }\n}\n"
  },
  {
    "path": "views/ProjectsListView.swift",
    "content": "import AppKit\nimport SwiftUI\nimport UniformTypeIdentifiers\n\nstruct ProjectsListView: View {\n  @EnvironmentObject private var viewModel: SessionListViewModel\n  let onEditProject: (Project) -> Void\n  @State private var showNewProject = false\n  @State private var newParentProject: Project? = nil\n  @State private var pendingDelete: Project? = nil\n  @State private var showDeleteConfirm = false\n  @State private var draftTaskForNew: CodMateTask? = nil\n  @State private var showAutoAssignSheet = false\n\n  var body: some View {\n    let countsDisplay = viewModel.projectCountsDisplay()\n    let tree = buildProjectTree(viewModel.projects)\n\n    let selectionBinding: Binding<Set<String>> = Binding(\n      get: { viewModel.selectedProjectIDs },\n      set: { viewModel.setSelectedProjects($0) }\n    )\n\n    let expandedBinding: Binding<Set<String>> = Binding(\n      get: { viewModel.expandedProjectIDs },\n      set: { viewModel.expandedProjectIDs = $0 }\n    )\n\n    return makeListView(\n      tree: tree,\n      countsDisplay: countsDisplay,\n      selectionBinding: selectionBinding,\n      expandedBinding: expandedBinding\n    )\n  }\n\n  private func makeListView(\n    tree: [ProjectTreeNode],\n    countsDisplay: [String: (visible: Int, total: Int)],\n    selectionBinding: Binding<Set<String>>,\n    expandedBinding: Binding<Set<String>>\n  ) -> some View {\n    List(selection: selectionBinding) {\n      if tree.isEmpty {\n        if #available(macOS 14.0, *) {\n          ContentUnavailableView(\"No Projects\", systemImage: \"square.grid.2x2\")\n        } else {\n          UnavailableStateView(\n            \"No Projects\",\n            systemImage: \"square.grid.2x2\",\n            titleFont: .callout\n          )\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n      } else {\n        ForEach(tree) { node in\n          makeProjectTreeNodeView(\n            node: node,\n            countsDisplay: countsDisplay,\n            expandedBinding: expandedBinding\n          )\n        }\n        makeOtherProjectRow(countsDisplay: countsDisplay)\n      }\n    }\n    .listStyle(.sidebar)\n    .padding(.horizontal, -10)\n    .environment(\\.defaultMinListRowHeight, 16)\n    .environment(\\.controlSize, .small)\n    .contextMenu {\n      Button {\n        newParentProject = nil\n        showNewProject = true\n      } label: {\n        Label(\"New Project…\", systemImage: \"square.grid.2x2\")\n      }\n      if viewModel.selectedProjectIDs.contains(SessionListViewModel.otherProjectId) {\n        Button {\n          showAutoAssignSheet = true\n        } label: {\n          Label(\"Auto assign to ...\", systemImage: \"wand.and.stars\")\n        }\n      }\n    }\n    .dropDestination(for: String.self) { items, _ in\n      // Handle drop on list background (outside any project row)\n      // This removes the parent from the dragged project (moves to top level)\n      let all = items.flatMap { $0.split(separator: \"\\n\").map(String.init) }\n      let projectDrags = all.filter { $0.hasPrefix(\"project:\") }\n      if let firstProjectDrag = projectDrags.first {\n        let draggedProjectId = String(firstProjectDrag.dropFirst(\"project:\".count))\n\n        // Don't allow dragging Other project\n        guard draggedProjectId != SessionListViewModel.otherProjectId else { return false }\n\n        Task {\n          await viewModel.changeProjectParent(projectId: draggedProjectId, newParentId: nil)\n        }\n        return true\n      }\n      return false\n    }\n    .onAppear {\n      if viewModel.expandedProjectIDs.isEmpty {\n        viewModel.expandedProjectIDs = Set(tree.map(\\.id))\n      }\n    }\n    .onReceive(NotificationCenter.default.publisher(for: .codMateExpandProjectTree)) { note in\n      if let ids = note.userInfo?[\"ids\"] as? [String] {\n        var merged = viewModel.expandedProjectIDs\n        merged.formUnion(ids)\n        viewModel.expandedProjectIDs = merged\n      }\n    }\n    .sheet(isPresented: $showNewProject, onDismiss: { newParentProject = nil }) {\n      ProjectEditorSheet(\n        isPresented: $showNewProject,\n        mode: .new,\n        prefill: ProjectEditorSheet.Prefill(\n          name: newParentProject == nil ? nil : \"New Subproject\",\n          directory: newParentProject?.directory,\n          trustLevel: nil,\n          overview: nil,\n          profileId: nil,\n          parentId: newParentProject?.id\n        )\n      )\n      .environmentObject(viewModel)\n    }\n    .sheet(item: $draftTaskForNew) { task in\n      if let workspaceVM = viewModel.workspaceVM {\n        EditTaskSheet(\n          task: task,\n          mode: .new,\n          workspaceVM: workspaceVM,\n          onSave: { updatedTask in\n            Task {\n              await workspaceVM.updateTask(updatedTask)\n              draftTaskForNew = nil\n            }\n          },\n          onCancel: {\n            draftTaskForNew = nil\n          }\n        )\n      }\n    }\n    .sheet(isPresented: $showAutoAssignSheet) {\n      AutoAssignSheet(isPresented: $showAutoAssignSheet)\n        .environmentObject(viewModel)\n    }\n    .confirmationDialog(\n      \"Delete project?\",\n      isPresented: $showDeleteConfirm,\n      titleVisibility: .visible,\n      presenting: pendingDelete\n    ) { prj in\n      let hasChildren = viewModel.projects.contains { $0.parentId == prj.id }\n      if hasChildren {\n        Button(\"Delete Project and Subprojects\", role: .destructive) {\n          Task { await viewModel.deleteProjectCascade(id: prj.id) }\n          pendingDelete = nil\n        }\n        Button(\"Move Subprojects to Top Level\") {\n          Task { await viewModel.deleteProjectMoveChildrenUp(id: prj.id) }\n          pendingDelete = nil\n        }\n        Button(\"Cancel\", role: .cancel) { pendingDelete = nil }\n      } else {\n        Button(\"Delete\", role: .destructive) {\n          Task { await viewModel.deleteProject(id: prj.id) }\n          pendingDelete = nil\n        }\n        Button(\"Cancel\", role: .cancel) { pendingDelete = nil }\n      }\n    } message: { prj in\n      Text(\n        \"Sessions remain intact. This only removes the project record. This action cannot be undone.\"\n      )\n    }\n  }\n\n  private func handleSelection(for project: Project) {\n    #if os(macOS)\n      let commandDown = NSApp.currentEvent?.modifierFlags.contains(.command) ?? false\n    #else\n      let commandDown = false\n    #endif\n    if commandDown {\n      viewModel.toggleProjectSelection(project.id)\n    } else {\n      viewModel.setSelectedProject(project.id)\n    }\n  }\n\n  private func displayName(_ p: Project) -> String {\n    if !p.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return p.name }\n    if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      let base = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent\n      return base.isEmpty ? p.id : base\n    }\n    return p.id\n  }\n\n  private func makeProjectTreeNodeView(\n    node: ProjectTreeNode,\n    countsDisplay: [String: (visible: Int, total: Int)],\n    expandedBinding: Binding<Set<String>>\n  ) -> some View {\n    ProjectTreeNodeView(\n      node: node,\n      countsDisplay: countsDisplay,\n      displayName: displayName(_:),\n      viewModel: viewModel,\n      expanded: expandedBinding,\n      onTap: { handleSelection(for: $0) },\n      onDoubleTap: { project in\n        onEditProject(project)\n      },\n      onNewSession: { viewModel.newSession(project: $0) },\n      onNewSubproject: { parent in\n        newParentProject = parent\n        showNewProject = true\n      },\n      onNewTask: { project in\n        guard project.id != SessionListViewModel.otherProjectId else { return }\n        draftTaskForNew = CodMateTask(\n          title: \"\",\n          description: nil,\n          projectId: project.id\n        )\n      },\n      onEdit: { project in\n        onEditProject(project)\n      },\n      onDelete: { project in\n        pendingDelete = project\n        showDeleteConfirm = true\n      },\n      onReveal: { viewModel.revealProjectDirectory($0) },\n      onOpenInEditor: { project, editor in\n        viewModel.openProjectInEditor(project, using: editor)\n      },\n      onAssignSessions: { projectId, ids in\n        Task { await viewModel.assignSessions(to: projectId, ids: ids) }\n      },\n      onChangeParent: { projectId, newParentId in\n        Task {\n          await viewModel.changeProjectParent(projectId: projectId, newParentId: newParentId)\n        }\n      },\n      onNewProjectClick: {\n        newParentProject = nil\n        showNewProject = true\n      },\n      onAutoAssign: {\n        showAutoAssignSheet = true\n      }\n    )\n  }\n\n  private func makeOtherProjectRow(countsDisplay: [String: (visible: Int, total: Int)]) -> some View {\n    let otherId = SessionListViewModel.otherProjectId\n    let otherProject = Project(\n      id: otherId, name: \"Others\", directory: nil, trustLevel: nil, overview: nil,\n      instructions: nil, profileId: nil, profile: nil, parentId: nil,\n      sources: ProjectSessionSource.allSet)\n\n    return ProjectRow(\n      project: otherProject,\n      displayName: \"Others\",\n      visible: countsDisplay[otherId]?.visible ?? 0,\n      total: countsDisplay[otherId]?.total ?? 0,\n      onNewSession: {},\n      onEdit: {},\n      onDelete: {}\n    )\n    .listRowInsets(EdgeInsets())\n    .contentShape(Rectangle())\n    .onTapGesture { handleSelection(for: otherProject) }\n    .tag(otherId)\n  }\n}\n\nextension View {\n  @ViewBuilder\n  fileprivate func applyAlternatingRows() -> some View {\n    if #available(macOS 14.0, *) {\n      self.alternatingRowBackgrounds(.enabled)\n    } else {\n      self\n    }\n  }\n}\n\nprivate func stripeBackground(for id: String) -> Color {\n  // Make one stripe transparent and the other a subtle separator tint\n  let isOdd = (id.hashValue & 1) != 0\n  if isOdd {\n    return Color(nsColor: .separatorColor).opacity(0.08)\n  } else {\n    return .clear\n  }\n}\n\nprivate struct ProjectRow: View {\n  let project: Project\n  let displayName: String\n  let visible: Int\n  let total: Int\n  var onNewSession: () -> Void\n  var onEdit: () -> Void\n  var onDelete: () -> Void\n\n  var body: some View {\n    HStack(spacing: 8) {\n      let iconName =\n        (project.id == SessionListViewModel.otherProjectId) ? \"ellipsis\" : \"square.grid.2x2\"\n      Image(systemName: iconName)\n        .foregroundStyle(.secondary)\n        .font(.caption)\n      Text(displayName)\n        .font(.caption)\n        .lineLimit(1)\n      Spacer(minLength: 4)\n      let showCount = (visible > 0) || (total > 0)\n      if showCount {\n        Text(\"\\(visible)/\\(total)\")\n          .font(.caption2.monospacedDigit())\n          .foregroundStyle(.tertiary)\n      }\n    }\n    .frame(height: 16)\n    .padding(.vertical, 8)\n    .padding(.trailing, 8)\n    // Thin top hairline to separate items, matching sessions list aesthetic\n    .overlay(alignment: .top) {\n      Rectangle()\n        .fill(Color(nsColor: .separatorColor).opacity(0.18))\n        .frame(height: 1)\n    }\n  }\n}\n\nprivate struct ProjectTreeNodeView: View {\n  let node: ProjectTreeNode\n  let countsDisplay: [String: (visible: Int, total: Int)]\n  let displayName: (Project) -> String\n  let viewModel: SessionListViewModel\n  @Binding var expanded: Set<String>\n  let onTap: (Project) -> Void\n  let onDoubleTap: (Project) -> Void\n  let onNewSession: (Project) -> Void\n  let onNewSubproject: (Project) -> Void\n  let onNewTask: (Project) -> Void\n  let onEdit: (Project) -> Void\n  let onDelete: (Project) -> Void\n  let onReveal: (Project) -> Void\n  let onOpenInEditor: (Project, EditorApp) -> Void\n  let onAssignSessions: (String, [String]) -> Void\n  let onChangeParent: (String, String?) -> Void\n  let onNewProjectClick: () -> Void\n  let onAutoAssign: () -> Void\n\n  var body: some View {\n    Group {\n      if let children = node.children, !children.isEmpty {\n        DisclosureGroup(isExpanded: binding(for: node.project.id)) {\n          ForEach(children) { child in\n            ProjectTreeNodeView(\n              node: child,\n              countsDisplay: countsDisplay,\n              displayName: displayName,\n              viewModel: viewModel,\n              expanded: $expanded,\n              onTap: onTap,\n              onDoubleTap: onDoubleTap,\n              onNewSession: onNewSession,\n              onNewSubproject: onNewSubproject,\n              onNewTask: onNewTask,\n              onEdit: onEdit,\n              onDelete: onDelete,\n              onReveal: onReveal,\n              onOpenInEditor: onOpenInEditor,\n              onAssignSessions: onAssignSessions,\n              onChangeParent: onChangeParent,\n              onNewProjectClick: onNewProjectClick,\n              onAutoAssign: onAutoAssign\n            )\n          }\n        } label: {\n          row(for: node.project)\n        }\n        .tag(node.project.id)\n      } else {\n        row(for: node.project)\n          .tag(node.project.id)\n      }\n    }\n  }\n\n  private func binding(for id: String) -> Binding<Bool> {\n    Binding(\n      get: { expanded.contains(id) },\n      set: { value in\n        if value {\n          expanded.insert(id)\n        } else {\n          expanded.remove(id)\n        }\n      }\n    )\n  }\n\n  private func row(for project: Project) -> some View {\n    let pair = countsDisplay[project.id] ?? (visible: 0, total: 0)\n    let isOtherProject = project.id == SessionListViewModel.otherProjectId\n\n    return ProjectRow(\n      project: project,\n      displayName: displayName(project),\n      visible: pair.visible,\n      total: pair.total,\n      onNewSession: { onNewSession(project) },\n      onEdit: { onEdit(project) },\n      onDelete: { onDelete(project) }\n    )\n    .listRowInsets(EdgeInsets())\n    .contentShape(Rectangle())\n    .onDrag {\n      // Only allow dragging real projects (not Other)\n      guard !isOtherProject else {\n        return NSItemProvider()\n      }\n      return NSItemProvider(object: \"project:\\(project.id)\" as NSString)\n    }\n    .onTapGesture { onTap(project) }\n    .onTapGesture(count: 2) { onDoubleTap(project) }\n    .contextMenu { contextMenu(for: project) }\n    // Drop destination for sessions and projects\n    .dropDestination(for: String.self) { items, _ in\n      // Don't allow dropping onto Other project\n      guard !isOtherProject else { return false }\n\n      let all = items.flatMap { $0.split(separator: \"\\n\").map(String.init) }\n      // Check if any item is a project drag (starts with \"project:\")\n      let projectDrags = all.filter { $0.hasPrefix(\"project:\") }\n      if let firstProjectDrag = projectDrags.first {\n        let draggedProjectId = String(firstProjectDrag.dropFirst(\"project:\".count))\n\n        // Prevent dropping onto self\n        guard draggedProjectId != project.id else { return false }\n\n        // Set this project as the parent of the dragged project\n        onChangeParent(draggedProjectId, project.id)\n        return true\n      }\n      // Otherwise, treat as session assignment\n      let sessionIds = all.filter { !$0.hasPrefix(\"project:\") }\n      if !sessionIds.isEmpty {\n        onAssignSessions(project.id, sessionIds)\n        return true\n      }\n      return false\n    }\n  }\n\n  @ViewBuilder\n  private func contextMenu(for project: Project) -> some View {\n    let anchor = projectAnchor(for: project)\n    let items = buildNewMenuItems(anchor: anchor, project: project)\n    Menu {\n      SplitMenuItemsView(items: items)\n    } label: {\n      Label(\"New Session…\", systemImage: \"plus\")\n    }\n    Button {\n      onNewProjectClick()\n    } label: {\n      Label(\"New Project…\", systemImage: \"square.grid.2x2\")\n    }\n    Button {\n      onNewTask(project)\n    } label: {\n      Label(\"New Task…\", systemImage: \"checklist\")\n    }\n    Button {\n      onNewSubproject(project)\n    } label: {\n      Label(\"New Subproject…\", systemImage: \"plus.square.on.square\")\n    }\n    Divider()\n    let editors = EditorApp.installedEditors\n    openInEditorMenu(editors: editors) { editor in\n      onOpenInEditor(project, editor)\n    }\n    .disabled(project.directory == nil || project.directory?.isEmpty == true)\n\n    Button {\n      onReveal(project)\n    } label: {\n      Label(\"Reveal in Finder\", systemImage: \"finder\")\n    }\n    .disabled(project.directory == nil || project.directory?.isEmpty == true)\n\n    Button {\n      onEdit(project)\n    } label: {\n      Label(\"Edit Project / Property\", systemImage: \"pencil\")\n    }\n    Divider()\n    Button(role: .destructive) {\n      onDelete(project)\n    } label: {\n      Label(\"Delete Project\", systemImage: \"trash\")\n    }\n\n    if project.id == SessionListViewModel.otherProjectId {\n      Divider()\n      Button {\n        onAutoAssign()\n      } label: {\n        Label(\"Auto assign to ...\", systemImage: \"wand.and.stars\")\n      }\n    }\n  }\n\n  // MARK: - Shared New Session menu (matches session/task context menus)\n  private func projectAnchor(for project: Project) -> SessionSummary? {\n    let vm = self.viewModel\n    // Prefer a visible session for this project; fallback to any session in the project.\n    if let visible = vm.sections.flatMap({ $0.sessions }).first(\n      where: { vm.projectIdForSession($0.id) == project.id })\n    {\n      return visible\n    }\n    return vm.allSessions.first { vm.projectIdForSession($0.id) == project.id }\n  }\n\n  private func buildNewMenuItems(anchor: SessionSummary?, project: Project? = nil) -> [SplitMenuItem] {\n    let vm = self.viewModel\n    let allowed: Set<ProjectSessionSource>\n    if let anchor {\n      allowed = Set(vm.allowedSources(for: anchor))\n    } else if let project {\n      let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources\n      allowed = Set(sources.filter { vm.preferences.isCLIEnabled($0.baseKind) })\n    } else {\n      allowed = Set(ProjectSessionSource.allCases.filter { vm.preferences.isCLIEnabled($0.baseKind) })\n    }\n    let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini]\n    let enabledRemoteHosts = vm.preferences.enabledRemoteHosts.sorted()\n\n    func sourceKey(_ source: SessionSource) -> String {\n      switch source {\n      case .codexLocal: return \"codex-local\"\n      case .codexRemote(let host): return \"codex-\\(host)\"\n      case .claudeLocal: return \"claude-local\"\n      case .claudeRemote(let host): return \"claude-\\(host)\"\n      case .geminiLocal: return \"gemini-local\"\n      case .geminiRemote(let host): return \"gemini-\\(host)\"\n      }\n    }\n\n    func launchItems(for source: SessionSource) -> [SplitMenuItem] {\n      let key = sourceKey(source)\n      var items = externalTerminalMenuItems(idPrefix: key) { profile in\n        if let anchor {\n          launchNewSession(for: anchor, using: source, profile: profile)\n        } else if let project {\n          vm.launchNewSessionFromProject(project: project, using: source, profile: profile)\n        }\n      }\n      if vm.preferences.isEmbeddedTerminalEnabled {\n        let embedded = embeddedTerminalProfile()\n        items.insert(\n          SplitMenuItem(\n            id: \"\\(key)-\\(embedded.id)\",\n            kind: .action(\n              title: embedded.displayTitle,\n              systemImage: \"macwindow\",\n              run: {\n                if let anchor {\n                  launchNewSession(for: anchor, using: source, profile: embedded)\n                } else if let project {\n                  vm.launchNewSessionFromProject(project: project, using: source, profile: embedded)\n                }\n              }\n            )\n          ),\n          at: 0\n        )\n      }\n      return items\n    }\n\n    func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource {\n      switch base {\n      case .codex: return .codexRemote(host: host)\n      case .claude: return .claudeRemote(host: host)\n      case .gemini: return .geminiRemote(host: host)\n      }\n    }\n\n    func providerAssetIcon(_ source: ProjectSessionSource) -> String {\n      switch source {\n      case .codex: return \"ChatGPTIcon\"\n      case .claude: return \"ClaudeIcon\"\n      case .gemini: return \"GeminiIcon\"\n      }\n    }\n\n    func assetIconForSessionSource(_ source: SessionSource) -> String {\n      switch source.baseKind {\n      case .codex: return \"ChatGPTIcon\"\n      case .claude: return \"ClaudeIcon\"\n      case .gemini: return \"GeminiIcon\"\n      }\n    }\n\n    var menuItems: [SplitMenuItem] = []\n    for base in requestedOrder where allowed.contains(base) {\n      var providerItems = launchItems(for: base.sessionSource)\n      if !enabledRemoteHosts.isEmpty {\n        providerItems.append(SplitMenuItem(id: \"sep-\\(base.rawValue)\", kind: .separator))\n        for host in enabledRemoteHosts {\n          let remote = remoteSource(for: base, host: host)\n          providerItems.append(\n            SplitMenuItem(\n              id: \"remote-\\(base.rawValue)-\\(host)\",\n              kind: .submenu(title: host, items: launchItems(for: remote))\n            ))\n        }\n      }\n      menuItems.append(\n        SplitMenuItem(\n          id: \"provider-\\(base.rawValue)\",\n          kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems)\n        ))\n    }\n\n    if menuItems.isEmpty, let anchor {\n      let fallbackSource = anchor.source\n      menuItems.append(\n        SplitMenuItem(\n          id: \"fallback-\\(sourceKey(fallbackSource))\",\n          kind: .submenu(\n            title: fallbackSource.branding.displayName,\n            assetImage: assetIconForSessionSource(fallbackSource),\n            items: launchItems(for: fallbackSource)\n          )))\n    }\n    return menuItems\n  }\n\n  private func launchNewSession(\n    for session: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    let vm = self.viewModel\n    vm.launchNewSessionWithProfile(\n      session: session,\n      using: source,\n      profile: profile,\n      workingDirectory: session.cwd\n    )\n  }\n\n}\n\nstruct ProjectEditorSheet: View {\n  enum Mode {\n    case new\n    case edit(existing: Project)\n  }\n  @EnvironmentObject private var viewModel: SessionListViewModel\n  @Binding var isPresented: Bool\n  let mode: Mode\n  struct Prefill: Sendable, Identifiable {\n    let id = UUID()\n    var name: String?\n    var directory: String?\n    var trustLevel: String?\n    var overview: String?\n    var profileId: String?\n    var parentId: String?\n  }\n  var prefill: Prefill? = nil\n  var autoAssignSessionIDs: [String]? = nil\n  @State private var showCloseConfirm = false\n  @State private var original: Snapshot? = nil\n\n  @State private var name: String = \"\"\n  @State private var directory: String = \"\"\n  @State private var trustLevel: String = \"\"\n  @State private var overview: String = \"\"\n  @State private var profileId: String = \"\"\n  @State private var profileSandbox: SandboxMode? = nil\n  @State private var profileApproval: ApprovalPolicy? = nil\n  @State private var profileFullAuto: Bool? = nil\n  @State private var profileDangerBypass: Bool? = nil\n  @State private var profilePathPrependText: String = \"\"\n  @State private var profileEnvText: String = \"\"\n  @State private var parentProjectId: String? = nil\n  @State private var sources: Set<ProjectSessionSource> = ProjectSessionSource.allSet\n  @State private var mcpSearchText: String = \"\"\n  @State private var skillsSearchText: String = \"\"\n  @StateObject private var extensionsVM = ProjectExtensionsViewModel()\n\n  private struct Snapshot: Equatable {\n    var name: String\n    var directory: String\n    var trustLevel: String\n    var overview: String\n    var profileSandbox: SandboxMode?\n    var profileApproval: ApprovalPolicy?\n    var profileFullAuto: Bool?\n    var profileDangerBypass: Bool?\n    var profilePathPrependText: String\n    var profileEnvText: String\n    var parentProjectId: String?\n    var sources: Set<ProjectSessionSource>\n  }\n\n  // Unified layout constants for aligned labels/fields across tabs\n  private let labelColWidth: CGFloat = 120\n  private let fieldColWidth: CGFloat = 460\n\n  private var generalTabView: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n        GridRow {\n          Text(\"Name\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          TextField(\"Display name\", text: $name)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Directory\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          HStack(spacing: 8) {\n            TextField(\"/absolute/path\", text: $directory)\n              .textFieldStyle(.roundedBorder)\n              .frame(maxWidth: .infinity)\n            Button(\"Choose…\") { chooseDirectory() }\n          }\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Parent Project\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          Picker(\n            \"\",\n            selection: Binding(\n              get: { parentProjectId ?? \"(none)\" },\n              set: { parentProjectId = $0 == \"(none)\" ? nil : $0 })\n          ) {\n            Text(\"(none)\").tag(\"(none)\")\n            ForEach(viewModel.projects.filter { $0.id != (modeSelfId()) }, id: \\.id) { p in\n              Text(p.name.isEmpty ? p.id : p.name).tag(p.id)\n            }\n          }\n          .labelsHidden()\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Trust Level\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          Picker(\"\", selection: trustLevelBinding) {\n            Text(\"trusted\").tag(\"trusted\")\n            Text(\"untrusted\").tag(\"untrusted\")\n          }\n          .labelsHidden()\n          .pickerStyle(.segmented)\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Sources\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          HStack(spacing: 16) {\n            ForEach(ProjectSessionSource.allCases) { source in\n              Toggle(source.displayName, isOn: binding(for: source))\n                .toggleStyle(.checkbox)\n                .disabled(!viewModel.preferences.isCLIEnabled(source.baseKind))\n            }\n          }\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow(alignment: .top) {\n          Text(\"Overview\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          VStack(alignment: .leading, spacing: 6) {\n            TextEditor(text: $overview)\n              .font(.body)\n              .frame(minHeight: 88, maxHeight: .infinity)\n              .overlay(\n                RoundedRectangle(cornerRadius: 6)\n                  .stroke(Color.secondary.opacity(0.2))\n              )\n          }\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n      }\n      Spacer(minLength: 0)\n    }\n    .padding(16)\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n  }\n\n  private var profileTabView: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n        GridRow {\n          Text(\"Sandbox\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          Picker(\n            \"\",\n            selection: Binding(\n              get: { profileSandbox ?? .workspaceWrite }, set: { profileSandbox = $0 })\n          ) {\n            ForEach(SandboxMode.allCases) { s in Text(s.title).tag(s) }\n          }\n          .labelsHidden()\n          .pickerStyle(.segmented)\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Approval\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          Picker(\n            \"\",\n            selection: Binding(\n              get: { profileApproval ?? .onRequest }, set: { profileApproval = $0 })\n          ) {\n            ForEach(ApprovalPolicy.allCases) { a in Text(a.title).tag(a) }\n          }\n          .labelsHidden()\n          .pickerStyle(.segmented)\n          .frame(maxWidth: .infinity, alignment: .leading)\n        }\n        GridRow {\n          Text(\"Presets\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          HStack(spacing: 12) {\n            Toggle(\n              \"Full Auto\",\n              isOn: Binding(get: { profileFullAuto ?? false }, set: { profileFullAuto = $0 }))\n            Toggle(\n              \"Danger Bypass\",\n              isOn: Binding(\n                get: { profileDangerBypass ?? false }, set: { profileDangerBypass = $0 }))\n          }\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n      }\n\n      Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {\n        GridRow {\n          Text(\"PATH Prepend\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          TextField(\"/opt/custom/bin:/project/bin\", text: $profilePathPrependText)\n            .textFieldStyle(.roundedBorder)\n            .frame(width: fieldColWidth, alignment: .leading)\n        }\n        GridRow(alignment: .top) {\n          Text(\"Environment\")\n            .font(.subheadline)\n            .frame(width: labelColWidth, alignment: .trailing)\n          VStack(alignment: .leading, spacing: 6) {\n            TextEditor(text: $profileEnvText)\n              .font(.system(.body, design: .monospaced))\n              .frame(minHeight: 100, maxHeight: 180)\n              .overlay(RoundedRectangle(cornerRadius: 6).stroke(Color.secondary.opacity(0.2)))\n            Text(\"One per line: KEY=VALUE. Will export as export KEY='VALUE'.\").font(.caption)\n              .foregroundStyle(.secondary)\n          }\n          .frame(width: fieldColWidth, alignment: .leading)\n        }\n      }\n    }\n    .padding(16)\n  }\n\n  private var mcpTabView: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      ZStack {\n        HStack {\n          Spacer(minLength: 0)\n          ToolbarSearchField(\n            placeholder: \"Search MCP servers\",\n            text: $mcpSearchText,\n            onFocusChange: { _ in },\n            onSubmit: {}\n          )\n          .frame(maxWidth: .infinity)\n          Spacer(minLength: 8)\n          Button {\n            extensionsVM.beginProjectMCPImport()\n          } label: {\n            Label(\"Import\", systemImage: \"tray.and.arrow.down\")\n              .labelStyle(.titleAndIcon)\n          }\n          .buttonStyle(.borderless)\n          .help(\"Import MCP servers from this project\")\n          Button {\n            openExtensionsSettings(tab: .mcp)\n          } label: {\n            Image(systemName: \"gearshape\")\n              .font(.body)\n          }\n          .buttonStyle(.borderless)\n          .help(\"Open Extensions settings\")\n        }\n        .frame(maxWidth: .infinity)\n      }\n\n      if filteredMCPSelections.isEmpty {\n        emptyState(\n          icon: \"server.rack\",\n          title: extensionsVM.mcpSelections.isEmpty ? \"No MCP Servers\" : \"No Results\",\n          message: extensionsVM.mcpSelections.isEmpty\n            ? \"Add servers in Settings › Extensions to enable project selection.\"\n            : \"Try a different search.\"\n        )\n      } else {\n        ScrollView {\n          LazyVStack(spacing: 8) {\n            ForEach(filteredMCPSelections) { entry in\n              HStack(alignment: .center, spacing: 8) {\n                Toggle(\n                  \"\",\n                  isOn: Binding(\n                    get: { entry.isSelected },\n                    set: { value in\n                      extensionsVM.updateMCPSelection(id: entry.id, isSelected: value)\n                    }\n                  )\n                )\n                .labelsHidden()\n                .controlSize(.small)\n\n                VStack(alignment: .leading, spacing: 4) {\n                  Text(entry.server.name)\n                    .font(.subheadline.weight(.medium))\n                  if let desc = entry.server.meta?.description, !desc.isEmpty {\n                    Text(desc)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                  }\n                }\n                Spacer(minLength: 8)\n                HStack(spacing: 6) {\n                  MCPServerTargetToggle(\n                    provider: .codex,\n                    isOn: Binding(\n                      get: { entry.targets.codex },\n                      set: { value in\n                        extensionsVM.updateMCPTarget(id: entry.id, target: .codex, value: value)\n                      }\n                    ),\n                    disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.codex)\n                  )\n                  MCPServerTargetToggle(\n                    provider: .claude,\n                    isOn: Binding(\n                      get: { entry.targets.claude },\n                      set: { value in\n                        extensionsVM.updateMCPTarget(id: entry.id, target: .claude, value: value)\n                      }\n                    ),\n                    disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.claude)\n                  )\n                  MCPServerTargetToggle(\n                    provider: .gemini,\n                    isOn: Binding(\n                      get: { entry.targets.gemini },\n                      set: { value in\n                        extensionsVM.updateMCPTarget(id: entry.id, target: .gemini, value: value)\n                      }\n                    ),\n                    disabled: !entry.isSelected || !viewModel.preferences.isCLIEnabled(.gemini)\n                  )\n                }\n              }\n              .padding(.vertical, 6)\n            }\n          }\n          .padding(.horizontal, 8)\n        }\n      }\n    }\n    .padding(16)\n  }\n\n  private var skillsTabView: some View {\n    VStack(alignment: .leading, spacing: 12) {\n\n      ZStack {\n        HStack {\n          Spacer(minLength: 0)\n          ToolbarSearchField(\n            placeholder: \"Search skills\",\n            text: $skillsSearchText,\n            onFocusChange: { _ in },\n            onSubmit: {}\n          )\n          .frame(maxWidth: .infinity)\n          Spacer(minLength: 8)\n          Button {\n            extensionsVM.beginProjectSkillsImport()\n          } label: {\n            Label(\"Import\", systemImage: \"tray.and.arrow.down\")\n              .labelStyle(.titleAndIcon)\n          }\n          .buttonStyle(.borderless)\n          .help(\"Import skills from this project\")\n          Button {\n            openExtensionsSettings(tab: .skills)\n          } label: {\n            Image(systemName: \"gearshape\")\n              .font(.body)\n          }\n          .buttonStyle(.borderless)\n          .help(\"Open Extensions settings\")\n        }\n        .frame(maxWidth: .infinity)\n      }\n\n      if filteredSkills.isEmpty {\n        emptyState(\n          icon: \"sparkles\",\n          title: extensionsVM.skills.isEmpty ? \"No Skills Installed\" : \"No Results\",\n          message: extensionsVM.skills.isEmpty\n            ? \"Install skills in Settings › Extensions to enable project selection.\"\n            : \"Try a different search.\"\n        )\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        ScrollView {\n          LazyVStack(spacing: 8) {\n            ForEach(filteredSkills) { skill in\n              HStack(alignment: .center, spacing: 8) {\n                Toggle(\n                  \"\",\n                  isOn: Binding(\n                    get: { skill.isSelected },\n                    set: { value in extensionsVM.updateSkillSelection(id: skill.id, value: value) }\n                  )\n                )\n                .labelsHidden()\n                .controlSize(.small)\n\n                VStack(alignment: .leading, spacing: 4) {\n                  Text(skill.displayName)\n                    .font(.subheadline.weight(.medium))\n                  if !skill.summary.isEmpty {\n                    Text(skill.summary)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .lineLimit(2)\n                  }\n                }\n                Spacer(minLength: 8)\n                HStack(spacing: 6) {\n                  MCPServerTargetToggle(\n                    provider: .codex,\n                    isOn: Binding(\n                      get: { skill.targets.codex },\n                      set: { value in\n                        extensionsVM.updateSkillTarget(id: skill.id, target: .codex, value: value)\n                      }\n                    ),\n                    disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.codex)\n                  )\n                  MCPServerTargetToggle(\n                    provider: .claude,\n                    isOn: Binding(\n                      get: { skill.targets.claude },\n                      set: { value in\n                        extensionsVM.updateSkillTarget(id: skill.id, target: .claude, value: value)\n                      }\n                    ),\n                    disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.claude)\n                  )\n                  MCPServerTargetToggle(\n                    provider: .gemini,\n                    isOn: Binding(\n                      get: { skill.targets.gemini },\n                      set: { value in\n                        extensionsVM.updateSkillTarget(id: skill.id, target: .gemini, value: value)\n                      }\n                    ),\n                    disabled: !skill.isSelected || !viewModel.preferences.isCLIEnabled(.gemini)\n                  )\n                }\n              }\n              .padding(.vertical, 6)\n            }\n          }\n          .padding(.horizontal, 8)\n        }\n      }\n    }\n    .padding(16)\n  }\n\n  private func emptyState(icon: String, title: String, message: String) -> some View {\n    VStack(spacing: 8) {\n      Image(systemName: icon)\n        .font(.system(size: 28))\n        .foregroundStyle(.secondary)\n      Text(title)\n        .font(.subheadline.weight(.semibold))\n      Text(message)\n        .font(.caption)\n        .foregroundStyle(.secondary)\n        .multilineTextAlignment(.center)\n    }\n    .frame(maxWidth: .infinity)\n    .padding(.vertical, 16)\n  }\n\n  private var filteredSkills: [SkillSummary] {\n    let trimmed = skillsSearchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return extensionsVM.skills }\n    return extensionsVM.skills.filter { skill in\n      let hay = [\n        skill.displayName,\n        skill.summary,\n        skill.tags.joined(separator: \" \"),\n        skill.source,\n      ]\n      .joined(separator: \" \")\n      .lowercased()\n      return hay.contains(trimmed.lowercased())\n    }\n  }\n\n  private var filteredMCPSelections: [ProjectMCPSelection] {\n    let trimmed = mcpSearchText.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !trimmed.isEmpty else { return extensionsVM.mcpSelections }\n    return extensionsVM.mcpSelections.filter { entry in\n      let hay = [\n        entry.server.name,\n        entry.server.meta?.description ?? \"\",\n      ]\n      .joined(separator: \" \")\n      .lowercased()\n      return hay.contains(trimmed.lowercased())\n    }\n  }\n\n  private func openExtensionsSettings(tab: ExtensionsSettingsTab) {\n    NotificationCenter.default.post(\n      name: .codMateOpenSettings,\n      object: nil,\n      userInfo: [\n        \"category\": SettingCategory.mcpServer.rawValue,\n        \"extensionsTab\": tab.rawValue,\n      ]\n    )\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      Text(modeTitle).font(.title3).fontWeight(.semibold)\n\n      Group {\n        if #available(macOS 15.0, *) {\n          TabView {\n            Tab(\"General\", systemImage: \"gearshape\") { generalTabView }\n            Tab(\"Profile\", systemImage: \"person.crop.square\") { profileTabView }\n            Tab(\"MCP Servers\", systemImage: \"server.rack\") { mcpTabView }\n            Tab(\"Skills\", systemImage: \"sparkles\") { skillsTabView }\n          }\n        } else {\n          TabView {\n            generalTabView\n              .tabItem { Label(\"General\", systemImage: \"gearshape\") }\n            profileTabView\n              .tabItem { Label(\"Profile\", systemImage: \"person.crop.square\") }\n            mcpTabView\n              .tabItem { Label(\"MCP Servers\", systemImage: \"server.rack\") }\n            skillsTabView\n              .tabItem { Label(\"Skills\", systemImage: \"sparkles\") }\n          }\n        }\n      }\n      .padding(.bottom, 4)\n\n      HStack {\n        if case .edit(let p) = mode {\n          Text(\"ID: \\(p.id)\").font(.caption).foregroundStyle(.secondary)\n        }\n        Spacer()\n        Button(\"Cancel\") { attemptClose() }\n          .keyboardShortcut(.cancelAction)\n        Button(primaryActionTitle) { save() }\n          .keyboardShortcut(.defaultAction)\n      }\n    }\n    .padding(16)\n    .frame(minWidth: 720, minHeight: 520)\n    .codmatePresentationSizingIfAvailable()\n    .onAppear(perform: load)\n    .onChange(of: directory) { newDir in\n      Task { await extensionsVM.load(projectId: modeSelfId(), projectDirectory: newDir, trustLevel: trustLevel) }\n    }\n    .alert(\"Discard changes?\", isPresented: $showCloseConfirm) {\n      Button(\"Keep Editing\", role: .cancel) {}\n      Button(\"Discard\", role: .destructive) { isPresented = false }\n    } message: {\n      Text(\"Your edits will be lost.\")\n    }\n    .sheet(isPresented: $extensionsVM.showMCPImportSheet) {\n      MCPImportSheet(\n        candidates: $extensionsVM.mcpImportCandidates,\n        isImporting: extensionsVM.isImportingMCP,\n        statusMessage: extensionsVM.mcpImportStatusMessage,\n        title: \"Import MCP Servers\",\n        subtitle: \"Scan this project for existing MCP servers and import into CodMate.\",\n        onCancel: { extensionsVM.cancelProjectMCPImport() },\n        onImport: { Task { await extensionsVM.importProjectMCPSelections() } }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .sheet(isPresented: $extensionsVM.showSkillsImportSheet) {\n      SkillsImportSheet(\n        candidates: $extensionsVM.skillsImportCandidates,\n        isImporting: extensionsVM.isImportingSkills,\n        statusMessage: extensionsVM.skillsImportStatusMessage,\n        title: \"Import Skills\",\n        subtitle: \"Scan this project for existing skills and import into CodMate.\",\n        onCancel: { extensionsVM.cancelProjectSkillsImport() },\n        onImport: { Task { await extensionsVM.importProjectSkillsSelections() } }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n  }\n\n  private var modeTitle: String {\n    if case .edit = mode { return \"Edit Project\" } else { return \"New Project\" }\n  }\n  private var primaryActionTitle: String {\n    if case .edit = mode { return \"Save\" } else { return \"Create\" }\n  }\n\n  private func chooseDirectory() {\n    let panel = NSOpenPanel()\n    panel.canChooseDirectories = true\n    panel.canChooseFiles = false\n    panel.allowsMultipleSelection = false\n    if panel.runModal() == .OK, let url = panel.url { directory = url.path }\n  }\n\n  private func load() {\n    switch mode {\n    case .edit(let p):\n      name = p.name\n      directory = p.directory ?? \"\"\n      trustLevel = p.trustLevel ?? \"trusted\"\n      parentProjectId = p.parentId\n      overview = p.overview ?? \"\"\n      profileId = p.profileId ?? \"\"\n      let initialSources = p.sources.isEmpty ? ProjectSessionSource.allSet : p.sources\n      let enabledSources = initialSources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }\n      sources = enabledSources.isEmpty\n        ? Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n        : enabledSources\n      if let pr = p.profile {\n        profileSandbox = pr.sandbox\n        profileApproval = pr.approval\n        profileFullAuto = pr.fullAuto\n        profileDangerBypass = pr.dangerouslyBypass\n        if let pp = pr.pathPrepend { profilePathPrependText = pp.joined(separator: \":\") }\n        if let env = pr.env {\n          let lines = env.keys.sorted().map { k in\n            let v = env[k] ?? \"\"\n            return \"\\(k)=\\(v)\"\n          }\n          profileEnvText = lines.joined(separator: \"\\n\")\n        }\n      }\n    case .new:\n      sources = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n      if let pf = prefill {\n        if let v = pf.name { name = v }\n        if let v = pf.directory { directory = v }\n        if let v = pf.trustLevel { trustLevel = v } else { trustLevel = \"trusted\" }\n        if let v = pf.overview { overview = v }\n        if let v = pf.profileId { profileId = v }\n        if let v = pf.parentId { parentProjectId = v }\n      }\n    }\n    original = currentSnapshot()\n    Task { await extensionsVM.load(projectId: modeSelfId(), projectDirectory: directory, trustLevel: trustLevel) }\n  }\n\n  private func slugify(_ s: String) -> String {\n    let lower = s.lowercased()\n    let allowed = \"abcdefghijklmnopqrstuvwxyz0123456789-\"\n    let chars = lower.map { ch -> Character in\n      if allowed.contains(ch) { return ch }\n      if ch.isLetter || ch.isNumber { return \"-\" }\n      return \"-\"\n    }\n    var str = String(chars)\n    while str.contains(\"--\") { str = str.replacingOccurrences(of: \"--\", with: \"-\") }\n    str = str.trimmingCharacters(in: CharacterSet(charactersIn: \"-\"))\n    return str.isEmpty ? \"project\" : str\n  }\n\n  private func generateId() -> String {\n    let baseName: String = {\n      let n = name.trimmingCharacters(in: .whitespaces)\n      if !n.isEmpty { return n }\n      let base = URL(fileURLWithPath: directory, isDirectory: true).lastPathComponent\n      return base.isEmpty ? \"project\" : base\n    }()\n    var candidate = slugify(baseName)\n    let existing = Set(viewModel.projects.map(\\.id))\n    var i = 1\n    while existing.contains(candidate) {\n      i += 1\n      candidate = slugify(baseName) + \"-\\(i)\"\n    }\n    return candidate\n  }\n\n  private func save() {\n    let trust = trustLevel.trimmingCharacters(in: .whitespaces).isEmpty ? nil : trustLevel\n    let ov = overview.trimmingCharacters(in: .whitespaces).isEmpty ? nil : overview\n    // Profile ID: auto map to project ID by default\n    let cleanedProfileId = profileId.trimmingCharacters(in: .whitespaces)\n    let profile: String? = cleanedProfileId.isEmpty ? nil : cleanedProfileId\n    let dirOpt: String? = {\n      let d = directory.trimmingCharacters(in: .whitespacesAndNewlines)\n      return d.isEmpty ? nil : directory\n    }()\n    let enabledSources = sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) }\n    let fallbackSources = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n    let finalSources = enabledSources.isEmpty ? fallbackSources : enabledSources\n\n    switch mode {\n    case .new:\n      let id = generateId()\n      let projProfile = buildProjectProfile(originalModel: nil)\n      let finalProfileId = profile ?? id\n      let p = Project(\n        id: id,\n        name: (name.isEmpty ? id : name),\n        directory: dirOpt,\n        trustLevel: trust,\n        overview: ov,\n        instructions: nil,\n        profileId: finalProfileId,\n        profile: projProfile,\n        parentId: parentProjectId,\n        sources: finalSources\n      )\n      Task {\n        await viewModel.createOrUpdateProject(p)\n        await extensionsVM.persistSelections(projectId: id, directory: dirOpt, trustLevel: trust)\n        if let ids = autoAssignSessionIDs, !ids.isEmpty {\n          await viewModel.assignSessions(to: id, ids: ids)\n        }\n        isPresented = false\n      }\n    case .edit(let old):\n      let projProfile = buildProjectProfile(originalModel: old.profile?.model)\n      let finalProfileId = profile ?? old.id\n      let p = Project(\n        id: old.id,\n        name: name,\n        directory: dirOpt,\n        trustLevel: trust,\n        overview: ov,\n        instructions: old.instructions,  // Preserve existing instructions\n        profileId: finalProfileId,\n        profile: projProfile,\n        parentId: parentProjectId,\n        sources: finalSources\n      )\n      Task {\n        await viewModel.createOrUpdateProject(p)\n        await extensionsVM.persistSelections(projectId: old.id, directory: dirOpt, trustLevel: trust)\n        isPresented = false\n      }\n    }\n  }\n\n  private var trustLevelSegment: String { trustLevel == \"untrusted\" ? \"untrusted\" : \"trusted\" }\n  private var trustLevelBinding: Binding<String> {\n    Binding<String>(\n      get: { trustLevelSegment },\n      set: { newValue in trustLevel = (newValue == \"untrusted\") ? \"untrusted\" : \"trusted\" }\n    )\n  }\n\n  private func binding(for source: ProjectSessionSource) -> Binding<Bool> {\n    Binding<Bool>(\n      get: { sources.contains(source) && viewModel.preferences.isCLIEnabled(source.baseKind) },\n      set: { newValue in\n        guard viewModel.preferences.isCLIEnabled(source.baseKind) else { return }\n        if newValue {\n          sources.insert(source)\n        } else {\n          if sources.count == 1 && sources.contains(source) { return }\n          sources.remove(source)\n        }\n      }\n    )\n  }\n\n  private func modeSelfId() -> String? {\n    if case .edit(let p) = mode { return p.id }\n    return nil\n  }\n\n  private func buildProjectProfile(originalModel: String?) -> ProjectProfile? {\n    if (profileId.trimmingCharacters(in: .whitespaces).isEmpty)\n      && (originalModel?.isEmpty ?? true)\n      && profileSandbox == nil\n      && profileApproval == nil\n      && profileFullAuto == nil\n      && profileDangerBypass == nil\n    {\n      return nil\n    }\n    return ProjectProfile(\n      model: originalModel,\n      sandbox: profileSandbox,\n      approval: profileApproval,\n      fullAuto: profileFullAuto,\n      dangerouslyBypass: profileDangerBypass,\n      pathPrepend: parsePathPrepend(profilePathPrependText),\n      env: parseEnv(profileEnvText)\n    )\n  }\n\n  private func parsePathPrepend(_ text: String) -> [String]? {\n    let s = text.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !s.isEmpty else { return nil }\n    return s.split(separator: \":\").map { String($0).trimmingCharacters(in: .whitespaces) }.filter {\n      !$0.isEmpty\n    }\n  }\n\n  private func parseEnv(_ text: String) -> [String: String]? {\n    let lines = text.split(whereSeparator: { $0 == \"\\n\" || $0 == \"\\r\" }).map(String.init)\n    var dict: [String: String] = [:]\n    for line in lines {\n      let t = line.trimmingCharacters(in: .whitespaces)\n      guard !t.isEmpty, let eq = t.firstIndex(of: \"=\") else { continue }\n      let key = String(t[..<eq]).trimmingCharacters(in: .whitespaces)\n      let val = String(t[t.index(after: eq)...])\n      if !key.isEmpty { dict[key] = val }\n    }\n    return dict.isEmpty ? nil : dict\n  }\n\n  private func currentSnapshot() -> Snapshot {\n    Snapshot(\n      name: name,\n      directory: directory,\n      trustLevel: trustLevel,\n      overview: overview,\n      profileSandbox: profileSandbox,\n      profileApproval: profileApproval,\n      profileFullAuto: profileFullAuto,\n      profileDangerBypass: profileDangerBypass,\n      profilePathPrependText: profilePathPrependText,\n      profileEnvText: profileEnvText,\n      parentProjectId: parentProjectId,\n      sources: sources\n    )\n  }\n\n  private func attemptClose() {\n    if let original, original != currentSnapshot() {\n      showCloseConfirm = true\n    } else {\n      isPresented = false\n    }\n  }\n\n}\nprivate struct ProjectTreeNode: Identifiable, Hashable {\n  let id: String\n  let project: Project\n  var children: [ProjectTreeNode]?\n}\n\nprivate func buildProjectTree(_ projects: [Project]) -> [ProjectTreeNode] {\n  var map: [String: ProjectTreeNode] = [:]\n  var roots: [ProjectTreeNode] = []\n  for p in projects {\n    map[p.id] = ProjectTreeNode(id: p.id, project: p, children: [])\n  }\n  for p in projects {\n    if let pid = p.parentId, let parent = map[pid] {\n      let copy = map[p.id]!\n      // attach under parent\n      var parentCopy = parent\n      parentCopy.children?.append(copy)\n      map[pid] = parentCopy\n    }\n  }\n  // rebuild roots (those without a valid parent)\n  for p in projects.sorted(by: { $0.name.localizedStandardCompare($1.name) == .orderedAscending }) {\n    if let pid = p.parentId, projects.contains(where: { $0.id == pid }) {\n      continue\n    }\n    // gather children from map updated above\n    let node = map[p.id] ?? ProjectTreeNode(id: p.id, project: p, children: nil)\n    roots.append(fixChildren(node, map: map))\n  }\n  return roots\n}\n\nprivate func fixChildren(_ node: ProjectTreeNode, map: [String: ProjectTreeNode]) -> ProjectTreeNode\n{\n  var out = node\n  let project = node.project\n  let children = map.values.filter { $0.project.parentId == project.id }\n    .sorted { $0.project.name.localizedStandardCompare($1.project.name) == .orderedAscending }\n    .map { fixChildren($0, map: map) }\n  out.children = children.isEmpty ? nil : children\n  return out\n}\n"
  },
  {
    "path": "views/ProviderEditorView.swift",
    "content": "import SwiftUI\n\nstruct ProviderEditorView: View {\n    @Binding var draft: CodexProvider\n    let isNew: Bool\n    var apiKeyApplyURL: String? = nil\n    var onCancel: () -> Void\n    var onSave: () -> Void\n    var onDelete: (() -> Void)? = nil\n    @State private var showDeleteAlert = false\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 16) {\n            Text(isNew ? \"Add Provider\" : \"Edit Provider\").font(.title2).fontWeight(.semibold)\n            Text(\"Configure a model provider compatible with OpenAI APIs.\")\n                .font(.subheadline).foregroundStyle(.secondary)\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) {\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"Name *\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Display name for this provider.\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\n                        \"OpenAI\", text: Binding(get: { draft.name ?? \"\" }, set: { draft.name = $0 })\n                    )\n                    .frame(maxWidth: .infinity)\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"Base URL *\").font(.subheadline).fontWeight(.medium)\n                        Text(\"API base URL, e.g., https://api.openai.com/v1\").font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"https://api.openai.com/v1\",\n                        text: Binding(get: { draft.baseURL ?? \"\" }, set: { draft.baseURL = $0 })\n                    )\n                    .frame(maxWidth: .infinity)\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"API Key\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Environment variable for API key (optional). Example: OPENAI_API_KEY\")\n                            .font(.caption).foregroundStyle(.secondary)\n                    }\n                    HStack(spacing: 8) {\n                        TextField(\n                            \"OPENAI_API_KEY\",\n                            text: Binding(get: { draft.envKey ?? \"\" }, set: { draft.envKey = $0 }))\n                        if let apiKeyApplyURL, let url = URL(string: apiKeyApplyURL) {\n                            Link(\"Get key\", destination: url)\n                                .font(.caption)\n                        }\n                    }\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"Wire API\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Protocol: chat or responses (optional).\").font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"responses\",\n                        text: Binding(get: { draft.wireAPI ?? \"\" }, set: { draft.wireAPI = $0 }))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"query_params\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Inline TOML. Example: { api-version = \\\"2025-04-01-preview\\\" }\").font(\n                            .caption\n                        ).foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"{ api-version = \\\"2025-04-01-preview\\\" }\",\n                        text: Binding(\n                            get: { draft.queryParamsRaw ?? \"\" }, set: { draft.queryParamsRaw = $0 })\n                    )\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"http_headers\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Inline TOML map. Example: { X-Header = \\\"abc\\\" }\").font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"{ X-Header = \\\"abc\\\" }\",\n                        text: Binding(\n                            get: { draft.httpHeadersRaw ?? \"\" }, set: { draft.httpHeadersRaw = $0 })\n                    )\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"env_http_headers\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Header values from env. Example: { X-Token = \\\"MY_ENV\\\" }\").font(\n                            .caption\n                        ).foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"{ X-Token = \\\"MY_ENV\\\" }\",\n                        text: Binding(\n                            get: { draft.envHttpHeadersRaw ?? \"\" },\n                            set: { draft.envHttpHeadersRaw = $0 }))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"request_max_retries\").font(.subheadline).fontWeight(.medium)\n                        Text(\"HTTP retry count (optional).\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\n                        \"4\",\n                        text: Binding(\n                            get: { (draft.requestMaxRetries?.description) ?? \"\" },\n                            set: { draft.requestMaxRetries = Int($0) }))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"stream_max_retries\").font(.subheadline).fontWeight(.medium)\n                        Text(\"SSE reconnect attempts (optional).\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\n                        \"5\",\n                        text: Binding(\n                            get: { (draft.streamMaxRetries?.description) ?? \"\" },\n                            set: { draft.streamMaxRetries = Int($0) }))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 0) {\n                        Text(\"stream_idle_timeout_ms\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Idle timeout for streaming (optional).\").font(.caption)\n                            .foregroundStyle(.secondary)\n                    }\n                    TextField(\n                        \"300000\",\n                        text: Binding(\n                            get: { (draft.streamIdleTimeoutMs?.description) ?? \"\" },\n                            set: { draft.streamIdleTimeoutMs = Int($0) }))\n                }\n            }\n            HStack {\n                if !isNew, onDelete != nil {\n                    Button(\"Delete\", role: .destructive) { showDeleteAlert = true }\n                }\n                Button(\"Cancel\", role: .cancel, action: onCancel)\n                Spacer()\n                Button(\"Save\", action: onSave).buttonStyle(.borderedProminent)\n            }\n        }\n        .alert(\"Delete provider?\", isPresented: $showDeleteAlert) {\n            Button(\"Cancel\", role: .cancel) { showDeleteAlert = false }\n            Button(\"Delete\", role: .destructive) {\n                showDeleteAlert = false\n                onDelete?()\n            }\n        } message: {\n            Text(\"This will remove the provider from config.toml.\")\n        }\n    }\n}\n"
  },
  {
    "path": "views/ProviderIconView.swift",
    "content": "import AppKit\nimport SwiftUI\n\nstruct ProviderIconView: View {\n  let provider: UsageProviderKind\n  var size: CGFloat = 12\n  var cornerRadius: CGFloat = 2\n  var saturation: Double = 1.0\n  var opacity: Double = 1.0\n\n  @Environment(\\.colorScheme) private var colorScheme\n\n  var body: some View {\n    Group {\n      if let name = iconName(for: provider),\n         let image = ProviderIconResource.processedImage(\n           named: name,\n           size: NSSize(width: size, height: size),\n           isDarkMode: colorScheme == .dark\n         ) {\n        Image(nsImage: image)\n          .resizable()\n          .interpolation(.high)\n          .aspectRatio(contentMode: .fit)\n          .frame(width: size, height: size)\n          .clipShape(RoundedRectangle(cornerRadius: cornerRadius))\n          .saturation(saturation)\n          .opacity(opacity)\n      } else {\n        Circle()\n          .fill(accent(for: provider))\n          .frame(width: dotSize, height: dotSize)\n          .saturation(saturation)\n          .opacity(opacity)\n      }\n    }\n    .frame(width: size, height: size, alignment: .center)\n    .id(colorScheme)\n  }\n\n  private var dotSize: CGFloat {\n    max(6, size * 0.75)\n  }\n\n  private func iconName(for provider: UsageProviderKind) -> String? {\n    switch provider {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    }\n  }\n\n  private func accent(for provider: UsageProviderKind) -> Color {\n    switch provider {\n    case .codex: return Color.accentColor\n    case .claude: return Color(nsColor: .systemPurple)\n    case .gemini: return Color(nsColor: .systemTeal)\n    }\n  }\n}\n"
  },
  {
    "path": "views/ProvidersSettingsView.swift",
    "content": "import AppKit\nimport Network\nimport SwiftUI\n\nstruct ProvidersSettingsView: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    @StateObject private var vm = ProvidersVM()\n    @StateObject private var proxyService = CLIProxyService.shared\n    @State private var pendingDeleteId: String?\n    @State private var pendingDeleteName: String?\n    @State private var pendingDeleteAccount: CLIProxyService.OAuthAccount?\n    @State private var oauthInfoAccount: CLIProxyService.OAuthAccount?\n    @State private var oauthLoginProvider: LocalAuthProvider?\n    @State private var oauthAutoStartFailed: Bool = false\n    @State private var pendingOAuthProvider: LocalAuthProvider?\n    @State private var showOAuthRiskWarning: Bool = false\n    @State private var localModels: [CLIProxyService.LocalModel] = []\n    @State private var localIP: String = \"127.0.0.1\"\n    @State private var publicAPIKey: String = \"\"\n\n    private let minPublicKeyLength = 36\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            header\n            Group {\n                if #available(macOS 15.0, *) {\n                    TabView {\n                        Tab(\"Providers\", systemImage: \"server.rack\") {\n                            SettingsTabContent {\n                                providersList\n                            }\n                        }\n                        Tab(\"ReRoute\", systemImage: \"arrow.triangle.2.circlepath\") {\n                            SettingsTabContent {\n                                proxyCapabilitiesSection\n                            }\n                        }\n                        Tab(\"Advanced\", systemImage: \"gearshape.2\") {\n                            SettingsTabContent {\n                                cliProxyAdvancedSection\n                            }\n                        }\n                    }\n                } else {\n                    TabView {\n                        SettingsTabContent {\n                            providersList\n                        }\n                        .tabItem { Label(\"Providers\", systemImage: \"server.rack\") }\n                        SettingsTabContent {\n                            proxyCapabilitiesSection\n                        }\n                        .tabItem { Label(\"ReRoute\", systemImage: \"arrow.triangle.2.circlepath\") }\n                        SettingsTabContent {\n                            cliProxyAdvancedSection\n                        }\n                        .tabItem { Label(\"Advanced\", systemImage: \"gearshape.2\") }\n                    }\n                }\n            }\n            .controlSize(.regular)\n            .padding(.bottom, 16)\n        }\n        .sheet(\n            isPresented: Binding(\n                get: { vm.showEditor },\n                set: { newValue in\n                    vm.showEditor = newValue\n                    if !newValue {\n                        // Reset new provider state when sheet closes\n                        vm.isNewProvider = false\n                        // Clear test results when closing editor\n                        vm.testResults = [:]\n                        vm.testResultText = nil\n                    }\n                }\n            )\n        ) { ProviderEditorSheet(vm: vm, preferences: preferences) }\n        .sheet(item: $oauthInfoAccount) { account in\n            let accounts = proxyService.listOAuthAccounts().filter {\n                $0.provider == account.provider\n            }\n            OAuthProviderInfoSheet(\n                provider: account.provider,\n                isLoggedIn: !accounts.isEmpty,\n                accounts: accounts,\n                selectedAccount: account,\n                initialModels: modelsForOAuthProvider(account.provider),\n                onLogin: { oauthLoginProvider = account.provider },\n                onLogout: { account in\n                    proxyService.deleteOAuthAccount(account)\n                    refreshOAuthStatus()\n                }\n            )\n        }\n        .sheet(item: $oauthLoginProvider) { provider in\n            OAuthLoginSheet(\n                provider: provider,\n                onDone: {\n                    oauthLoginProvider = nil\n                    Task {\n                        await vm.refreshOAuthAccounts()\n                        await refreshLocalModels()\n                        ensureServiceRunningIfNeeded(force: true)\n                    }\n                },\n                onCancel: {\n                    proxyService.cancelLogin()\n                    oauthLoginProvider = nil\n                }\n            )\n        }\n        .sheet(\n            item: Binding(\n                get: {\n                    proxyService.loginPrompt != nil && oauthLoginProvider != nil\n                        ? proxyService.loginPrompt : nil\n                },\n                set: { _ in proxyService.loginPrompt = nil }\n            )\n        ) { prompt in\n            LoginPromptSheet(\n                prompt: prompt,\n                onSubmit: { input in\n                    proxyService.submitLoginInput(input)\n                    proxyService.loginPrompt = nil\n                },\n                onCancel: {\n                    proxyService.loginPrompt = nil\n                },\n                onStop: {\n                    proxyService.cancelLogin()\n                    proxyService.loginPrompt = nil\n                }\n            )\n        }\n        .codmatePresentationSizingIfAvailable()\n        .alert(\"OAuth Provider Authorization Risk Warning\", isPresented: $showOAuthRiskWarning) {\n            Button(\"I Understand and Accept the Risk\", role: .destructive) {\n                confirmOAuthLogin()\n            }\n            Button(\"Cancel\", role: .cancel) {\n                pendingOAuthProvider = nil\n            }\n        } message: {\n            Text(\n                \"\"\"\n                Adding OAuth providers requires separate authorization through CLIProxyAPI, which is isolated from CodMate's main CLI authorization.\n\n                ⚠️ **Potential Risks:**\n                • Account suspension or termination by the provider\n                • Violation of provider terms of service\n                • Loss of access to services\n\n                By proceeding, you acknowledge that you understand these risks and will use this feature at your own discretion.\n\n                **Note:** The ability to add OAuth providers may be partially or fully removed in future versions of CodMate.\n                \"\"\")\n        }\n        .task {\n            await vm.loadAll()\n            await vm.loadTemplates()\n            getLocalIPAddress()\n            loadPublicKey()\n            refreshOAuthStatus()\n            await refreshLocalModels()\n            ensureServiceRunningIfNeeded()\n        }\n        .onChange(of: preferences.localServerEnabled) { enabled in\n            if enabled {\n                ensureServiceRunningIfNeeded(force: true)\n            }\n        }\n        // Removed rerouteBuiltIn/reroute3P onChange handlers - all providers now use Auto-Proxy mode\n        .onChange(of: preferences.oauthProvidersEnabled) { _ in\n            refreshOAuthStatus()\n            Task { await refreshLocalModels() }\n            ensureServiceRunningIfNeeded()\n        }\n        .onChange(of: preferences.apiKeyProvidersEnabled) { _ in\n            Task { await refreshLocalModels() }\n            ensureServiceRunningIfNeeded()\n        }\n        .onChange(of: proxyService.isRunning) { running in\n            if running { oauthAutoStartFailed = false }\n            Task { await refreshLocalModels() }\n        }\n        .confirmationDialog(\n            \"Delete Provider\",\n            isPresented: Binding(\n                get: { pendingDeleteId != nil },\n                set: {\n                    if !$0 {\n                        pendingDeleteId = nil\n                        pendingDeleteName = nil\n                    }\n                }\n            ),\n            titleVisibility: .visible\n        ) {\n            Button(\"Delete\", role: .destructive) {\n                if let id = pendingDeleteId {\n                    Task { await vm.delete(id: id, preferences: preferences) }\n                }\n            }\n            Button(\"Cancel\", role: .cancel) {}\n        } message: {\n            if let name = pendingDeleteName {\n                Text(\"Are you sure you want to delete \\\"\\(name)\\\"? This action cannot be undone.\")\n            } else {\n                Text(\"Are you sure you want to delete this provider? This action cannot be undone.\")\n            }\n        }\n        .confirmationDialog(\n            \"Sign Out\",\n            isPresented: Binding(\n                get: { pendingDeleteAccount != nil },\n                set: { if !$0 { pendingDeleteAccount = nil } }\n            ),\n            titleVisibility: .visible\n        ) {\n            Button(\"Sign Out\", role: .destructive) {\n                if let account = pendingDeleteAccount {\n                    Task { await vm.deleteOAuthAccount(account) }\n                }\n            }\n            Button(\"Cancel\", role: .cancel) {}\n        } message: {\n            if let email = pendingDeleteAccount?.email {\n                Text(\"Are you sure you want to sign out \\\"\\(email)\\\"? Credentials will be removed.\")\n            } else {\n                Text(\"Are you sure you want to sign out? Credentials will be removed.\")\n            }\n        }\n    }\n\n    private var header: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            Text(\"Providers Settings\")\n                .font(.title2)\n                .fontWeight(.bold)\n            Text(\"Manage API key and OAuth providers for Codex and Claude Code.\")\n                .font(.subheadline)\n                .foregroundColor(.secondary)\n        }\n    }\n\n    // Computed properties for sorted providers\n    private var sortedOAuthProviders: [LocalAuthProvider] {\n        LocalAuthProvider.allCases.sorted {\n            $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending\n        }\n    }\n\n    private var sortedTemplates: [ProvidersRegistryService.Provider] {\n        vm.templates.sorted {\n            let name0 = ($0.name?.isEmpty == false ? $0.name! : $0.id).lowercased()\n            let name1 = ($1.name?.isEmpty == false ? $1.name! : $1.id).lowercased()\n            return name0.localizedCaseInsensitiveCompare(name1) == .orderedAscending\n        }\n    }\n\n    private var providersList: some View {\n        ScrollView {\n            VStack(alignment: .leading, spacing: 20) {\n                // OAuth Section\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"OAuth\").font(.headline).fontWeight(.semibold)\n\n                    if !vm.oauthAccounts.isEmpty {\n                        settingsCard {\n                            VStack(alignment: .leading, spacing: 0) {\n                                ForEach(Array(vm.oauthAccounts.enumerated()), id: \\.element.id) {\n                                    index, account in\n                                    if index > 0 {\n                                        Divider().padding(.vertical, 4)\n                                    }\n                                    HStack(alignment: .center, spacing: 0) {\n                                        // Left: Icon + Name\n                                        HStack(alignment: .center, spacing: 8) {\n                                            LocalAuthProviderIconView(\n                                                provider: account.provider, size: 16,\n                                                cornerRadius: 4\n                                            )\n                                            .frame(width: 20)\n                                            Text(account.provider.displayName)\n                                                .font(.body.weight(.medium))\n                                        }\n                                        .frame(minWidth: 140, alignment: .leading)\n\n                                        Spacer(minLength: 16)\n\n                                        // Center: Email/Status\n                                        VStack(alignment: .leading, spacing: 2) {\n                                            if let email = account.email, !email.isEmpty {\n                                                Text(email)\n                                                    .font(.caption)\n                                                    .foregroundStyle(.secondary)\n                                            }\n                                            Text(\"Logged In\")\n                                                .font(.caption2)\n                                                .foregroundStyle(.green)\n                                        }\n                                        .frame(maxWidth: .infinity, alignment: .leading)\n\n                                        // Right: Info + Toggle\n                                        Button {\n                                            oauthInfoAccount = account\n                                        } label: {\n                                            Image(systemName: \"info.circle\")\n                                                .font(.body)\n                                        }\n                                        .buttonStyle(.borderless)\n                                        .help(\"View details\")\n\n                                        Toggle(\"\", isOn: bindingForOAuthAccount(account: account))\n                                            .toggleStyle(.switch)\n                                            .labelsHidden()\n                                            .controlSize(.small)\n                                            .padding(.leading, 8)\n                                    }\n                                    .padding(.vertical, 4)\n                                    .contentShape(Rectangle())\n                                    .contextMenu {\n                                        Button {\n                                            oauthInfoAccount = account\n                                        } label: {\n                                            Text(\"Info\")\n                                        }\n                                        Divider()\n                                        Button(role: .destructive) {\n                                            pendingDeleteAccount = account\n                                        } label: {\n                                            Text(\"Sign Out\")\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    } else {\n                        settingsCard {\n                            VStack(spacing: 12) {\n                                Image(systemName: \"person.crop.circle.badge.plus\")\n                                    .font(.system(size: 32))\n                                    .foregroundStyle(.secondary)\n                                Text(\"No OAuth Accounts\")\n                                    .font(.subheadline)\n                                    .fontWeight(.medium)\n                                Text(\"Click + to add an account\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                            }\n                            .frame(maxWidth: .infinity)\n                            .frame(minHeight: 100)\n                            .padding(.vertical, 20)\n                        }\n                    }\n\n                    // OAuth Add Button (Right aligned below list)\n                    HStack {\n                        Spacer()\n                        ProviderAddMenu(\n                            title: \"Add OAuth Account\",\n                            helpText: \"Add OAuth Account\",\n                            items: sortedOAuthProviders.map { provider in\n                                ProviderMenuItem(\n                                    id: provider.id,\n                                    name: provider.displayName,\n                                    icon: .oauth(provider),\n                                    action: { startOAuthLogin(provider) }\n                                )\n                            }\n                        )\n                    }\n\n                }\n\n                // API Key Section\n                VStack(alignment: .leading, spacing: 10) {\n                    Text(\"API Key\").font(.headline).fontWeight(.semibold)\n\n                    if !vm.providers.isEmpty {\n                        settingsCard {\n                            VStack(alignment: .leading, spacing: 0) {\n                                ForEach(Array(vm.providers.enumerated()), id: \\.element.id) {\n                                    index, p in\n                                    if index > 0 {\n                                        Divider().padding(.vertical, 4)\n                                    }\n                                    HStack(alignment: .center, spacing: 0) {\n                                        // Left: Icon + Name\n                                        HStack(alignment: .center, spacing: 8) {\n                                            APIKeyProviderIconView(\n                                                provider: p,\n                                                size: 16,\n                                                cornerRadius: 4,\n                                                isSelected: vm.activeCodexProviderId == p.id\n                                            )\n                                            .frame(width: 20)\n                                            Text(p.name?.isEmpty == false ? p.name! : p.id)\n                                                .font(.body.weight(.medium))\n                                        }\n                                        .frame(minWidth: 140, alignment: .leading)\n\n                                        Spacer(minLength: 16)\n\n                                        VStack(alignment: .leading, spacing: 2) {\n                                            endpointBlock(\n                                                label: \"Codex\",\n                                                value: p.connectors[\n                                                    ProvidersRegistryService.Consumer.codex.rawValue\n                                                ]?\n                                                .baseURL\n                                            )\n                                            endpointBlock(\n                                                label: \"Claude\",\n                                                value: p.connectors[\n                                                    ProvidersRegistryService.Consumer.claudeCode\n                                                        .rawValue]?\n                                                    .baseURL\n                                            )\n                                        }\n                                        .frame(maxWidth: .infinity, alignment: .leading)\n\n                                        // Right: Edit + Toggle\n                                        Button {\n                                            vm.selectedId = p.id\n                                            vm.showEditor = true\n                                        } label: {\n                                            Image(systemName: \"pencil\")\n                                                .font(.body)\n                                        }\n                                        .buttonStyle(.borderless)\n                                        .help(\"Edit provider\")\n\n                                        Toggle(\"\", isOn: bindingForAPIKeyProvider(providerId: p.id))\n                                            .toggleStyle(.switch)\n                                            .labelsHidden()\n                                            .controlSize(.small)\n                                            .padding(.leading, 8)\n                                    }\n                                    .padding(.vertical, 4)\n                                    .contentShape(Rectangle())\n                                    .contextMenu {\n                                        Button(\"Edit…\") {\n                                            vm.showEditor = true\n                                            vm.selectedId = p.id\n                                        }\n                                        Divider()\n                                        Button(role: .destructive) {\n                                            pendingDeleteId = p.id\n                                            pendingDeleteName =\n                                                p.name?.isEmpty == false ? p.name : p.id\n                                        } label: {\n                                            Text(\"Delete\")\n                                        }\n                                    }\n                                }\n                            }\n                        }\n                    } else {\n                        settingsCard {\n                            VStack(spacing: 12) {\n                                Image(systemName: \"key\")\n                                    .font(.system(size: 32))\n                                    .foregroundStyle(.secondary)\n                                Text(\"No API Key Providers\")\n                                    .font(.subheadline)\n                                    .fontWeight(.medium)\n                                Text(\"Click + to configure an API key provider\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                            }\n                            .frame(maxWidth: .infinity)\n                            .frame(minHeight: 100)\n                            .padding(.vertical, 20)\n                        }\n                    }\n\n                    // API Key Add Button (Right aligned below list)\n                    HStack {\n                        Spacer()\n                        ProviderAddMenu(\n                            title: \"Add API Key Provider\",\n                            helpText: \"Add API Key Provider\",\n                            items: sortedTemplates.map { template in\n                                ProviderMenuItem(\n                                    id: template.id,\n                                    name: template.name?.isEmpty == false\n                                        ? template.name! : template.id,\n                                    icon: .apiKey(template),\n                                    action: { vm.startFromTemplate(template) }\n                                )\n                            },\n                            emptyMessage: \"No templates found\",\n                            customAction: (\"Custom…\", { vm.startNewProvider() })\n                        )\n                    }\n                }\n            }\n            .padding(.bottom, 20)\n        }\n    }\n\n    private var proxyCapabilitiesSection: some View {\n        VStack(alignment: .leading, spacing: 20) {\n            // 1. CLI Proxy API Status\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"CLI Proxy API\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                HStack(spacing: 6) {\n                                    Image(systemName: \"bolt.horizontal\")\n                                        .frame(width: 16, alignment: .leading)\n                                    Text(\"Service Status\")\n                                        .font(.subheadline).fontWeight(.medium)\n                                }\n                                Text(\n                                    \"All providers are routed through CLI Proxy API when enabled in the Providers list above.\"\n                                )\n                                .font(.caption).foregroundColor(.secondary)\n                                .padding(.leading, 22)\n                            }\n                            HStack(spacing: 8) {\n                                statusPill(\n                                    proxyService.isRunning ? \"Running\" : \"Stopped\",\n                                    active: proxyService.isRunning)\n                                if proxyService.isRunning {\n                                    Button(\"Restart\") {\n                                        restartProxyService()\n                                    }\n                                    .buttonStyle(.bordered)\n                                } else {\n                                    Button(\"Start\") {\n                                        startProxyService()\n                                    }\n                                    .buttonStyle(.borderedProminent)\n                                    .disabled(!proxyService.isBinaryInstalled)\n                                }\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n\n                        gridDivider\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                HStack(spacing: 6) {\n                                    Image(systemName: \"number\")\n                                        .frame(width: 16, alignment: .leading)\n                                    Text(\"Port\")\n                                        .font(.subheadline).fontWeight(.medium)\n                                }\n                                Text(\"Server port number for CLI Proxy API\")\n                                    .font(.caption).foregroundColor(.secondary)\n                                    .padding(.leading, 22)\n                            }\n                            TextField(\n                                \"Port\", value: $preferences.localServerPort,\n                                formatter: NumberFormatter()\n                            )\n                            .textFieldStyle(.roundedBorder)\n                            .font(.system(.caption, design: .monospaced))\n                            .frame(width: 80)\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                    }\n                }\n            }\n\n            // 2. Public Access\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"Public Access\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                HStack(spacing: 6) {\n                                    Image(systemName: \"network\")\n                                        .frame(width: 16, alignment: .leading)\n                                    Text(\"Public Access\")\n                                        .font(.subheadline).fontWeight(.medium)\n                                }\n                                Text(\"Expose a unified API endpoint for all providers\")\n                                    .font(.caption).foregroundColor(.secondary)\n                                    .padding(.leading, 22)\n                            }\n                            Toggle(\"\", isOn: $preferences.localServerEnabled)\n                                .labelsHidden()\n                                .toggleStyle(.switch)\n                                .controlSize(.small)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n\n                        if preferences.localServerEnabled {\n                            gridDivider\n                            GridRow {\n                                VStack(alignment: .leading, spacing: 0) {\n                                    HStack(spacing: 6) {\n                                        Image(systemName: \"link\")\n                                            .frame(width: 16, alignment: .leading)\n                                        Text(\"Public URL\")\n                                            .font(.subheadline).fontWeight(.medium)\n                                    }\n                                    Text(\"Publicly accessible server URL\")\n                                        .font(.caption).foregroundColor(.secondary)\n                                        .padding(.leading, 22)\n                                }\n                                HStack(spacing: 6) {\n                                    Text(\"http://\\(localIP):\\(String(preferences.localServerPort))\")\n                                        .font(.system(.caption, design: .monospaced))\n                                        .textSelection(.enabled)\n                                    Button(action: {\n                                        copyToClipboard(\n                                            \"http://\\(localIP):\\(String(preferences.localServerPort))\"\n                                        )\n                                    }) {\n                                        Image(systemName: \"doc.on.doc\")\n                                    }\n                                    .buttonStyle(.plain)\n                                }\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            }\n\n                            gridDivider\n                            GridRow {\n                                VStack(alignment: .leading, spacing: 0) {\n                                    HStack(spacing: 6) {\n                                        Image(systemName: \"key\")\n                                            .frame(width: 16, alignment: .leading)\n                                        Text(\"Public Key\")\n                                            .font(.subheadline).fontWeight(.medium)\n                                    }\n                                    Text(\"API key for public access authentication\")\n                                        .font(.caption).foregroundColor(.secondary)\n                                        .padding(.leading, 22)\n                                }\n                                VStack(alignment: .trailing, spacing: 4) {\n                                    HStack(spacing: 6) {\n                                        Button(action: regeneratePublicKey) {\n                                            Image(systemName: \"arrow.clockwise\")\n                                        }\n                                        .buttonStyle(.plain)\n                                        HStack(spacing: 4) {\n                                            TextField(\"Key\", text: $publicAPIKey)\n                                                .textFieldStyle(.roundedBorder)\n                                                .font(.system(.caption, design: .monospaced))\n                                                .onChange(of: publicAPIKey) { newValue in\n                                                    let trimmed = newValue.trimmingCharacters(\n                                                        in: .whitespacesAndNewlines)\n                                                    guard trimmed.count >= minPublicKeyLength else {\n                                                        return\n                                                    }\n                                                    proxyService.updatePublicAPIKey(trimmed)\n                                                }\n                                            Button(action: { copyToClipboard(publicAPIKey) }) {\n                                                Image(systemName: \"doc.on.doc\")\n                                            }\n                                            .buttonStyle(.plain)\n                                        }\n                                        .frame(width: 320)\n                                    }\n                                    if publicAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)\n                                        .count\n                                        < minPublicKeyLength\n                                    {\n                                        Text(\"Minimum \\(minPublicKeyLength) characters\")\n                                            .font(.caption)\n                                            .foregroundColor(.red)\n                                    }\n                                }\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            }\n                        }\n                    }\n                }\n            }\n\n            // 3. Config Reference\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"Config Reference\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"GitHub Repository\", systemImage: \"square.stack.3d.up\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"CLIProxyAPI source code repository\")\n                                    .font(.caption).foregroundColor(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            HStack(spacing: 6) {\n                                Link(\n                                    destination: URL(\n                                        string: \"https://github.com/router-for-me/CLIProxyAPI\")!\n                                ) {\n                                    HStack(spacing: 6) {\n                                        Text(\"https://github.com/router-for-me/CLIProxyAPI\")\n                                            .font(.system(.caption, design: .monospaced))\n                                            .foregroundColor(.secondary)\n                                        Image(systemName: \"arrow.up.right.square\")\n                                            .font(.system(size: 12))\n                                            .foregroundColor(.secondary)\n                                            .opacity(0.6)\n                                    }\n                                }\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n\n                        gridDivider\n\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 2) {\n                                Label(\"Documentation\", systemImage: \"book\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"CLIProxyAPI official documentation\")\n                                    .font(.caption).foregroundColor(.secondary)\n                                    .fixedSize(horizontal: false, vertical: true)\n                            }\n                            HStack(spacing: 6) {\n                                Link(destination: URL(string: \"https://help.router-for.me/\")!) {\n                                    HStack(spacing: 6) {\n                                        Text(\"https://help.router-for.me/\")\n                                            .font(.system(.caption, design: .monospaced))\n                                            .foregroundColor(.secondary)\n                                        Image(systemName: \"arrow.up.right.square\")\n                                            .font(.system(size: 12))\n                                            .foregroundColor(.secondary)\n                                            .opacity(0.6)\n                                    }\n                                }\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    private var cliProxyAdvancedSection: some View {\n        VStack(alignment: .leading, spacing: 20) {\n            // CLI Proxy API Installation & Diagnostics\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"Advanced\").font(.headline).fontWeight(.semibold)\n                settingsCard {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        // Conflict warning (only show if there's a conflict)\n                        if let warning = proxyService.conflictWarning {\n                            GridRow {\n                                HStack(spacing: 8) {\n                                    Image(systemName: \"exclamationmark.triangle.fill\")\n                                        .foregroundColor(.orange)\n                                    Text(warning)\n                                        .font(.caption)\n                                        .foregroundColor(.secondary)\n                                }\n                                .gridCellColumns(3)\n                            }\n\n                            gridDivider\n                        }\n\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Binary Location\", systemImage: \"app.badge\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"CLIProxyAPI binary executable path\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(proxyService.binaryFilePath)\n                                .font(.system(.caption, design: .monospaced))\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                                .onTapGesture(count: 2) {\n                                    revealCLIProxyBinaryInFinder()\n                                }\n                                .help(\"Double-click to reveal in Finder\")\n                            HStack(spacing: 8) {\n                                if proxyService.isInstalling {\n                                    ProgressView()\n                                        .scaleEffect(0.6)\n                                        .frame(width: 14, height: 14)\n                                    Text(\"Installing\")\n                                        .font(.caption)\n                                        .foregroundColor(.secondary)\n                                } else {\n                                    Button(cliProxyActionButtonTitle) {\n                                        Task {\n                                            if proxyService.binarySource == .homebrew {\n                                                try? await proxyService.brewUpgrade()\n                                            } else {\n                                                try? await proxyService.install()\n                                            }\n                                        }\n                                    }\n                                    .buttonStyle(.borderedProminent)\n                                    .tint(cliProxyActionButtonColor)\n                                }\n                            }\n                            .frame(width: 90, alignment: .trailing)\n                            .disabled(proxyService.isInstalling)\n                        }\n\n                        gridDivider\n\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Config File\", systemImage: \"doc.text\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"CLIProxyAPI configuration file\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(cliProxyConfigFilePath)\n                                .font(.system(.caption, design: .monospaced))\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            Button(\"Reveal\") { revealCLIProxyConfigInFinder() }\n                                .buttonStyle(.bordered)\n                                .frame(width: 90, alignment: .trailing)\n                        }\n\n                        gridDivider\n\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Auth Directory\", systemImage: \"folder\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"OAuth credential storage\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(cliProxyAuthDirPath)\n                                .font(.system(.caption, design: .monospaced))\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            Button(\"Reveal\") { revealCLIProxyAuthDirInFinder() }\n                                .buttonStyle(.bordered)\n                                .frame(width: 90, alignment: .trailing)\n                        }\n\n                        gridDivider\n\n                        GridRow {\n                            VStack(alignment: .leading, spacing: 0) {\n                                Label(\"Logs\", systemImage: \"doc.plaintext\")\n                                    .font(.subheadline).fontWeight(.medium)\n                                Text(\"CLIProxyAPI log files directory\")\n                                    .font(.caption).foregroundColor(.secondary)\n                            }\n                            Text(cliProxyLogsPath)\n                                .font(.system(.caption, design: .monospaced))\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                            Button(\"Reveal\") { revealCLIProxyLogsInFinder() }\n                                .buttonStyle(.bordered)\n                                .frame(width: 90, alignment: .trailing)\n                        }\n                    }\n                }\n            }\n        }\n    }\n\n    @ViewBuilder\n    private func endpointBlock(label: String, value: String?) -> some View {\n        HStack(spacing: 6) {\n            Text(\"\\(label):\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n                .frame(width: 50, alignment: .leading)\n            Text((value?.isEmpty == false) ? value! : \"—\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n                .lineLimit(1)\n                .truncationMode(.middle)\n        }\n    }\n\n    private func startOAuthLogin(_ provider: LocalAuthProvider) {\n        guard oauthLoginProvider == nil else { return }\n        // Show risk warning before starting OAuth login\n        pendingOAuthProvider = provider\n        showOAuthRiskWarning = true\n    }\n\n    private func confirmOAuthLogin() {\n        guard let provider = pendingOAuthProvider else { return }\n        pendingOAuthProvider = nil\n        oauthLoginProvider = provider\n    }\n\n    @ViewBuilder\n    private func oauthStatusBadge(_ provider: LocalAuthProvider, isLoggedIn: Bool) -> some View {\n        if isLoggedIn {\n            Text(\"Logged In\").font(.caption).foregroundStyle(.green)\n        } else {\n            Text(\"Logged Out\").font(.caption).foregroundStyle(.secondary)\n        }\n    }\n\n    private func refreshOAuthStatus() {\n        Task { await vm.refreshOAuthAccounts() }\n    }\n\n    private func ensureServiceRunningIfNeeded(force: Bool = false) {\n        let hasLoggedInOAuth = !vm.oauthAccounts.isEmpty\n        let hasEnabledProviders = !vm.oauthAccounts.isEmpty || !vm.providers.isEmpty\n        let shouldEnsure =\n            force\n            || hasLoggedInOAuth\n            || preferences.localServerEnabled\n            || hasEnabledProviders\n        guard shouldEnsure else { return }\n        guard !proxyService.isRunning else { return }\n        oauthAutoStartFailed = false\n        Task {\n            do {\n                try await proxyService.start()\n                await MainActor.run { oauthAutoStartFailed = false }\n            } catch {\n                await MainActor.run { oauthAutoStartFailed = true }\n            }\n        }\n    }\n\n    private func restartProxyService() {\n        Task {\n            if proxyService.isRunning {\n                proxyService.stop()\n                try? await Task.sleep(nanoseconds: 500_000_000)\n            }\n            try? await proxyService.start()\n        }\n    }\n\n    private func startProxyService() {\n        Task { try? await proxyService.start() }\n    }\n\n    private func statusPill(_ text: String, active: Bool) -> some View {\n        Text(text)\n            .font(.caption)\n            .padding(.horizontal, 8)\n            .padding(.vertical, 3)\n            .background(active ? Color.green.opacity(0.15) : Color.secondary.opacity(0.12))\n            .foregroundStyle(active ? Color.green : Color.secondary)\n            .clipShape(Capsule())\n    }\n\n    private func refreshLocalModels() async {\n        localModels = await proxyService.fetchLocalModels(forceRefresh: true)\n    }\n\n    private func modelsForOAuthProvider(_ provider: LocalAuthProvider) -> [String] {\n        guard let target = builtInProvider(for: provider) else { return [] }\n        var seen: Set<String> = []\n        var ids: [String] = []\n        for model in localModels {\n            if builtInProvider(for: model) == target {\n                if !seen.contains(model.id) {\n                    seen.insert(model.id)\n                    ids.append(model.id)\n                }\n            }\n        }\n        return ids.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n    }\n\n    private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? {\n        switch provider {\n        case .codex: return .openai\n        case .claude: return .anthropic\n        case .gemini: return .gemini\n        case .antigravity: return .antigravity\n        case .qwen: return .qwen\n        }\n    }\n\n    private func builtInProvider(for model: CLIProxyService.LocalModel)\n        -> LocalServerBuiltInProvider?\n    {\n        let hint = model.provider ?? model.source ?? model.owned_by\n        if let hint,\n            let provider = LocalServerBuiltInProvider.allCases.first(where: {\n                $0.matchesOwnedBy(hint)\n            })\n        {\n            return provider\n        }\n        let modelId = model.id\n        if let provider = LocalServerBuiltInProvider.allCases.first(where: {\n            $0.matchesModelId(modelId)\n        }) {\n            return provider\n        }\n        return nil\n    }\n\n    private var gridDivider: some View {\n        Divider()\n    }\n\n    private func getLocalIPAddress() {\n        var address: String?\n        var ifaddr: UnsafeMutablePointer<ifaddrs>?\n        if getifaddrs(&ifaddr) == 0 {\n            var ptr = ifaddr\n            while ptr != nil {\n                defer { ptr = ptr?.pointee.ifa_next }\n\n                let interface = ptr?.pointee\n                let addrFamily = interface?.ifa_addr.pointee.sa_family\n                if addrFamily == UInt8(AF_INET) {\n                    let name = String(cString: (interface?.ifa_name)!)\n                    if name == \"en0\" || name.starts(with: \"en\") {\n                        var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))\n                        getnameinfo(\n                            interface?.ifa_addr, socklen_t((interface?.ifa_addr.pointee.sa_len)!),\n                            &hostname,\n                            socklen_t(hostname.count), nil, socklen_t(0), NI_NUMERICHOST)\n                        address = String(cString: hostname)\n                    }\n                }\n            }\n            freeifaddrs(ifaddr)\n        }\n\n        localIP = address ?? \"127.0.0.1\"\n    }\n\n    private func loadPublicKey() {\n        let key = proxyService.resolvePublicAPIKey()\n        publicAPIKey = key\n        proxyService.updatePublicAPIKey(key)\n    }\n\n    private func regeneratePublicKey() {\n        let generated = proxyService.generatePublicAPIKey(length: minPublicKeyLength)\n        publicAPIKey = generated\n        proxyService.updatePublicAPIKey(generated)\n    }\n\n    private func copyToClipboard(_ text: String) {\n        let pasteboard = NSPasteboard.general\n        pasteboard.clearContents()\n        pasteboard.setString(text, forType: .string)\n    }\n\n    // MARK: - CLI Proxy API Path Helpers\n    private var cliProxyConfigFilePath: String {\n        let appSupport = FileManager.default.urls(\n            for: .applicationSupportDirectory, in: .userDomainMask\n        ).first!\n        let configPath = appSupport.appendingPathComponent(\"CodMate/config.yaml\")\n        return configPath.path\n    }\n\n    private var cliProxyAuthDirPath: String {\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        return home.appendingPathComponent(\".codmate/auth\").path\n    }\n\n    private var cliProxyLogsPath: String {\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        return home.appendingPathComponent(\".codmate/auth/logs\").path\n    }\n\n    private var cliProxyBinarySourceDescription: String {\n        switch proxyService.binarySource {\n        case .none:\n            return \"No binary detected\"\n        case .homebrew:\n            return \"Homebrew installation (managed via brew services)\"\n        case .codmate:\n            return \"CodMate built-in installation\"\n        case .other:\n            return \"Other installation (potential conflicts)\"\n        }\n    }\n\n    private var cliProxyBinarySourceLabel: String {\n        switch proxyService.binarySource {\n        case .none:\n            return \"Not Detected\"\n        case .homebrew:\n            return \"Homebrew\"\n        case .codmate:\n            return \"CodMate\"\n        case .other:\n            return \"Other\"\n        }\n    }\n\n    private var cliProxyBinarySourceColor: Color {\n        switch proxyService.binarySource {\n        case .none:\n            return .secondary\n        case .homebrew:\n            return .green\n        case .codmate:\n            return .blue\n        case .other:\n            return .orange\n        }\n    }\n\n    private var cliProxyActionButtonTitle: String {\n        switch proxyService.binarySource {\n        case .none:\n            return \"Install\"\n        case .homebrew:\n            return proxyService.isBinaryInstalled ? \"Upgrade\" : \"Install\"\n        case .codmate:\n            return proxyService.isBinaryInstalled ? \"Reinstall\" : \"Install\"\n        case .other:\n            return proxyService.isBinaryInstalled ? \"Reinstall\" : \"Install\"\n        }\n    }\n\n    private var cliProxyActionButtonColor: Color {\n        switch proxyService.binarySource {\n        case .none:\n            return .blue\n        case .homebrew:\n            return .green\n        case .codmate:\n            return proxyService.isBinaryInstalled ? .red : .blue\n        case .other:\n            return proxyService.isBinaryInstalled ? .red : .blue\n        }\n    }\n\n    private func revealCLIProxyConfigInFinder() {\n        let url = URL(fileURLWithPath: cliProxyConfigFilePath)\n        NSWorkspace.shared.selectFile(\n            url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)\n    }\n\n    private func revealCLIProxyAuthDirInFinder() {\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        let authPath = home.appendingPathComponent(\".codmate/auth\")\n        NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: authPath.path)\n    }\n\n    private func revealCLIProxyLogsInFinder() {\n        let home = FileManager.default.homeDirectoryForCurrentUser\n        let logsPath = home.appendingPathComponent(\".codmate/auth/logs\")\n        NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: logsPath.path)\n    }\n\n    private func revealCLIProxyBinaryInFinder() {\n        let url = URL(fileURLWithPath: proxyService.binaryFilePath)\n        NSWorkspace.shared.selectFile(\n            url.path, inFileViewerRootedAtPath: url.deletingLastPathComponent().path)\n    }\n\n    // MARK: - Helper Views\n\n    @ViewBuilder\n    private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n        VStack(alignment: .leading, spacing: 8) {\n            content()\n        }\n        .padding(10)\n        .background(Color(nsColor: .separatorColor).opacity(0.35))\n        .cornerRadius(10)\n    }\n\n    // old tab panes removed to keep Providers view pure. Editing happens in a sheet.\n\n    private var bindingsPane: some View {\n        ScrollView {\n            VStack(alignment: .leading, spacing: 16) {\n                GroupBox(\"Codex\") {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            Text(\"Active Provider\").font(.subheadline).fontWeight(.medium)\n                            Picker(\"\", selection: $vm.activeCodexProviderId) {\n                                Text(\"(Built‑in)\").tag(String?.none)\n                                ForEach(vm.providers, id: \\.id) { p in\n                                    Text(p.name?.isEmpty == false ? p.name! : p.id).tag(\n                                        String?(p.id))\n                                }\n                            }\n                            .labelsHidden()\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                            .onChange(of: vm.activeCodexProviderId) { newVal in\n                                Task { await vm.applyActiveCodexProvider(newVal) }\n                            }\n                        }\n                        GridRow {\n                            Text(\"Default Model\").font(.subheadline).fontWeight(.medium)\n                            HStack(spacing: 8) {\n                                TextField(\"gpt-5.2-codex\", text: $vm.defaultCodexModel)\n                                    .onSubmit { Task { await vm.applyDefaultCodexModel() } }\n                                let ids = vm.catalogModelIdsForActiveCodex()\n                                if !ids.isEmpty {\n                                    Menu {\n                                        ForEach(ids, id: \\.self) { mid in\n                                            Button(mid) {\n                                                vm.defaultCodexModel = mid\n                                                Task { await vm.applyDefaultCodexModel() }\n                                            }\n                                        }\n                                    } label: {\n                                        Label(\"From Catalog\", systemImage: \"chevron.down\")\n                                    }\n                                }\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n                    }\n                }\n                GroupBox(\"Claude Code\") {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        GridRow {\n                            Text(\"Active Provider\").font(.subheadline).fontWeight(.medium)\n                            Picker(\"\", selection: $vm.activeClaudeProviderId) {\n                                Text(\"(None)\").tag(String?.none)\n                                ForEach(vm.providers, id: \\.id) { p in\n                                    Text(p.name?.isEmpty == false ? p.name! : p.id).tag(\n                                        String?(p.id))\n                                }\n                            }\n                            .labelsHidden()\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                            .onChange(of: vm.activeClaudeProviderId) { newVal in\n                                Task { await vm.applyActiveClaudeProvider(newVal) }\n                            }\n                        }\n                    }\n                }\n                Text(vm.lastError ?? \"\").foregroundStyle(.red)\n            }\n            .padding(.horizontal, 8)\n            .padding(.vertical, 8)\n        }\n    }\n\n    private func bindingForOAuthAccount(account: CLIProxyService.OAuthAccount) -> Binding<Bool> {\n        Binding(\n            get: { preferences.oauthAccountsEnabled.contains(account.id) },\n            set: { newValue in\n                var enabled = preferences.oauthAccountsEnabled\n                if newValue {\n                    enabled.insert(account.id)\n                } else {\n                    enabled.remove(account.id)\n                }\n                preferences.oauthAccountsEnabled = enabled\n\n                // Note: CLI Proxy API's Management API does not provide an endpoint to enable/disable auth files\n                // The enabled/disabled state is managed locally via oauthAccountsEnabled setting\n                // CLIProxyAPI will load all auth files, and CodMate filters which ones to use based on local settings\n            }\n        )\n    }\n\n    private func bindingForAPIKeyProvider(providerId: String) -> Binding<Bool> {\n        Binding(\n            get: { preferences.apiKeyProvidersEnabled.contains(providerId) },\n            set: { newValue in\n                var enabled = preferences.apiKeyProvidersEnabled\n                if newValue {\n                    enabled.insert(providerId)\n                } else {\n                    enabled.remove(providerId)\n                }\n                preferences.apiKeyProvidersEnabled = enabled\n\n                // Sync third-party providers to CLIProxyAPI config\n                // Only enabled providers will be written to config.yaml\n                Task {\n                    await CLIProxyService.shared.syncThirdPartyProviders(\n                        enabledProviderIds: enabled)\n                    // Refresh local models immediately after config sync\n                    await self.refreshLocalModels()\n                }\n            }\n        )\n    }\n\n}\n\n// MARK: - Editor Sheet (Standard vs Advanced)\nprivate struct ProviderEditorSheet: View {\n    @ObservedObject var vm: ProvidersVM\n    @ObservedObject var preferences: SessionPreferencesStore\n    @Environment(\\.dismiss) private var dismiss\n    @State private var selectedTab: EditorTab = .basic\n    @State private var isTesting: Bool = false\n    @State private var selectedModelRowIDs: Set<UUID> = []\n    @State private var showDeleteSelectedModelsAlert: Bool = false\n    @State private var showAPIKey: Bool = false\n\n    private enum EditorTab { case basic, models }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 16) {\n            HStack(alignment: .firstTextBaseline) {\n                Text(vm.isNewProvider ? \"New Provider\" : \"Edit Provider\").font(.title3).fontWeight(\n                    .semibold)\n                Spacer()\n            }\n            TabView(selection: $selectedTab) {\n                SettingsTabContent { basicTab }\n                    .tabItem { Label(\"Basic\", systemImage: \"slider.horizontal.3\") }\n                    .tag(EditorTab.basic)\n                SettingsTabContent { modelsTab }\n                    .tabItem { Label(\"Models\", systemImage: \"list.bullet.rectangle\") }\n                    .tag(EditorTab.models)\n            }\n            .frame(minHeight: 260)\n            if selectedTab == .basic {\n                // Enhanced test results UI\n                if !vm.testResults.isEmpty {\n                    VStack(alignment: .leading, spacing: 8) {\n                        ForEach(Array(vm.testResults.keys.sorted()), id: \\.self) { endpoint in\n                            if let result = vm.testResults[endpoint] {\n                                TestResultCard(label: endpoint, result: result)\n                            }\n                        }\n                    }\n                    .padding(.top, 8)\n                } else if let text = vm.testResultText, !text.isEmpty {\n                    Text(text)\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                }\n\n                if let error = vm.lastError, !error.isEmpty {\n                    Text(error).foregroundStyle(.red).font(.caption)\n                }\n            }\n            HStack {\n                if selectedTab == .basic {\n                    Button {\n                        if !isTesting {\n                            isTesting = true\n                            Task {\n                                await vm.testEditingFields()\n                                isTesting = false\n                            }\n                        }\n                    } label: {\n                        if isTesting { ProgressView().controlSize(.small) } else { Text(\"Test\") }\n                    }\n                    .buttonStyle(.bordered)\n                    .disabled(isTesting)\n                }\n                Spacer()\n                Button(\"Cancel\") { dismiss() }\n                Button(\"Save\") {\n                    Task {\n                        if await vm.saveEditing(preferences: preferences) {\n                            dismiss()\n                        }\n                    }\n                }\n                .buttonStyle(.borderedProminent)\n                .disabled(!vm.canSave)\n            }\n        }\n        .padding(16)\n        .frame(\n            minWidth: 640,\n            idealWidth: 760,\n            maxWidth: .infinity,\n            minHeight: 360,\n            maxHeight: .infinity,\n            alignment: .topLeading\n        )\n        .onAppear { vm.loadModelRowsFromSelected() }\n    }\n\n    private var basicTab: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                // Icon picker (only for user-created providers, not bundled/preset providers)\n                // Bundled providers (Anthropic, DeepSeek, GLM, K2, MiniMax, OpenAI, OpenRouter) use preset brand icons\n                if vm.isNewProvider\n                    || (!vm.isEditingBundledProvider()\n                        && vm.editingProviderBinding()?.managedByCodMate == true)\n                {\n                    GridRow {\n                        VStack(alignment: .leading, spacing: 4) {\n                            Text(\"Icon\").font(.subheadline).fontWeight(.medium)\n                            Text(\n                                vm.presetIconName != nil\n                                    ? \"Provider configured icon\"\n                                    : \"SF Symbol icon for this provider\"\n                            )\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                        }\n                        iconPickerView\n                    }\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"Name\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Display label shown in lists.\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\"Provider name\", text: vm.binding(for: \\.providerName))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"Codex Base URL\").font(.subheadline).fontWeight(.medium)\n                        Text(\"OpenAI-compatible endpoint\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\"https://api.example.com/v1\", text: vm.binding(for: \\.codexBaseURL))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"Claude Base URL\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Anthropic-compatible endpoint\").font(.caption).foregroundStyle(\n                            .secondary)\n                    }\n                    TextField(\n                        \"https://gateway.example.com/anthropic\",\n                        text: vm.binding(for: \\.claudeBaseURL))\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"API Key Env\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Environment variable name\")\n                            .font(.caption).foregroundStyle(.secondary)\n                    }\n                    HStack(spacing: 8) {\n                        Group {\n                            if showAPIKey {\n                                TextField(\"OPENAI_API_KEY\", text: vm.binding(for: \\.codexEnvKey))\n                            } else {\n                                SecureField(\"OPENAI_API_KEY\", text: vm.binding(for: \\.codexEnvKey))\n                            }\n                        }\n                        .frame(maxWidth: .infinity)\n                        .overlay(alignment: .trailing) {\n                            Button {\n                                showAPIKey.toggle()\n                            } label: {\n                                Image(systemName: showAPIKey ? \"eye.slash.fill\" : \"eye.fill\")\n                                    .foregroundStyle(.secondary)\n                                    .font(.body)\n                            }\n                            .buttonStyle(.plain)\n                            .help(showAPIKey ? \"Hide API key\" : \"Show API key\")\n                            .padding(.horizontal, 8)\n                            .padding(.vertical, 4)\n                            .background {\n                                // 添加背景确保图标在文本之上清晰可见\n                                RoundedRectangle(cornerRadius: 4)\n                                    .fill(Color(nsColor: .controlBackgroundColor))\n                            }\n                            .zIndex(1)\n                        }\n                        if let keyURL = vm.providerKeyURL {\n                            Link(\"Get Key\", destination: keyURL)\n                                .font(.caption)\n                                .help(\"Open provider API key management page\")\n                        }\n                    }\n                }\n                GridRow {\n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(\"Wire API\").font(.subheadline).fontWeight(.medium)\n                        Text(\"Protocol for Codex CLI\")\n                            .font(.caption).foregroundStyle(.secondary)\n                    }\n                    Picker(\"\", selection: vm.binding(for: \\.codexWireAPI)) {\n                        Text(\"Chat\").tag(\"chat\")\n                        Text(\"Responses\").tag(\"responses\")\n                    }\n                    .pickerStyle(.segmented)\n                }\n            }\n            if let docs = vm.providerDocsURL {\n                Link(\"View API documentation\", destination: docs)\n                    .font(.caption)\n            }\n        }\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n    }\n\n    @State private var showIconPicker = false\n\n    private var iconPickerView: some View {\n        HStack(spacing: 8) {\n            // If preset icon exists, show read-only icon display\n            if vm.presetIconName != nil {\n                if let presetIconName = vm.presetIconName,\n                    let nsImage = ProviderIconThemeHelper.menuImage(\n                        named: presetIconName, size: NSSize(width: 18, height: 18))\n                {\n                    Image(nsImage: nsImage)\n                        .resizable()\n                        .interpolation(.high)\n                        .aspectRatio(contentMode: .fit)\n                        .frame(width: 18, height: 18)\n                        .padding(.horizontal, 8)\n                        .padding(.vertical, 4)\n                        .background(Color(nsColor: .controlBackgroundColor))\n                        .cornerRadius(6)\n                        .overlay(\n                            RoundedRectangle(cornerRadius: 6)\n                                .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)\n                        )\n                }\n            } else {\n                // Icon display button (only for custom providers)\n                Button {\n                    showIconPicker = true\n                } label: {\n                    HStack(spacing: 6) {\n                        // Custom SF Symbol icon\n                        if let iconName = vm.customIcon\n                            ?? defaultIconForProviderName(vm.providerName)\n                        {\n                            Image(systemName: iconName)\n                                .font(.system(size: 18))\n                                .frame(width: 18, height: 18)\n                        }\n                        // Fallback: Empty circle\n                        else {\n                            Circle()\n                                .fill(Color.secondary.opacity(0.2))\n                                .frame(width: 18, height: 18)\n                        }\n                        Image(systemName: \"chevron.down\")\n                            .font(.system(size: 10))\n                            .foregroundStyle(.secondary)\n                    }\n                    .padding(.horizontal, 8)\n                    .padding(.vertical, 4)\n                    .background(Color(nsColor: .controlBackgroundColor))\n                    .cornerRadius(6)\n                    .overlay(\n                        RoundedRectangle(cornerRadius: 6)\n                            .stroke(Color(nsColor: .separatorColor), lineWidth: 0.5)\n                    )\n                }\n                .buttonStyle(.plain)\n                .popover(isPresented: $showIconPicker, arrowEdge: .bottom) {\n                    iconPickerPopover\n                }\n            }\n        }\n    }\n\n    private var iconPickerPopover: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Text(\"Select Icon\")\n                .font(.headline)\n                .padding(.top, 16)\n\n            Divider()\n\n            LazyVGrid(\n                columns: Array(repeating: GridItem(.fixed(40), spacing: 8), count: 6), spacing: 8\n            ) {\n                ForEach(sfSymbolsIndices, id: \\.self) { iconName in\n                    Button {\n                        vm.customIcon = iconName\n                        vm.presetIconName = nil  // Clear preset icon when user selects custom SF Symbol\n                        showIconPicker = false\n                    } label: {\n                        Image(systemName: iconName)\n                            .font(.system(size: 24))\n                            .frame(width: 40, height: 40)\n                            .background(Color(nsColor: .controlBackgroundColor))\n                            .cornerRadius(6)\n                    }\n                    .buttonStyle(.plain)\n                }\n            }\n            .frame(height: 280)\n        }\n        .frame(width: 350)\n        .padding(.bottom, 16)\n        .padding(.horizontal, 16)\n    }\n\n    // SF Symbols indices (letter-based icons)\n    private var sfSymbolsIndices: [String] {\n        let letters = \"abcdefghijklmnopqrstuvwxyz\"\n        return letters.map { \"\\($0).circle.fill\" }\n    }\n\n    // Generate default icon from first letter of provider name\n    private func defaultIconForProviderName(_ name: String) -> String? {\n        guard let firstChar = name.lowercased().first, firstChar.isLetter else {\n            return nil\n        }\n        return \"\\(firstChar).circle.fill\"\n    }\n\n    private var modelsTab: some View {\n        VStack(alignment: .leading, spacing: 10) {\n            HStack {\n                Text(\"Models\").font(.subheadline).fontWeight(.medium)\n                Spacer()\n                HStack(spacing: 0) {\n                    Button {\n                        vm.addModelRow()\n                    } label: {\n                        Text(\"+\")\n                            .frame(width: 18, height: 16)\n                    }\n                    .buttonStyle(.bordered)\n\n                    Button {\n                        if !selectedModelRowIDs.isEmpty { showDeleteSelectedModelsAlert = true }\n                    } label: {\n                        Text(\"–\")\n                            .frame(width: 18, height: 16)\n                    }\n                    .buttonStyle(.bordered)\n                    .disabled(selectedModelRowIDs.isEmpty)\n                }\n                .clipShape(RoundedRectangle(cornerRadius: 6))\n            }\n            Table(vm.modelRows, selection: $selectedModelRowIDs) {\n                TableColumn(\"Default\") { row in\n                    Toggle(\n                        \"\",\n                        isOn: Binding(\n                            get: { vm.defaultModelRowID == row.id },\n                            set: { isOn in\n                                vm.setDefaultModelRow(\n                                    rowID: isOn ? row.id : nil, modelId: isOn ? row.modelId : nil)\n                            }\n                        )\n                    )\n                    .labelsHidden()\n                    .controlSize(.small)\n                    .disabled(row.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n                }.width(50)\n\n                TableColumn(\"Model ID\") { row in\n                    if let binding = vm.bindingModelId(for: row.id) {\n                        TextField(\"vendor model id\", text: binding)\n                            .onChange(of: binding.wrappedValue) { newValue in\n                                vm.handleModelIDChange(for: row.id, newValue: newValue)\n                            }\n                    }\n                }.width(min: 120, ideal: 200)\n\n                TableColumn(\"Reasoning\") { row in\n                    if let b = vm.bindingBool(for: row.id, keyPath: \\.reasoning) {\n                        Toggle(\"\", isOn: b).labelsHidden().controlSize(.small)\n                    }\n                }.width(60)\n\n                TableColumn(\"Tool Use\") { row in\n                    if let b = vm.bindingBool(for: row.id, keyPath: \\.toolUse) {\n                        Toggle(\"\", isOn: b).labelsHidden().controlSize(.small)\n                    }\n                }.width(50)\n\n                TableColumn(\"Vision\") { row in\n                    if let b = vm.bindingBool(for: row.id, keyPath: \\.vision) {\n                        Toggle(\"\", isOn: b).labelsHidden().controlSize(.small)\n                    }\n                }.width(50)\n\n                TableColumn(\"Long Ctx\") { row in\n                    if let b = vm.bindingBool(for: row.id, keyPath: \\.longContext) {\n                        Toggle(\"\", isOn: b).labelsHidden().controlSize(.small)\n                    }\n                }.width(60)\n\n            }\n            .environment(\\.defaultMinListRowHeight, 26)\n            .controlSize(.small)\n        }\n        .alert(\"Delete selected models?\", isPresented: $showDeleteSelectedModelsAlert) {\n            Button(\"Delete\", role: .destructive) {\n                for id in selectedModelRowIDs { vm.deleteModelRow(rowKey: id) }\n                selectedModelRowIDs.removeAll()\n            }\n            Button(\"Cancel\", role: .cancel) {}\n        } message: {\n            Text(\"This action cannot be undone.\")\n        }\n    }\n\n}\n\nprivate struct OAuthProviderInfoSheet: View {\n    let provider: LocalAuthProvider\n    let isLoggedIn: Bool\n    let accounts: [CLIProxyService.OAuthAccount]\n    let selectedAccount: CLIProxyService.OAuthAccount\n    let initialModels: [String]\n    let onLogin: () -> Void\n    let onLogout: (CLIProxyService.OAuthAccount) -> Void\n\n    @StateObject private var proxyService = CLIProxyService.shared\n    @State private var models: [String] = []\n    @State private var isRefreshing: Bool = false\n    @State private var accountInfo: AccountInfo?\n    @State private var isHoveringModels: Bool = false\n    @Environment(\\.dismiss) private var dismiss\n\n    struct AccountInfo {\n        let email: String?\n        let planType: String?\n        let planChecked: Bool\n        let accountType: String?\n        let organization: String?\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack(spacing: 10) {\n                LocalAuthProviderIconView(provider: provider, size: 20, cornerRadius: 4)\n                Text(provider.displayName)\n                    .font(.headline)\n                Spacer()\n                if isLoggedIn {\n                    Button {\n                        refreshModels()\n                    } label: {\n                        if isRefreshing {\n                            ProgressView()\n                                .controlSize(.small)\n                        } else {\n                            Image(systemName: \"arrow.clockwise\")\n                                .font(.body)\n                        }\n                    }\n                    .buttonStyle(.plain)\n                    .disabled(isRefreshing)\n                    .help(\"Refresh models\")\n                } else {\n                    Text(\"Not logged in\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                }\n            }\n\n            if isLoggedIn {\n                // Account Status Section\n                if let info = accountInfo {\n                    VStack(alignment: .leading, spacing: 6) {\n                        Text(\"Account Status\")\n                            .font(.subheadline)\n                            .fontWeight(.medium)\n                        VStack(alignment: .leading, spacing: 4) {\n                            if let email = info.email {\n                                HStack(spacing: 4) {\n                                    Text(\"Email:\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                    Text(email)\n                                        .font(.caption)\n                                }\n                            }\n                            if let planType = info.planType, !planType.isEmpty {\n                                HStack(spacing: 4) {\n                                    Text(\"Plan:\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                    Text(planType)\n                                        .font(.caption)\n                                        .fontWeight(.medium)\n                                        .foregroundStyle(.primary)\n                                }\n                            } else {\n                                // Show \"Loading...\" or \"Unknown\" if plan is being fetched\n                                HStack(spacing: 4) {\n                                    Text(\"Plan:\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                    Text(info.planChecked ? \"Unknown\" : \"Checking...\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                        .italic()\n                                }\n                            }\n                            if let accountType = info.accountType {\n                                HStack(spacing: 4) {\n                                    Text(\"Type:\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                    Text(accountType)\n                                        .font(.caption)\n                                }\n                            }\n                            if let org = info.organization {\n                                HStack(spacing: 4) {\n                                    Text(\"Organization:\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                    Text(org)\n                                        .font(.caption)\n                                }\n                            }\n                        }\n                    }\n                    Divider()\n                }\n\n                // Models Section\n                if models.isEmpty {\n                    Text(\"No models detected yet. Make sure CLI Proxy API is running.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                } else {\n                    VStack(alignment: .leading, spacing: 6) {\n                        HStack {\n                            Text(\"Available Models\")\n                                .font(.subheadline)\n                                .fontWeight(.medium)\n                            Spacer()\n                        }\n                        ZStack(alignment: .topTrailing) {\n                            ScrollView {\n                                Text(models.joined(separator: \"\\n\"))\n                                    .font(.system(.caption, design: .monospaced))\n                                    .foregroundStyle(.secondary)\n                                    .textSelection(.enabled)\n                                    .frame(maxWidth: .infinity, alignment: .leading)\n                                    .padding(.trailing, isHoveringModels ? 28 : 0)\n                            }\n                            .frame(maxHeight: 200)\n\n                            if isHoveringModels {\n                                Button {\n                                    copyModelsToClipboard()\n                                } label: {\n                                    Image(systemName: \"doc.on.doc\")\n                                        .font(.system(size: 12))\n                                        .foregroundStyle(.secondary)\n                                }\n                                .buttonStyle(.plain)\n                                .help(\"Copy all models to clipboard\")\n                                .padding(4)\n                            }\n                        }\n                        .onHover { hovering in\n                            isHoveringModels = hovering\n                        }\n                    }\n                }\n            } else {\n                Text(\"Sign in to view available models for this provider.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n            }\n\n            Divider()\n\n            HStack {\n                if isLoggedIn {\n                    Button(\"Sign Out\") {\n                        // Use the selected account instead of always using the first one\n                        onLogout(selectedAccount)\n                    }\n                    .buttonStyle(.bordered)\n                    .focusable(false)\n                } else {\n                    Button(\"Upstream Login\") { onLogin() }\n                        .buttonStyle(.borderedProminent)\n                        .focusable(false)\n                }\n                Spacer()\n                Button(\"Done\") { dismiss() }\n                    .buttonStyle(.plain)\n                    .focusable(false)\n            }\n        }\n        .padding(16)\n        .frame(width: 460)\n        .focusable(false)\n        .onAppear {\n            models = initialModels\n            loadAccountInfo()\n        }\n    }\n\n    private func copyModelsToClipboard() {\n        let modelsText = models.joined(separator: \"\\n\")\n        let pasteboard = NSPasteboard.general\n        pasteboard.clearContents()\n        pasteboard.setString(modelsText, forType: .string)\n    }\n\n    private func refreshModels() {\n        guard !isRefreshing else { return }\n        isRefreshing = true\n        Task {\n            let refreshedModels = await proxyService.fetchLocalModels(forceRefresh: true)\n            await MainActor.run {\n                // Filter models for this provider\n                guard let target = builtInProvider(for: provider) else {\n                    isRefreshing = false\n                    return\n                }\n                var seen: Set<String> = []\n                var ids: [String] = []\n                for model in refreshedModels {\n                    if builtInProvider(for: model) == target {\n                        if !seen.contains(model.id) {\n                            seen.insert(model.id)\n                            ids.append(model.id)\n                        }\n                    }\n                }\n                models = ids.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }\n                // Also refresh account info in case it changed\n                loadAccountInfo()\n                isRefreshing = false\n            }\n        }\n    }\n\n    private func builtInProvider(for provider: LocalAuthProvider) -> LocalServerBuiltInProvider? {\n        switch provider {\n        case .codex: return .openai\n        case .claude: return .anthropic\n        case .gemini: return .gemini\n        case .antigravity: return .antigravity\n        case .qwen: return .qwen\n        }\n    }\n\n    private func builtInProvider(for model: CLIProxyService.LocalModel)\n        -> LocalServerBuiltInProvider?\n    {\n        let ownedBy = (model.owned_by ?? \"\").lowercased()\n        let provider = (model.provider ?? \"\").lowercased()\n        let source = (model.source ?? \"\").lowercased()\n\n        for builtIn in LocalServerBuiltInProvider.allCases {\n            if builtIn.matchesOwnedBy(ownedBy) || builtIn.matchesOwnedBy(provider)\n                || builtIn.matchesOwnedBy(source)\n            {\n                return builtIn\n            }\n        }\n        return nil\n    }\n\n    private func loadAccountInfo() {\n        // Use the selected account instead of always using the first one\n        let account = selectedAccount\n\n        // Try to extract more info from the account file\n        var email: String? = account.email\n        var planType: String?\n        var accountType: String?\n        var organization: String?\n\n        if let data = try? Data(contentsOf: URL(fileURLWithPath: account.filePath)),\n            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n        {\n\n            // Extract email if not already set\n            if email == nil {\n                email =\n                    json[\"email\"] as? String\n                    ?? json[\"user_email\"] as? String\n                    ?? json[\"account\"] as? String\n                    ?? json[\"user\"] as? String\n            }\n\n            // Try to extract plan/subscription info (provider-specific)\n            switch provider {\n            case .claude:\n                // Claude might have plan info in the token or account data\n                if let plan = json[\"plan\"] as? String ?? json[\"plan_type\"] as? String ?? json[\n                    \"subscription\"] as? String\n                {\n                    planType = plan\n                }\n                if let org = json[\"organization\"] as? String ?? json[\"org_id\"] as? String {\n                    organization = org\n                }\n            case .codex:\n                // Codex/OpenAI might have plan info\n                if let plan = json[\"plan\"] as? String ?? json[\"plan_type\"] as? String {\n                    planType = plan\n                }\n                accountType = json[\"account_type\"] as? String\n            case .gemini:\n                // Gemini might have account info\n                if let plan = json[\"plan\"] as? String ?? json[\"tier\"] as? String {\n                    planType = plan\n                }\n            default:\n                break\n            }\n        }\n\n        // Always try to fetch plan type via API (more reliable)\n        accountInfo = AccountInfo(\n            email: email,\n            planType: planType,\n            planChecked: planType != nil,\n            accountType: accountType,\n            organization: organization\n        )\n\n        // Fetch plan type from API in background\n        Task {\n            await fetchPlanTypeFromAPI(account: account, email: email)\n        }\n    }\n\n    private func fetchPlanTypeFromAPI(account: CLIProxyService.OAuthAccount, email: String?) async {\n        // Use CLI Proxy API management endpoint to fetch account info\n        guard let authFileInfo = await proxyService.fetchAuthFileInfo(for: account.filename) else {\n            await MainActor.run {\n                accountInfo = AccountInfo(\n                    email: accountInfo?.email ?? email,\n                    planType: accountInfo?.planType,\n                    planChecked: true,\n                    accountType: accountInfo?.accountType,\n                    organization: accountInfo?.organization\n                )\n            }\n            return\n        }\n\n        await MainActor.run {\n            accountInfo = AccountInfo(\n                email: accountInfo?.email ?? email ?? authFileInfo.email,\n                planType: authFileInfo.consolidatedPlan,\n                planChecked: true,\n                accountType: authFileInfo.consolidatedAccountType,\n                organization: authFileInfo.organization\n            )\n        }\n    }\n}\n\nprivate struct OAuthLoginSheet: View {\n    let provider: LocalAuthProvider\n    let onDone: () -> Void\n    let onCancel: () -> Void\n\n    @StateObject private var proxyService = CLIProxyService.shared\n    @State private var loginState: LoginState = .idle\n    @State private var loginError: String?\n    @State private var loginTask: Task<Void, Never>?\n    @State private var checkAccountTask: Task<Void, Never>?\n    @Environment(\\.dismiss) private var dismiss\n\n    enum LoginState {\n        case idle\n        case loggingIn\n        case needsInput\n        case success\n        case failed\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack(spacing: 10) {\n                LocalAuthProviderIconView(provider: provider, size: 20, cornerRadius: 4)\n                Text(provider.displayName)\n                    .font(.headline)\n                Spacer()\n                statusIndicator\n            }\n\n            statusMessage\n\n            if case .needsInput = loginState {\n                if let prompt = proxyService.loginPrompt {\n                    Text(prompt.message)\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .padding(.top, 4)\n                }\n            }\n\n            if let error = loginError {\n                Text(error)\n                    .font(.caption)\n                    .foregroundStyle(.red)\n                    .padding(.top, 4)\n            }\n\n            Divider()\n\n            HStack {\n                if loginState == .failed {\n                    Button(\"Retry\") {\n                        loginError = nil\n                        startLogin()\n                    }\n                    .buttonStyle(.bordered)\n                    .focusable(false)\n                }\n                Spacer()\n                if loginState == .success {\n                    Button(\"Done\") {\n                        onDone()\n                        dismiss()\n                    }\n                    .buttonStyle(.borderedProminent)\n                    .focusable(false)\n                } else {\n                    Button(\"Cancel Login\") {\n                        onCancel()\n                        dismiss()\n                    }\n                    .buttonStyle(.plain)\n                    .focusable(false)\n                }\n            }\n        }\n        .padding(16)\n        .frame(width: 460)\n        .focusable(false)\n        .onAppear {\n            startLogin()\n            startAccountCheck()\n        }\n        .onDisappear {\n            loginTask?.cancel()\n            checkAccountTask?.cancel()\n        }\n        .onChange(of: proxyService.loginPrompt) { prompt in\n            if prompt != nil && loginState == .loggingIn {\n                loginState = .needsInput\n            }\n        }\n    }\n\n    @ViewBuilder\n    private var statusIndicator: some View {\n        switch loginState {\n        case .idle, .loggingIn:\n            ProgressView()\n                .controlSize(.small)\n        case .needsInput:\n            Image(systemName: \"exclamationmark.circle.fill\")\n                .foregroundStyle(.orange)\n        case .success:\n            Image(systemName: \"checkmark.circle.fill\")\n                .foregroundStyle(.green)\n        case .failed:\n            Image(systemName: \"xmark.circle.fill\")\n                .foregroundStyle(.red)\n        }\n    }\n\n    @ViewBuilder\n    private var statusMessage: some View {\n        switch loginState {\n        case .idle, .loggingIn:\n            Text(\"Logging in to \\(provider.displayName)...\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n        case .needsInput:\n            Text(\"Please complete the login in the browser or provide the required input.\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n        case .success:\n            Text(\"Login successful. Click Done to add this account.\")\n                .font(.caption)\n                .foregroundStyle(.green)\n        case .failed:\n            Text(\"Login failed. You can retry or cancel.\")\n                .font(.caption)\n                .foregroundStyle(.red)\n        }\n    }\n\n    private func startLogin() {\n        guard loginState != .loggingIn else { return }\n        loginState = .loggingIn\n        loginError = nil\n\n        loginTask = Task {\n            do {\n                try await proxyService.login(provider: provider)\n                // Login completed, checkAccountTask will verify success\n            } catch is CancellationError {\n                await MainActor.run {\n                    if loginState == .loggingIn {\n                        loginState = .idle\n                    }\n                }\n            } catch {\n                await MainActor.run {\n                    loginState = .failed\n                    loginError = error.localizedDescription\n                }\n            }\n        }\n    }\n\n    private func startAccountCheck() {\n        checkAccountTask = Task {\n            // Record initial account files before login starts\n            let initialFiles = Set(\n                proxyService.listOAuthAccounts()\n                    .filter { $0.provider == provider }\n                    .map { $0.filename })\n\n            // Wait 1 second to allow hideAuthFiles to execute\n            try? await Task.sleep(nanoseconds: 1_000_000_000)\n\n            while !Task.isCancelled {\n                try? await Task.sleep(nanoseconds: 500_000_000)\n\n                let currentFiles = Set(\n                    proxyService.listOAuthAccounts()\n                        .filter { $0.provider == provider }\n                        .map { $0.filename })\n\n                // Detect new files or account count increase\n                let hasNewFiles = !currentFiles.subtracting(initialFiles).isEmpty\n                let countIncreased = currentFiles.count > initialFiles.count\n\n                if (hasNewFiles || countIncreased) && loginState != .success {\n                    await MainActor.run {\n                        loginState = .success\n                        loginError = nil\n                    }\n                    break\n                }\n            }\n        }\n    }\n}\n\nprivate struct LoginPromptSheet: View {\n    let prompt: CLIProxyService.LoginPrompt\n    let onSubmit: (String) -> Void\n    let onCancel: () -> Void\n    let onStop: () -> Void\n\n    @State private var input: String = \"\"\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            Text(\"\\(prompt.provider.displayName) Login\")\n                .font(.headline)\n            Text(prompt.message)\n                .font(.subheadline)\n                .foregroundColor(.secondary)\n            if prompt.provider == .codex {\n                Text(\n                    \"If the browser already shows “Authentication Successful”, you can keep waiting—no paste needed.\"\n                )\n                .font(.caption)\n                .foregroundColor(.secondary)\n            }\n            TextField(\"Paste here\", text: $input)\n                .textFieldStyle(.roundedBorder)\n                .font(.system(.body, design: .monospaced))\n            HStack {\n                Button(\"Paste\") { pasteFromClipboard() }\n                Spacer()\n                Button(\"Keep Waiting\") { onCancel() }\n                Button(\"Stop Login\") { onStop() }\n                Button(\"Submit\") { onSubmit(input.trimmingCharacters(in: .whitespacesAndNewlines)) }\n                    .buttonStyle(.borderedProminent)\n            }\n        }\n        .padding(16)\n        .frame(width: 420)\n    }\n\n    private func pasteFromClipboard() {\n        let pasteboard = NSPasteboard.general\n        if let value = pasteboard.string(forType: .string) {\n            input = value\n        }\n    }\n}\n\n// MARK: - Testing Support Types\nstruct ProviderTestResult {\n    let success: Bool\n    let layers: [TestLayerResult]\n    let summary: String\n    let detailedLogs: [String]\n}\n\nstruct TestLayerResult: Identifiable {\n    let id = UUID()\n    enum Layer: String {\n        case connectivity = \"Connectivity\"\n        case authentication = \"Authentication\"\n        case apiCall = \"API Call\"\n    }\n\n    let layer: Layer\n    let status: TestStatus\n    let message: String\n    let details: String?\n    let httpCode: Int?\n    let responsePreview: String?\n    let durationMs: Int\n\n    var icon: String {\n        switch status {\n        case .success: return \"checkmark.circle.fill\"\n        case .warning: return \"exclamationmark.triangle.fill\"\n        case .error: return \"xmark.circle.fill\"\n        case .skipped: return \"minus.circle.fill\"\n        }\n    }\n\n    var color: Color {\n        switch status {\n        case .success: return .green\n        case .warning: return .orange\n        case .error: return .red\n        case .skipped: return .secondary\n        }\n    }\n}\n\nenum TestStatus {\n    case success\n    case warning\n    case error\n    case skipped\n}\n\n// MARK: - ViewModel (Codex-first)\n@MainActor\nfinal class ProvidersVM: ObservableObject {\n\n    @Published var providers: [ProvidersRegistryService.Provider] = []\n    @Published var selectedId: String? = nil {\n        didSet {\n            guard selectedId != oldValue else { return }\n            Task { @MainActor in\n                syncEditingFieldsFromSelected()\n                loadModelRowsFromSelected()\n                testResultText = nil\n                testResults = [:]\n            }\n        }\n    }\n\n    // Connection fields\n    @Published var providerName: String = \"\"\n    @Published var customIcon: String? = nil  // SF Symbol name for custom providers\n    @Published var presetIconName: String? = nil  // Preset PNG icon name for bundled providers (e.g., \"DeepSeekIcon\")\n    @Published var codexBaseURL: String = \"\"\n    @Published var codexEnvKey: String = \"OPENAI_API_KEY\"\n    @Published var codexWireAPI: String = \"chat\"\n    @Published var claudeBaseURL: String = \"\"\n    @Published var canSave: Bool = false\n\n    @Published var activeCodexProviderId: String? = nil\n    @Published var defaultCodexModel: String = \"\"\n    @Published var activeClaudeProviderId: String? = nil\n\n    @Published var lastError: String? = nil\n    @Published var testResultText: String? = nil\n    @Published var testResults: [String: ProviderTestResult] = [:]\n    @Published var isTestingEndpoint: [String: Bool] = [:]\n    @Published var showEditor: Bool = false\n    @Published var isNewProvider: Bool = false\n\n    @Published var providerKeyURL: URL? = nil\n    @Published var providerDocsURL: URL? = nil\n\n    @Published var oauthAccounts: [CLIProxyService.OAuthAccount] = []\n\n    private let registry = ProvidersRegistryService()\n    private let codex = CodexConfigService()\n    @Published var templates: [ProvidersRegistryService.Provider] = []\n\n    func loadAll() async {\n        await registry.migrateFromCodexIfNeeded(codex: codex)\n        await reload()\n        await refreshOAuthAccounts()\n    }\n\n    func loadTemplates() async {\n        let list = await registry.listBundledProviders()\n        // Sorting is handled by sortedTemplates computed property to avoid duplication\n        await MainActor.run { templates = list }\n    }\n\n    func reload() async {\n        // Only show user-added providers in list to avoid confusion\n        let list = await registry.listProviders()\n        providers = list\n        let bindings = await registry.getBindings()\n        activeCodexProviderId =\n            bindings.activeProvider?[ProvidersRegistryService.Consumer.codex.rawValue]\n        defaultCodexModel =\n            bindings.defaultModel?[ProvidersRegistryService.Consumer.codex.rawValue] ?? \"\"\n        activeClaudeProviderId =\n            bindings.activeProvider?[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n\n        // If current selectedId is not in the list anymore, select the first one or clear\n        if let currentId = selectedId, !list.contains(where: { $0.id == currentId }) {\n            selectedId = list.first?.id\n        } else if selectedId == nil {\n            selectedId = list.first?.id\n        }\n\n        syncEditingFieldsFromSelected()\n        loadModelRowsFromSelected()\n    }\n\n    func refreshOAuthAccounts() async {\n        let accounts = CLIProxyService.shared.listOAuthAccounts()\n        await MainActor.run {\n            self.oauthAccounts = accounts.sorted {\n                if $0.provider.displayName != $1.provider.displayName {\n                    return $0.provider.displayName < $1.provider.displayName\n                }\n                return ($0.email ?? \"\") < ($1.email ?? \"\")\n            }\n        }\n    }\n\n    func deleteOAuthAccount(_ account: CLIProxyService.OAuthAccount) async {\n        CLIProxyService.shared.deleteOAuthAccount(account)\n        await refreshOAuthAccounts()\n    }\n\n    private func syncEditingFieldsFromSelected() {\n        guard let sel = selectedId, let provider = providers.first(where: { $0.id == sel }) else {\n            DispatchQueue.main.async {\n                self.providerName = \"\"\n                self.customIcon = nil\n                self.presetIconName = nil\n                self.codexBaseURL = \"\"\n                self.codexEnvKey = \"OPENAI_API_KEY\"\n                self.codexWireAPI = \"chat\"\n                self.claudeBaseURL = \"\"\n                self.defaultModelId = nil\n                self.recomputeCanSave()\n            }\n            return\n        }\n        let name = provider.name ?? \"\"\n        let icon = provider.customIcon\n        let codexConnector = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]\n        let claudeConnector = provider.connectors[\n            ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        let codexBase = codexConnector?.baseURL ?? \"\"\n        let envKey =\n            provider.envKey ?? codexConnector?.envKey ?? claudeConnector?.envKey ?? \"OPENAI_API_KEY\"\n        let wireAPI = normalizedWireAPI(codexConnector?.wireAPI)\n        let claudeBase = claudeConnector?.baseURL ?? \"\"\n        // Check if this provider has a preset icon\n        let presetIcon = ProviderIconResource.iconName(for: provider)\n\n        DispatchQueue.main.async {\n            self.providerName = name\n            self.customIcon = icon\n            self.presetIconName = presetIcon\n            self.codexBaseURL = codexBase\n            self.codexEnvKey = envKey\n            self.codexWireAPI = wireAPI\n            self.claudeBaseURL = claudeBase\n            // For prebuilt-like providers, supply Get Key / Docs links by matching templates by baseURL\n            self.applyTemplateMetadataForCurrent(provider: provider)\n            self.recomputeCanSave()\n        }\n    }\n\n    func editingProviderBinding() -> ProvidersRegistryService.Provider? {\n        guard let sel = selectedId else { return nil }\n        return providers.first(where: { $0.id == sel })\n    }\n\n    /// Check if the currently editing provider is a bundled (preset) provider\n    /// Bundled providers have preset brand icons and should not allow custom icon selection\n    func isEditingBundledProvider() -> Bool {\n        guard let sel = selectedId else { return false }\n        // Check if this provider ID exists in bundled templates\n        return templates.contains(where: { $0.id == sel })\n    }\n\n    // MARK: - Models directory editing\n    struct ModelRow: Identifiable, Hashable {\n        var key: UUID = UUID()\n        var id: UUID { key }\n        var modelId: String\n        var reasoning: Bool\n        var toolUse: Bool\n        var vision: Bool\n        var longContext: Bool\n    }\n    @Published var modelRows: [ModelRow] = []\n    @Published var defaultModelId: String?\n    @Published var defaultModelRowID: UUID? = nil\n\n    func loadModelRowsFromSelected() {\n        // When creating from a template, modelRows are already seeded; avoid clearing.\n        if isNewProvider { return }\n        guard let sel = selectedId, let p = providers.first(where: { $0.id == sel }) else {\n            DispatchQueue.main.async {\n                self.modelRows = []\n            }\n            return\n        }\n        let rows: [ModelRow] = (p.catalog?.models ?? []).map { me in\n            let c = me.caps\n            return ModelRow(\n                modelId: me.vendorModelId,\n                reasoning: c?.reasoning ?? false,\n                toolUse: c?.tool_use ?? false,\n                vision: c?.vision ?? false,\n                longContext: c?.long_context ?? false\n            )\n        }\n\n        let matchingRow = providerDefaultModel(from: p).flatMap { model in\n            rows.first(where: { $0.modelId == model })\n        }\n        let firstNonEmpty = rows.first(where: { !$0.modelId.isEmpty })\n\n        DispatchQueue.main.async {\n            self.modelRows = rows\n            if let match = matchingRow {\n                self.defaultModelRowID = match.id\n                self.defaultModelId = match.modelId\n            } else if let first = firstNonEmpty {\n                self.defaultModelRowID = first.id\n                self.defaultModelId = first.modelId\n            } else {\n                self.defaultModelRowID = nil\n                self.defaultModelId = nil\n            }\n            self.normalizeDefaultSelection()\n        }\n    }\n\n    // MARK: - Bindings for Table cells\n    func indexForRow(_ id: UUID) -> Int? { modelRows.firstIndex(where: { $0.id == id }) }\n\n    func bindingModelId(for id: UUID) -> Binding<String>? {\n        guard let idx = indexForRow(id) else { return nil }\n        return Binding<String>(\n            get: { self.modelRows[idx].modelId },\n            set: { newVal in\n                self.modelRows[idx].modelId = newVal\n                self.handleModelIDChange(for: id, newValue: newVal)\n            }\n        )\n    }\n\n    func bindingBool(for id: UUID, keyPath: WritableKeyPath<ModelRow, Bool>) -> Binding<Bool>? {\n        guard let idx = indexForRow(id) else { return nil }\n        return Binding<Bool>(\n            get: { self.modelRows[idx][keyPath: keyPath] },\n            set: { newVal in self.modelRows[idx][keyPath: keyPath] = newVal }\n        )\n    }\n\n    private func providerDefaultModel(from provider: ProvidersRegistryService.Provider) -> String? {\n        if let recommended = provider.recommended?.defaultModelFor?[\n            ProvidersRegistryService.Consumer.codex.rawValue], !recommended.isEmpty\n        {\n            return recommended\n        }\n        if let alias = provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?\n            .modelAliases?[\"default\"], !alias.isEmpty\n        {\n            return alias\n        }\n        if let first = provider.catalog?.models?.first?.vendorModelId {\n            return first\n        }\n        return nil\n    }\n\n    func setDefaultModelRow(rowID: UUID?, modelId: String?) {\n        defaultModelRowID = rowID\n        let trimmed = modelId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n        defaultModelId = trimmed.isEmpty ? nil : trimmed\n        normalizeDefaultSelection()\n    }\n\n    func handleModelIDChange(for rowID: UUID, newValue: String) {\n        guard defaultModelRowID == rowID else { return }\n        let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)\n        defaultModelId = trimmed.isEmpty ? nil : trimmed\n        normalizeDefaultSelection()\n    }\n\n    private func normalizeDefaultSelection() {\n        if modelRows.isEmpty {\n            DispatchQueue.main.async {\n                self.defaultModelRowID = nil\n                self.defaultModelId = nil\n            }\n            return\n        }\n        if let rowID = defaultModelRowID,\n            let current = modelRows.first(where: { $0.id == rowID }),\n            !current.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            DispatchQueue.main.async {\n                self.defaultModelId = current.modelId.trimmingCharacters(\n                    in: .whitespacesAndNewlines)\n            }\n            return\n        }\n        if let defined = defaultModelId,\n            let match = modelRows.first(where: { $0.modelId == defined })\n        {\n            DispatchQueue.main.async {\n                self.defaultModelRowID = match.id\n                self.defaultModelId = match.modelId\n            }\n            return\n        }\n        if let fallback = modelRows.first(where: {\n            !$0.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        }) {\n            DispatchQueue.main.async {\n                self.defaultModelRowID = fallback.id\n                self.defaultModelId = fallback.modelId.trimmingCharacters(\n                    in: .whitespacesAndNewlines)\n            }\n        } else {\n            DispatchQueue.main.async {\n                self.defaultModelRowID = nil\n                self.defaultModelId = nil\n            }\n        }\n    }\n\n    private func resolvedDefaultModel(from models: [ProvidersRegistryService.ModelEntry]) -> String?\n    {\n        let ids = models.map { $0.vendorModelId }\n        if let current = defaultModelId?.trimmingCharacters(in: .whitespacesAndNewlines),\n            !current.isEmpty, ids.contains(current)\n        {\n            return current\n        }\n        return ids.first\n    }\n\n    func addModelRow() {\n        let row = ModelRow(\n            modelId: \"\", reasoning: false, toolUse: false, vision: false, longContext: false)\n        modelRows.append(row)\n        normalizeDefaultSelection()\n    }\n    func deleteModelRow(rowKey: UUID) {\n        modelRows.removeAll { $0.id == rowKey }\n        normalizeDefaultSelection()\n    }\n\n    func binding(for keyPath: ReferenceWritableKeyPath<ProvidersVM, String>) -> Binding<String> {\n        Binding<String>(\n            get: { self[keyPath: keyPath] },\n            set: { newVal in\n                self[keyPath: keyPath] = newVal\n                self.recomputeCanSave()\n                self.testResultText = nil\n            })\n    }\n\n    private func normalizedWireAPI(_ value: String?) -> String {\n        let lowered = value?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? \"\"\n        switch lowered {\n        case \"responses\": return \"responses\"\n        default: return \"chat\"\n        }\n    }\n\n    // Preset helpers removed; providers are now sourced from bundled providers.json\n\n    private func recomputeCanSave() {\n        let codex = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        let claude = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        let env = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines)\n        let newValue = !env.isEmpty && (!codex.isEmpty || !claude.isEmpty)\n        DispatchQueue.main.async {\n            self.canSave = newValue\n        }\n    }\n\n    @discardableResult\n    func saveEditing(preferences: SessionPreferencesStore) async -> Bool {\n        lastError = nil\n        guard let sel = selectedId else {\n            lastError = \"No provider selected\"\n            return false\n        }\n\n        // Handle new provider creation\n        if isNewProvider {\n            return await saveNewProvider(preferences: preferences)\n        }\n\n        guard var p = providers.first(where: { $0.id == sel }) else {\n            lastError = \"Missing provider\"\n            return false\n        }\n        let trimmedName = providerName.trimmingCharacters(in: .whitespacesAndNewlines)\n        p.name = trimmedName.isEmpty ? nil : trimmedName\n        // Save customIcon (only for user-created providers)\n        if p.managedByCodMate {\n            p.customIcon = customIcon\n        }\n        var conn =\n            p.connectors[ProvidersRegistryService.Consumer.codex.rawValue]\n            ?? .init(\n                baseURL: nil, wireAPI: nil, envKey: nil, queryParams: nil, httpHeaders: nil,\n                envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil,\n                streamIdleTimeoutMs: nil, modelAliases: nil)\n        let trimmedCodexBase = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        let trimmedEnv = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines)\n        let normalizedWire = normalizedWireAPI(codexWireAPI)\n        conn.baseURL = trimmedCodexBase.isEmpty ? nil : trimmedCodexBase\n        // Use provider-level envKey; avoid duplicating at connector level\n        p.envKey = trimmedEnv.isEmpty ? nil : trimmedEnv\n        conn.envKey = nil\n        conn.wireAPI = normalizedWire\n        p.connectors[ProvidersRegistryService.Consumer.codex.rawValue] = conn\n        var cconn =\n            p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n            ?? .init(\n                baseURL: nil, wireAPI: nil, envKey: nil, queryParams: nil, httpHeaders: nil,\n                envHttpHeaders: nil, requestMaxRetries: nil, streamMaxRetries: nil,\n                streamIdleTimeoutMs: nil, modelAliases: nil)\n        let trimmedClaudeBase = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        cconn.baseURL = trimmedClaudeBase.isEmpty ? nil : trimmedClaudeBase\n        cconn.envKey = nil\n        p.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = cconn\n        let cleanedModels: [ProvidersRegistryService.ModelEntry] = modelRows.compactMap { r in\n            let trimmed = r.modelId.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed.isEmpty { return nil }\n            let caps = ProvidersRegistryService.ModelCaps(\n                reasoning: r.reasoning, tool_use: r.toolUse, vision: r.vision,\n                long_context: r.longContext,\n                code_tuned: nil, tps_hint: nil, max_output_tokens: nil\n            )\n            return ProvidersRegistryService.ModelEntry(\n                vendorModelId: trimmed, caps: caps, aliases: nil)\n        }\n        p.catalog =\n            cleanedModels.isEmpty ? nil : ProvidersRegistryService.Catalog(models: cleanedModels)\n        normalizeDefaultSelection()\n        let defaultModel = resolvedDefaultModel(from: cleanedModels)\n        defaultModelId = defaultModel\n        var updatedRecommended: ProvidersRegistryService.Recommended?\n        if var recommended = p.recommended {\n            var defaults = recommended.defaultModelFor ?? [:]\n            let codexKey = ProvidersRegistryService.Consumer.codex.rawValue\n            let claudeKey = ProvidersRegistryService.Consumer.claudeCode.rawValue\n            if let defaultModel {\n                defaults[codexKey] = defaultModel\n                defaults[claudeKey] = defaultModel\n            } else {\n                defaults.removeValue(forKey: codexKey)\n                defaults.removeValue(forKey: claudeKey)\n            }\n            recommended.defaultModelFor = defaults.isEmpty ? nil : defaults\n            updatedRecommended = recommended.defaultModelFor == nil ? nil : recommended\n        } else if let defaultModel {\n            updatedRecommended = ProvidersRegistryService.Recommended(defaultModelFor: [\n                ProvidersRegistryService.Consumer.codex.rawValue: defaultModel,\n                ProvidersRegistryService.Consumer.claudeCode.rawValue: defaultModel,\n            ])\n        }\n        p.recommended = updatedRecommended\n        do {\n            try await registry.upsertProvider(p)\n            if activeCodexProviderId == p.id {\n                try await registry.setDefaultModel(.codex, modelId: defaultModel)\n                do {\n                    try await codex.setTopLevelString(\"model\", value: defaultModel)\n                } catch {\n                    lastError = \"Failed to write model to Codex config\"\n                }\n            }\n            if activeClaudeProviderId == p.id {\n                try await registry.setDefaultModel(.claudeCode, modelId: defaultModel)\n            }\n            await syncActiveCodexProviderIfNeeded(with: p)\n\n            // Sync to config.yaml if this API Key provider is enabled\n            if preferences.apiKeyProvidersEnabled.contains(p.id) {\n                await CLIProxyService.shared.syncThirdPartyProviders(\n                    enabledProviderIds: preferences.apiKeyProvidersEnabled)\n            }\n\n            await reload()\n            return true\n        } catch {\n            lastError = \"Save failed: \\(error.localizedDescription)\"\n            return false\n        }\n    }\n\n    private func saveNewProvider(preferences: SessionPreferencesStore) async -> Bool {\n        let trimmedName = providerName.trimmingCharacters(in: .whitespacesAndNewlines)\n        let list = await registry.listAllProviders()\n        let baseSlug = slugify(trimmedName.isEmpty ? \"provider\" : trimmedName)\n        var candidate = baseSlug\n        var n = 2\n        while list.contains(where: { $0.id == candidate }) {\n            candidate = \"\\(baseSlug)-\\(n)\"\n            n += 1\n        }\n\n        var connectors: [String: ProvidersRegistryService.Connector] = [:]\n        let trimmedCodexBase = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        let trimmedEnv = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines)\n        let normalizedWire = normalizedWireAPI(codexWireAPI)\n\n        if !trimmedCodexBase.isEmpty || !trimmedEnv.isEmpty {\n            connectors[ProvidersRegistryService.Consumer.codex.rawValue] = .init(\n                baseURL: trimmedCodexBase.isEmpty ? nil : trimmedCodexBase,\n                wireAPI: normalizedWire,\n                envKey: nil,\n                queryParams: nil, httpHeaders: nil, envHttpHeaders: nil,\n                requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n                modelAliases: nil\n            )\n        }\n\n        let trimmedClaudeBase = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        if !trimmedClaudeBase.isEmpty || !trimmedEnv.isEmpty {\n            let cconn = ProvidersRegistryService.Connector(\n                baseURL: trimmedClaudeBase.isEmpty ? nil : trimmedClaudeBase,\n                wireAPI: nil,\n                envKey: nil,\n                queryParams: nil, httpHeaders: nil, envHttpHeaders: nil,\n                requestMaxRetries: nil, streamMaxRetries: nil, streamIdleTimeoutMs: nil,\n                modelAliases: nil\n            )\n            connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue] = cconn\n        }\n\n        let cleanedModels: [ProvidersRegistryService.ModelEntry] = modelRows.compactMap { r in\n            let trimmed = r.modelId.trimmingCharacters(in: .whitespacesAndNewlines)\n            if trimmed.isEmpty { return nil }\n            let caps = ProvidersRegistryService.ModelCaps(\n                reasoning: r.reasoning, tool_use: r.toolUse, vision: r.vision,\n                long_context: r.longContext,\n                code_tuned: nil, tps_hint: nil, max_output_tokens: nil\n            )\n            return ProvidersRegistryService.ModelEntry(\n                vendorModelId: trimmed, caps: caps, aliases: nil)\n        }\n\n        let catalog =\n            cleanedModels.isEmpty ? nil : ProvidersRegistryService.Catalog(models: cleanedModels)\n        normalizeDefaultSelection()\n        let defaultModel = resolvedDefaultModel(from: cleanedModels)\n        defaultModelId = defaultModel\n        var recommended: ProvidersRegistryService.Recommended?\n        if let defaultModel {\n            recommended = ProvidersRegistryService.Recommended(defaultModelFor: [\n                ProvidersRegistryService.Consumer.codex.rawValue: defaultModel,\n                ProvidersRegistryService.Consumer.claudeCode.rawValue: defaultModel,\n            ])\n        }\n\n        var provider = ProvidersRegistryService.Provider(\n            id: candidate,\n            name: trimmedName.isEmpty ? nil : trimmedName,\n            class: \"openai-compatible\",\n            managedByCodMate: true,\n            envKey: trimmedEnv.isEmpty ? nil : trimmedEnv,\n            connectors: connectors,\n            catalog: catalog,\n            recommended: recommended,\n            customIcon: customIcon  // User-created providers can have custom icons\n        )\n        // Clear connector-level envKey to avoid duplication; prefer provider-level envKey\n        for key in [\n            ProvidersRegistryService.Consumer.codex.rawValue,\n            ProvidersRegistryService.Consumer.claudeCode.rawValue,\n        ] {\n            if var c = provider.connectors[key] {\n                c.envKey = nil\n                provider.connectors[key] = c\n            }\n        }\n\n        do {\n            try await registry.upsertProvider(provider)\n            await syncActiveCodexProviderIfNeeded(with: provider)\n\n            // Sync to config.yaml if this API Key provider is enabled\n            if preferences.apiKeyProvidersEnabled.contains(provider.id) {\n                await CLIProxyService.shared.syncThirdPartyProviders(\n                    enabledProviderIds: preferences.apiKeyProvidersEnabled)\n            }\n\n            isNewProvider = false\n            await reload()\n            selectedId = candidate\n            return true\n        } catch {\n            lastError = \"Save failed: \\(error.localizedDescription)\"\n            return false\n        }\n    }\n\n    // MARK: - Test editing fields (before save)\n    func testEditingFields() async {\n        lastError = nil\n        testResultText = nil\n        testResults = [:]\n\n        let providerName = providerName.isEmpty ? \"Provider\" : providerName\n        let taskToken = StatusBarLogStore.shared.beginTask(\n            \"Testing \\(providerName) configuration...\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        let codexURL = codexBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        let claudeURL = claudeBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n\n        guard !codexURL.isEmpty || !claudeURL.isEmpty else {\n            testResultText = \"No endpoints configured\"\n            StatusBarLogStore.shared.endTask(\n                taskToken,\n                message: \"Test skipped: No URLs configured\",\n                level: .warning,\n                source: \"Provider Test\"\n            )\n            return\n        }\n\n        StatusBarLogStore.shared.post(\n            \"Starting layered connectivity test for \\(providerName)\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        // Test each configured endpoint\n        var results: [(String, ProviderTestResult)] = []\n\n        if !codexURL.isEmpty {\n            await MainActor.run { isTestingEndpoint[\"codex\"] = true }\n            let result = await testEndpointLayered(\n                label: \"Codex\",\n                urlString: codexURL,\n                consumer: .codex,\n                providerName: providerName\n            )\n            results.append((\"Codex\", result))\n            await MainActor.run { isTestingEndpoint[\"codex\"] = false }\n        }\n\n        if !claudeURL.isEmpty {\n            await MainActor.run { isTestingEndpoint[\"claude\"] = true }\n            let result = await testEndpointLayered(\n                label: \"Claude\",\n                urlString: claudeURL,\n                consumer: .claudeCode,\n                providerName: providerName\n            )\n            results.append((\"Claude\", result))\n            await MainActor.run { isTestingEndpoint[\"claude\"] = false }\n        }\n\n        // Store results\n        for (label, result) in results {\n            testResults[label] = result\n        }\n\n        // Generate summary\n        let allSuccess = results.allSatisfy { $0.1.success }\n        let summaryLines = results.map { \"\\($0.0): \\($0.1.summary)\" }\n        testResultText = summaryLines.joined(separator: \"\\n\")\n\n        StatusBarLogStore.shared.endTask(\n            taskToken,\n            message: allSuccess ? \"All tests passed\" : \"Some tests encountered issues\",\n            level: allSuccess ? .success : .warning,\n            source: \"Provider Test\"\n        )\n    }\n\n    // Catalog helpers\n    func catalogModelIdsForActiveCodex() -> [String] {\n        let ap = activeCodexProviderId\n        guard let id = ap, let p = providers.first(where: { $0.id == id }) else { return [] }\n        return (p.catalog?.models ?? []).map { $0.vendorModelId }\n    }\n\n    func setActiveCodexProvider(_ id: String?) async {\n        do { try await registry.setActiveProvider(.codex, providerId: id) } catch {\n            lastError = \"Failed to set active: \\(error.localizedDescription)\"\n        }\n        await reload()\n    }\n\n    func applyActiveCodexProvider(_ id: String?) async {\n        do {\n            try await registry.setActiveProvider(.codex, providerId: id)\n            if let id, let provider = providers.first(where: { $0.id == id }) {\n                try await codex.applyProviderFromRegistry(provider)\n            } else {\n                try await codex.applyProviderFromRegistry(nil)\n            }\n        } catch {\n            lastError = \"Failed to apply active provider to Codex\"\n        }\n        await reload()\n    }\n\n    func applyActiveClaudeProvider(_ id: String?) async {\n        do {\n            try await registry.setActiveProvider(.claudeCode, providerId: id)\n        } catch {\n            lastError = \"Failed to apply active provider to Claude Code\"\n        }\n        await reload()\n    }\n\n    func applyDefaultCodexModel() async {\n        do {\n            try await registry.setDefaultModel(\n                .codex, modelId: defaultCodexModel.isEmpty ? nil : defaultCodexModel)\n            try await codex.setTopLevelString(\n                \"model\", value: defaultCodexModel.isEmpty ? nil : defaultCodexModel)\n        } catch { lastError = \"Failed to apply default model to Codex\" }\n        await reload()\n    }\n\n    func delete(id: String, preferences: SessionPreferencesStore) async {\n        do {\n            try await registry.deleteProvider(id: id)\n            if activeCodexProviderId == id {\n                try await registry.setActiveProvider(.codex, providerId: nil)\n                try await registry.setDefaultModel(.codex, modelId: nil)\n                await syncActiveCodexProviderIfNeeded(with: nil)\n            }\n\n            // Remove from enabled set and sync to config.yaml\n            var enabled = preferences.apiKeyProvidersEnabled\n            if enabled.remove(id) != nil {\n                preferences.apiKeyProvidersEnabled = enabled\n                await CLIProxyService.shared.syncThirdPartyProviders(enabledProviderIds: enabled)\n            }\n        } catch {\n            lastError = \"Delete failed: \\(error.localizedDescription)\"\n        }\n        await reload()\n    }\n\n    func addOther() { startNewProvider() }\n\n    // Randomly select an icon from available SF Symbols\n    private func randomIcon() -> String {\n        let letters = \"abcdefghijklmnopqrstuvwxyz\"\n        let icons = letters.map { \"\\($0).circle.fill\" }\n        return icons.randomElement() ?? icons[0]\n    }\n\n    func startNewProvider() {\n        isNewProvider = true\n        selectedId = \"new-provider-temp\"\n        // Empty for custom provider\n        providerName = \"\"\n        presetIconName = nil\n        customIcon = randomIcon()  // Randomly select an icon on initialization\n        codexBaseURL = \"\"\n        codexEnvKey = \"OPENAI_API_KEY\"\n        codexWireAPI = \"chat\"\n        claudeBaseURL = \"\"\n\n        modelRows = []\n        defaultModelId = nil\n        defaultModelRowID = nil\n        testResultText = nil\n        lastError = nil\n        recomputeCanSave()\n        showEditor = true\n    }\n\n    func startFromTemplate(_ t: ProvidersRegistryService.Provider) {\n        isNewProvider = true\n        selectedId = \"new-provider-temp\"\n        providerName = t.name ?? t.id\n        // Use the same icon matching logic as menu items\n        if let presetIcon = ProviderIconResource.iconName(for: t) {\n            // Preset provider with Assets.xcassets icon\n            presetIconName = presetIcon\n            customIcon = nil\n        } else {\n            // Custom provider, use random SF Symbol\n            presetIconName = nil\n            customIcon = randomIcon()\n        }\n        let codexConnector = t.connectors[ProvidersRegistryService.Consumer.codex.rawValue]\n        let claudeConnector = t.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]\n        codexBaseURL = codexConnector?.baseURL ?? \"\"\n        codexWireAPI = normalizedWireAPI(codexConnector?.wireAPI)\n        claudeBaseURL = claudeConnector?.baseURL ?? \"\"\n        codexEnvKey = t.envKey ?? \"OPENAI_API_KEY\"\n        // Seed catalog into rows\n        modelRows = (t.catalog?.models ?? []).map { me in\n            let c = me.caps\n            return ModelRow(\n                modelId: me.vendorModelId,\n                reasoning: c?.reasoning ?? false,\n                toolUse: c?.tool_use ?? false,\n                vision: c?.vision ?? false,\n                longContext: c?.long_context ?? false\n            )\n        }\n        if let def = providerDefaultModel(from: t),\n            let match = modelRows.first(where: { $0.modelId == def })\n        {\n            defaultModelRowID = match.id\n            defaultModelId = match.modelId\n        } else {\n            defaultModelRowID = modelRows.first?.id\n            defaultModelId = modelRows.first?.modelId\n        }\n        testResultText = nil\n        lastError = nil\n        // Provide helpful links on template\n        applyTemplateMetadataFor(template: t)\n        recomputeCanSave()\n        showEditor = true\n    }\n\n    private func applyTemplateMetadataFor(template: ProvidersRegistryService.Provider) {\n        let keyURL: URL? = if let s = template.keyURL, let url = URL(string: s) { url } else { nil }\n        let docsURL: URL? =\n            if let s = template.docsURL, let url = URL(string: s) { url } else { nil }\n\n        DispatchQueue.main.async {\n            self.providerKeyURL = keyURL\n            self.providerDocsURL = docsURL\n        }\n    }\n\n    private func applyTemplateMetadataForCurrent(provider: ProvidersRegistryService.Provider) {\n        // Match by baseURL to a bundled template to surface links\n        let codexBase =\n            provider.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL?\n            .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n        let claudeBase =\n            provider.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL?\n            .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n        if let t = templates.first(where: {\n            ($0.connectors[ProvidersRegistryService.Consumer.codex.rawValue]?.baseURL?\n                .trimmingCharacters(\n                    in: .whitespacesAndNewlines) ?? \"\") == codexBase\n                || ($0.connectors[ProvidersRegistryService.Consumer.claudeCode.rawValue]?.baseURL?\n                    .trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\") == claudeBase\n        }) {\n            applyTemplateMetadataFor(template: t)\n        } else {\n            DispatchQueue.main.async {\n                self.providerKeyURL = nil\n                self.providerDocsURL = nil\n            }\n        }\n    }\n\n    private func slugify(_ s: String) -> String {\n        let lower = s.lowercased()\n        let mapped = lower.map { (c: Character) -> Character in (c.isLetter || c.isNumber) ? c : \"-\"\n        }\n        var out: [Character] = []\n        var lastDash = false\n        for ch in mapped {\n            if ch == \"-\" {\n                if !lastDash {\n                    out.append(ch)\n                    lastDash = true\n                }\n            } else {\n                out.append(ch)\n                lastDash = false\n            }\n        }\n        while out.first == \"-\" { out.removeFirst() }\n        while out.last == \"-\" { out.removeLast() }\n        return out.isEmpty ? \"provider\" : String(out)\n    }\n\n    private func syncActiveCodexProviderIfNeeded(with provider: ProvidersRegistryService.Provider?)\n        async\n    {\n        let targetId = provider?.id\n        if targetId == activeCodexProviderId || (provider == nil && activeCodexProviderId != nil) {\n            do {\n                try await codex.applyProviderFromRegistry(provider)\n            } catch {\n                await MainActor.run { self.lastError = \"Failed to sync provider to Codex config\" }\n            }\n        }\n    }\n\n    private struct EndpointCheck {\n        let message: String\n        let ok: Bool\n        let statusCode: Int\n    }\n\n    private enum APIKeySource {\n        case direct\n        case environment\n        case none\n    }\n\n    private func resolveAPIKey() -> (value: String?, source: APIKeySource) {\n        let trimmed = codexEnvKey.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return (nil, .none) }\n\n        // Try as environment variable first\n        if let envValue = ProcessInfo.processInfo.environment[trimmed], !envValue.isEmpty {\n            return (envValue, .environment)\n        }\n\n        // Check if it's a direct token\n        // OpenAI: sk-... (but not Anthropic)\n        if trimmed.hasPrefix(\"sk-\") && !trimmed.hasPrefix(\"sk-ant-\") {\n            return (trimmed, .direct)\n        }\n\n        // Anthropic: sk-ant-...\n        if trimmed.hasPrefix(\"sk-ant-\") {\n            return (trimmed, .direct)\n        }\n\n        // JWT-style: eyJ...\n        if trimmed.hasPrefix(\"eyJ\") {\n            return (trimmed, .direct)\n        }\n\n        // Generic token with dots (JWT pattern)\n        if trimmed.contains(\".\") && trimmed.count >= 30 {\n            return (trimmed, .direct)\n        }\n\n        // Alphanumeric keys (length validation)\n        let isAlphanumeric = trimmed.allSatisfy {\n            $0.isLetter || $0.isNumber || $0 == \"-\" || $0 == \"_\"\n        }\n        if isAlphanumeric && trimmed.count >= 20 {\n            return (trimmed, .direct)\n        }\n\n        return (nil, .none)\n    }\n\n    // MARK: - Layered Testing Implementation\n\n    private func testEndpointLayered(\n        label: String,\n        urlString: String,\n        consumer: ProvidersRegistryService.Consumer,\n        providerName: String\n    ) async -> ProviderTestResult {\n        var layers: [TestLayerResult] = []\n\n        // Resolve configuration\n        guard let baseURL = URL(string: urlString) else {\n            let result = TestLayerResult(\n                layer: .connectivity,\n                status: .error,\n                message: \"Invalid URL format\",\n                details: \"The base URL '\\(urlString)' is not a valid URL\",\n                httpCode: nil,\n                responsePreview: nil,\n                durationMs: 0\n            )\n            return ProviderTestResult(\n                success: false,\n                layers: [result],\n                summary: \"Invalid URL\",\n                detailedLogs: [\"Invalid URL format: \\(urlString)\"]\n            )\n        }\n\n        let keyInfo = resolveAPIKey()\n        let provider = editingProviderBinding()\n        let connector = provider?.connectors[consumer.rawValue]\n        let providerClass = (provider?.class ?? \"openai-compatible\").lowercased()\n        let wireAPI = normalizedWireAPI(connector?.wireAPI)\n\n        StatusBarLogStore.shared.post(\n            \"\\(label): Configuration - Class: \\(providerClass), Wire: \\(wireAPI), Auth: \\(keyInfo.value != nil ? \"present\" : \"missing\")\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        // Layer 1: Basic Connectivity\n        StatusBarLogStore.shared.post(\n            \"\\(label): [L1] Testing network connectivity...\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        let connectivityResult = await testConnectivity(\n            label: label,\n            baseURL: baseURL,\n            apiKey: keyInfo.value\n        )\n        layers.append(connectivityResult)\n\n        guard connectivityResult.status != .error else {\n            return ProviderTestResult(\n                success: false,\n                layers: layers,\n                summary: \"Network connectivity failed\",\n                detailedLogs: [connectivityResult.message]\n            )\n        }\n\n        // Layer 2: Authentication & Endpoint Discovery\n        StatusBarLogStore.shared.post(\n            \"\\(label): [L2] Testing authentication and endpoints...\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        let authResult = await testAuthentication(\n            label: label,\n            baseURL: baseURL,\n            providerClass: providerClass,\n            wireAPI: wireAPI,\n            apiKey: keyInfo.value\n        )\n        layers.append(authResult)\n\n        // Determine overall success\n        let hasErrors = layers.contains { $0.status == .error }\n        let allSuccess = layers.allSatisfy { $0.status == .success }\n        let hasWarnings = layers.contains { $0.status == .warning }\n\n        let summary: String\n        if allSuccess {\n            summary = \"All checks passed\"\n        } else if hasErrors {\n            summary = \"Failed - see details\"\n        } else if hasWarnings {\n            summary = \"Reachable with warnings\"\n        } else {\n            summary = \"Partial success\"\n        }\n\n        return ProviderTestResult(\n            success: !hasErrors,\n            layers: layers,\n            summary: summary,\n            detailedLogs: layers.map { \"\\($0.layer.rawValue): \\($0.message)\" }\n        )\n    }\n\n    private func testConnectivity(\n        label: String,\n        baseURL: URL,\n        apiKey: String?\n    ) async -> TestLayerResult {\n        let startTime = Date()\n\n        var request = URLRequest(url: baseURL, timeoutInterval: 5)\n        request.httpMethod = \"HEAD\"\n\n        if let key = apiKey {\n            request.setValue(\"Bearer \\(key)\", forHTTPHeaderField: \"Authorization\")\n        }\n\n        do {\n            let (_, response) = try await URLSession.shared.data(for: request)\n            let duration = Int(Date().timeIntervalSince(startTime) * 1000)\n            let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1\n\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L1] Connectivity OK (HTTP \\(statusCode), \\(duration)ms)\",\n                level: .success,\n                source: \"Provider Test\"\n            )\n\n            return TestLayerResult(\n                layer: .connectivity,\n                status: .success,\n                message: \"Server reachable\",\n                details: \"Base URL responds to requests\",\n                httpCode: statusCode,\n                responsePreview: nil,\n                durationMs: duration\n            )\n        } catch {\n            let duration = Int(Date().timeIntervalSince(startTime) * 1000)\n\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L1] Connectivity failed: \\(error.localizedDescription)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n\n            return TestLayerResult(\n                layer: .connectivity,\n                status: .error,\n                message: \"Cannot connect to server\",\n                details: error.localizedDescription,\n                httpCode: -1,\n                responsePreview: nil,\n                durationMs: duration\n            )\n        }\n    }\n\n    private func testAuthentication(\n        label: String,\n        baseURL: URL,\n        providerClass: String,\n        wireAPI: String,\n        apiKey: String?\n    ) async -> TestLayerResult {\n        let startTime = Date()\n\n        // Choose appropriate endpoint based on provider class\n        let testPath: String\n        let method: String\n\n        if providerClass == \"anthropic\" {\n            testPath = \"messages\"\n            method = \"HEAD\"  // Minimal check\n        } else {\n            testPath = \"models\"\n            method = \"GET\"\n        }\n\n        // Build URL with proper versioning\n        let testURL: URL\n        if providerClass == \"anthropic\" {\n            testURL = anthropicEndpoint(baseURL: baseURL.absoluteString, path: testPath)\n        } else {\n            testURL = openAIEndpoint(baseURL: baseURL.absoluteString, path: testPath)\n        }\n\n        var request = URLRequest(url: testURL, timeoutInterval: 8)\n        request.httpMethod = method\n\n        if providerClass == \"anthropic\" {\n            request.setValue(\"2023-06-01\", forHTTPHeaderField: \"anthropic-version\")\n        }\n\n        if let key = apiKey {\n            request.setValue(\"Bearer \\(key)\", forHTTPHeaderField: \"Authorization\")\n        }\n\n        do {\n            let (data, response) = try await URLSession.shared.data(for: request)\n            let duration = Int(Date().timeIntervalSince(startTime) * 1000)\n            let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1\n            let body = String(data: data, encoding: .utf8)\n\n            let result = evaluateAuthResponse(\n                label: label,\n                statusCode: statusCode,\n                responseBody: body,\n                providerClass: providerClass,\n                method: method,\n                apiKeyPresent: apiKey != nil,\n                durationMs: duration\n            )\n\n            return result\n        } catch {\n            let duration = Int(Date().timeIntervalSince(startTime) * 1000)\n\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] Request failed: \\(error.localizedDescription)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n\n            return TestLayerResult(\n                layer: .authentication,\n                status: .error,\n                message: \"Request failed\",\n                details: error.localizedDescription,\n                httpCode: -1,\n                responsePreview: nil,\n                durationMs: duration\n            )\n        }\n    }\n\n    private func evaluateAuthResponse(\n        label: String,\n        statusCode: Int,\n        responseBody: String?,\n        providerClass: String,\n        method: String,\n        apiKeyPresent: Bool,\n        durationMs: Int\n    ) -> TestLayerResult {\n        var status: TestStatus\n        var message: String\n        var details: String?\n\n        switch statusCode {\n        case 200:\n            status = .success\n            message = \"Endpoint accessible and authenticated\"\n            details = \"Successfully connected to \\(providerClass) API\"\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ✓ Authentication successful (HTTP 200, \\(durationMs)ms)\",\n                level: .success,\n                source: \"Provider Test\"\n            )\n\n        case 401:\n            status = .error\n            message = \"Authentication failed\"\n            var suggestions = [\"Check API key validity\"]\n\n            if !apiKeyPresent {\n                suggestions.append(\"No API key provided\")\n            }\n\n            if providerClass == \"anthropic\" {\n                suggestions.append(\"Anthropic keys start with 'sk-ant-'\")\n            } else {\n                suggestions.append(\"OpenAI keys start with 'sk-'\")\n            }\n\n            // Try to extract error details\n            if let body = responseBody,\n                let errorInfo = parseErrorResponse(body)\n            {\n                message = \"Authentication failed: \\(errorInfo.message)\"\n                suggestions.append(contentsOf: errorInfo.suggestions)\n            }\n\n            details = suggestions.joined(separator: \"; \")\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ✗ Authentication failed (HTTP 401)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n\n        case 403:\n            status = .error\n            message = \"Access forbidden\"\n            var suggestions = [\"API key lacks required permissions\", \"Check account status\"]\n\n            if let body = responseBody,\n                let errorInfo = parseErrorResponse(body)\n            {\n                message = \"Access forbidden: \\(errorInfo.message)\"\n                suggestions.append(contentsOf: errorInfo.suggestions)\n            }\n\n            details = suggestions.joined(separator: \"; \")\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ✗ Access forbidden (HTTP 403)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n\n        case 404:\n            status = .error\n            message = \"Endpoint not found\"\n            let suggestions = [\n                \"Base URL may be incorrect\",\n                providerClass == \"anthropic\"\n                    ? \"Anthropic uses /v1/messages\" : \"OpenAI uses /v1/chat/completions\",\n            ]\n            details = suggestions.joined(separator: \"; \")\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ✗ Endpoint not found (HTTP 404)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n\n        case 405:\n            // Method not allowed - acceptable for HEAD requests\n            if method == \"HEAD\" {\n                status = .success\n                message = \"Endpoint exists (HEAD not supported)\"\n                details = \"Server doesn't support HEAD but endpoint is valid\"\n                StatusBarLogStore.shared.post(\n                    \"\\(label): [L2] ✓ Endpoint exists (HTTP 405 for HEAD is OK)\",\n                    level: .success,\n                    source: \"Provider Test\"\n                )\n            } else {\n                status = .warning\n                message = \"Method not allowed\"\n                details = \"Endpoint exists but \\(method) not supported\"\n                StatusBarLogStore.shared.post(\n                    \"\\(label): [L2] ⚠ Method not allowed (HTTP 405)\",\n                    level: .warning,\n                    source: \"Provider Test\"\n                )\n            }\n\n        case 400:\n            status = .warning\n            message = \"Bad request format\"\n            details =\n                \"Endpoint reachable but request parameters invalid (acceptable for GET /models)\"\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ⚠ Bad request (HTTP 400, endpoint reachable)\",\n                level: .warning,\n                source: \"Provider Test\"\n            )\n\n        default:\n            status = .error\n            message = \"Unexpected status code: \\(statusCode)\"\n            details = nil\n            StatusBarLogStore.shared.post(\n                \"\\(label): [L2] ✗ Unexpected response (HTTP \\(statusCode))\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n        }\n\n        return TestLayerResult(\n            layer: .authentication,\n            status: status,\n            message: message,\n            details: details,\n            httpCode: statusCode,\n            responsePreview: responseBody?.prefix(200).description,\n            durationMs: durationMs\n        )\n    }\n\n    private func parseErrorResponse(_ body: String) -> (message: String, suggestions: [String])? {\n        guard let data = body.data(using: .utf8),\n            let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any]\n        else {\n            return nil\n        }\n\n        var message = \"\"\n        var suggestions: [String] = []\n\n        // OpenAI format\n        if let error = json[\"error\"] as? [String: Any] {\n            message = error[\"message\"] as? String ?? \"Unknown error\"\n            if let code = error[\"code\"] as? String {\n                switch code {\n                case \"invalid_api_key\":\n                    suggestions.append(\"API key is invalid or malformed\")\n                case \"insufficient_quota\":\n                    suggestions.append(\"Account quota exceeded\")\n                case \"model_not_found\":\n                    suggestions.append(\"Requested model not available\")\n                default:\n                    break\n                }\n            }\n        }\n        // Anthropic format\n        else if json[\"type\"] as? String == \"error\",\n            let error = json[\"error\"] as? [String: Any]\n        {\n            message = error[\"message\"] as? String ?? \"Unknown error\"\n        }\n        // Generic\n        else if let msg = json[\"message\"] as? String ?? json[\"error\"] as? String {\n            message = msg\n        }\n\n        return message.isEmpty ? nil : (message, suggestions)\n    }\n\n    // URL construction helpers\n    private func openAIEndpoint(baseURL: String, path: String) -> URL {\n        var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        if base.hasSuffix(\"/\") { base.removeLast() }\n\n        // Check if base already has version suffix\n        if base.lowercased().hasSuffix(\"/v1\") || hasNumericVersionSuffix(base) {\n            return URL(string: base + \"/\" + path)!\n        } else {\n            return URL(string: base + \"/v1/\" + path)!\n        }\n    }\n\n    private func anthropicEndpoint(baseURL: String, path: String) -> URL {\n        var base = baseURL.trimmingCharacters(in: .whitespacesAndNewlines)\n        if base.hasSuffix(\"/\") { base.removeLast() }\n\n        if base.lowercased().hasSuffix(\"/v1\") {\n            return URL(string: base + \"/\" + path)!\n        } else {\n            return URL(string: base + \"/v1/\" + path)!\n        }\n    }\n\n    private func hasNumericVersionSuffix(_ urlString: String) -> Bool {\n        guard let url = URL(string: urlString) else { return false }\n        let parts = url.path.split(separator: \"/\")\n        guard let last = parts.last else { return false }\n        let s = String(last).lowercased()\n        if s.hasPrefix(\"v\") {\n            let digits = s.dropFirst()\n            return !digits.isEmpty && digits.allSatisfy { $0.isNumber }\n        }\n        return false\n    }\n\n    private func evaluateEndpoint(\n        label: String,\n        urlString: String,\n        providerName: String = \"Provider\",\n        consumer: ProvidersRegistryService.Consumer\n    ) async -> EndpointCheck {\n        guard let baseURL = URL(string: urlString) else {\n            StatusBarLogStore.shared.post(\n                \"\\(label): Invalid URL format: \\(urlString)\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(message: \"\\(label): invalid URL\", ok: false, statusCode: -1)\n        }\n\n        StatusBarLogStore.shared.post(\n            \"\\(label): Base URL configured: \\(baseURL.absoluteString)\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        let keyInfo = resolveAPIKey()\n        if keyInfo.value != nil {\n            let source = keyInfo.source == .environment ? \"environment variable\" : \"direct input\"\n            StatusBarLogStore.shared.post(\n                \"\\(label): API key detected (from \\(source))\",\n                level: .info,\n                source: \"Provider Test\"\n            )\n        } else {\n            StatusBarLogStore.shared.post(\n                \"\\(label): No API key found, testing without authentication\",\n                level: .warning,\n                source: \"Provider Test\"\n            )\n        }\n\n        let lower = baseURL.absoluteString.lowercased()\n        let isAnthropicEndpoint = consumer == .claudeCode || lower.contains(\"anthropic\")\n\n        // Get provider configuration for this consumer\n        let provider = editingProviderBinding()\n        let connector = provider?.connectors[consumer.rawValue]\n        _ = normalizedWireAPI(\n            connector?.wireAPI ?? (consumer == .codex ? codexWireAPI : nil))\n\n        // Test endpoint connectivity: Try GET /models endpoint\n        let modelsURL = baseURL.appendingPathComponent(\"models\")\n        StatusBarLogStore.shared.post(\n            \"\\(label): [GET] Testing /models endpoint: \\(modelsURL.absoluteString)\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        let getModelsResult = await testSingleEndpoint(\n            label: label,\n            url: modelsURL,\n            token: keyInfo.value,\n            isAnthropic: isAnthropicEndpoint,\n            method: \"GET\"\n        )\n\n        // Determine result based on status code\n        if getModelsResult.statusCode == 200 {\n            StatusBarLogStore.shared.post(\n                \"\\(label): Endpoint reachable, API key valid (GET /models: 200)\",\n                level: .success,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): HTTP 200 (reachable)\",\n                ok: true,\n                statusCode: 200\n            )\n        } else if getModelsResult.statusCode == 401 || getModelsResult.statusCode == 403 {\n            StatusBarLogStore.shared.post(\n                \"\\(label): Endpoint reachable but API key invalid (GET /models: \\(getModelsResult.statusCode))\",\n                level: .warning,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): HTTP \\(getModelsResult.statusCode) (API key issue)\",\n                ok: false,\n                statusCode: getModelsResult.statusCode\n            )\n        } else if getModelsResult.statusCode == 400 || getModelsResult.statusCode == 404\n            || getModelsResult.statusCode == 405\n        {\n            // Endpoint exists but doesn't support GET - this is acceptable for many providers\n            StatusBarLogStore.shared.post(\n                \"\\(label): Endpoint reachable (GET /models: \\(getModelsResult.statusCode), endpoint may not support GET)\",\n                level: .info,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): HTTP \\(getModelsResult.statusCode) (reachable)\",\n                ok: true,  // Consider reachable if endpoint responds (even with 400/404/405)\n                statusCode: getModelsResult.statusCode\n            )\n        } else {\n            StatusBarLogStore.shared.post(\n                \"\\(label): Endpoint test failed (GET /models: \\(getModelsResult.statusCode))\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): HTTP \\(getModelsResult.statusCode) (unexpected)\",\n                ok: false,\n                statusCode: getModelsResult.statusCode\n            )\n        }\n    }\n\n    private func testSingleEndpoint(\n        label: String,\n        url: URL,\n        token: String?,\n        isAnthropic: Bool,\n        method: String = \"GET\"\n    ) async -> EndpointCheck {\n        var req = URLRequest(url: url)\n        req.httpMethod = method\n        if isAnthropic {\n            req.setValue(\"2023-06-01\", forHTTPHeaderField: \"anthropic-version\")\n        }\n        if let token {\n            req.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n        }\n\n        let startTime = Date()\n        do {\n            let (_, resp) = try await URLSession.shared.data(for: req)\n            let elapsed = Date().timeIntervalSince(startTime)\n            let code = (resp as? HTTPURLResponse)?.statusCode ?? -1\n\n            // Target is 200 status code\n            let ok = code == 200\n\n            let statusLevel: StatusBarLogLevel = ok ? .success : .warning\n            let statusMsg = ok ? \"HTTP \\(code) (success)\" : \"HTTP \\(code) (expected 200)\"\n            StatusBarLogStore.shared.post(\n                \"\\(label): [\\(method)] \\(url.absoluteString) → \\(statusMsg) [\\(String(format: \"%.2f\", elapsed * 1000))ms]\",\n                level: statusLevel,\n                source: \"Provider Test\"\n            )\n\n            return EndpointCheck(\n                message: \"\\(label): [\\(method)] HTTP \\(code)\", ok: ok, statusCode: code)\n        } catch {\n            let elapsed = Date().timeIntervalSince(startTime)\n            StatusBarLogStore.shared.post(\n                \"\\(label): [\\(method)] \\(url.absoluteString) → Error: \\(error.localizedDescription) [\\(String(format: \"%.2f\", elapsed * 1000))ms]\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): [\\(method)] \\(error.localizedDescription)\", ok: false,\n                statusCode: -1)\n        }\n    }\n\n    private func selectModelForTesting(\n        consumer: ProvidersRegistryService.Consumer,\n        provider: ProvidersRegistryService.Provider?\n    ) -> String? {\n        // Priority 1: Use recommended model for this consumer\n        if let provider = provider,\n            let recommended = provider.recommended?.defaultModelFor?[consumer.rawValue],\n            !recommended.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        {\n            return recommended\n        }\n\n        // Priority 2: Filter models by consumer and select\n        let availableModels = modelRows.filter {\n            !$0.modelId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n        }\n\n        if consumer == .codex {\n            // For Codex: exclude anthropic/ models, prefer others\n            let codexModels = availableModels.filter {\n                !$0.modelId.lowercased().hasPrefix(\"anthropic/\")\n            }\n            if let defaultModel = defaultModelId, !defaultModel.isEmpty,\n                codexModels.contains(where: { $0.modelId == defaultModel })\n            {\n                return defaultModel\n            }\n            return codexModels.first?.modelId ?? availableModels.first?.modelId\n        } else {\n            // For Claude: prefer anthropic/ models\n            let claudeModels = availableModels.filter {\n                $0.modelId.lowercased().hasPrefix(\"anthropic/\")\n            }\n            if let defaultModel = defaultModelId, !defaultModel.isEmpty,\n                claudeModels.contains(where: { $0.modelId == defaultModel })\n                    || availableModels.contains(where: { $0.modelId == defaultModel })\n            {\n                return defaultModel\n            }\n            return claudeModels.first?.modelId ?? availableModels.first?.modelId\n        }\n    }\n\n    private func testModelAPI(\n        label: String,\n        baseURL: URL,\n        model: String,\n        token: String?,\n        isAnthropic: Bool,\n        wireAPI: String\n    ) async -> EndpointCheck {\n        let endpointPath =\n            isAnthropic ? \"messages\" : (wireAPI == \"chat\" ? \"chat/completions\" : \"responses\")\n        let testURL = baseURL.appendingPathComponent(endpointPath)\n\n        StatusBarLogStore.shared.post(\n            \"\\(label): [POST] Testing model API endpoint: \\(testURL.absoluteString)\",\n            level: .info,\n            source: \"Provider Test\"\n        )\n\n        var req = URLRequest(url: testURL)\n        req.httpMethod = \"POST\"\n        req.setValue(\"application/json\", forHTTPHeaderField: \"Content-Type\")\n\n        if isAnthropic {\n            req.setValue(\"2023-06-01\", forHTTPHeaderField: \"anthropic-version\")\n        }\n        if let token {\n            req.setValue(\"Bearer \\(token)\", forHTTPHeaderField: \"Authorization\")\n        }\n\n        // Prepare request body based on API type\n        var body: [String: Any] = [:]\n        if isAnthropic {\n            body = [\n                \"model\": model,\n                \"max_tokens\": 10,\n                \"messages\": [\n                    [\"role\": \"user\", \"content\": \"test\"]\n                ],\n            ]\n        } else if wireAPI == \"chat\" {\n            body = [\n                \"model\": model,\n                \"messages\": [\n                    [\"role\": \"user\", \"content\": \"test\"]\n                ],\n                \"max_tokens\": 10,\n            ]\n        } else {\n            body = [\n                \"model\": model,\n                \"input\": [\n                    [\n                        \"role\": \"user\",\n                        \"content\": [[\"type\": \"text\", \"text\": \"test\"]],\n                    ]\n                ],\n                \"max_output_tokens\": 10,\n            ]\n        }\n\n        let startTime = Date()\n        do {\n            let jsonData = try JSONSerialization.data(withJSONObject: body)\n            req.httpBody = jsonData\n\n            let (_, resp) = try await URLSession.shared.data(for: req)\n            let elapsed = Date().timeIntervalSince(startTime)\n            let code = (resp as? HTTPURLResponse)?.statusCode ?? -1\n\n            // Target is 200 status code\n            let ok = code == 200\n\n            let statusLevel: StatusBarLogLevel = ok ? .success : .warning\n            let statusMsg = ok ? \"HTTP \\(code) (success)\" : \"HTTP \\(code) (expected 200)\"\n            StatusBarLogStore.shared.post(\n                \"\\(label): [POST] Model '\\(model)' API test → \\(statusMsg) [\\(String(format: \"%.2f\", elapsed * 1000))ms]\",\n                level: statusLevel,\n                source: \"Provider Test\"\n            )\n\n            return EndpointCheck(\n                message: \"\\(label): [POST] Model API test HTTP \\(code)\",\n                ok: ok,\n                statusCode: code\n            )\n        } catch {\n            let elapsed = Date().timeIntervalSince(startTime)\n            StatusBarLogStore.shared.post(\n                \"\\(label): [POST] Model '\\(model)' API test → Error: \\(error.localizedDescription) [\\(String(format: \"%.2f\", elapsed * 1000))ms]\",\n                level: .error,\n                source: \"Provider Test\"\n            )\n            return EndpointCheck(\n                message: \"\\(label): [POST] Model API test error: \\(error.localizedDescription)\",\n                ok: false,\n                statusCode: -1\n            )\n        }\n    }\n\n    private func formattedLine(for result: EndpointCheck) -> String {\n        var line = result.message\n        guard !result.ok else { return line }\n        switch result.statusCode {\n        case 401, 403:\n            line += \" – Check the API key or token permissions.\"\n        case 404:\n            line +=\n                \" – 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.\"\n            if let docs = providerDocsURL {\n                line += \" Docs: \\(docs.absoluteString)\"\n            }\n        default:\n            if let docs = providerDocsURL {\n                line += \" – See docs: \\(docs.absoluteString)\"\n            }\n        }\n        return line\n    }\n\n}\n\n// MARK: - Provider Menu Components\n\nprivate struct ProviderMenuItem {\n    let id: String\n    let name: String\n    let icon: ProviderIconType\n    let action: () -> Void\n}\n\nprivate enum ProviderIconType {\n    case oauth(LocalAuthProvider)\n    case apiKey(ProvidersRegistryService.Provider)\n}\n\nprivate struct ProviderAddMenu: View {\n    let title: String\n    let helpText: String\n    let items: [ProviderMenuItem]\n    var emptyMessage: String? = nil\n    var customAction: (String, () -> Void)? = nil\n\n    var body: some View {\n        Menu {\n            Text(title)\n            if items.isEmpty {\n                if let emptyMessage {\n                    Text(emptyMessage)\n                }\n            } else {\n                ForEach(items, id: \\.id) { item in\n                    Button(action: item.action) {\n                        HStack {\n                            ProviderMenuIconView(icon: item.icon, size: 16, cornerRadius: 3)\n                            Text(item.name)\n                        }\n                    }\n                }\n                if customAction != nil {\n                    Divider()\n                }\n            }\n            if let (label, action) = customAction {\n                Button(label, action: action)\n            }\n        } label: {\n            Image(systemName: \"plus\")\n                .font(.body)\n                .frame(width: 24, height: 24)\n        }\n        .menuStyle(.borderlessButton)\n        .buttonStyle(.plain)\n        .contentShape(Rectangle())\n        .help(helpText)\n    }\n}\n\nprivate struct ProviderMenuIconView: View {\n    let icon: ProviderIconType\n    var size: CGFloat = 16\n    var cornerRadius: CGFloat = 3\n\n    var body: some View {\n        Group {\n            switch icon {\n            case .oauth(let provider):\n                let iconName = iconNameForOAuthProvider(provider)\n                if let nsImage = ProviderIconThemeHelper.menuImage(\n                    named: iconName, size: NSSize(width: size, height: size))\n                {\n                    Image(nsImage: nsImage)\n                        .resizable()\n                        .interpolation(.high)\n                        .aspectRatio(contentMode: .fit)\n                        .frame(width: size, height: size)\n                } else {\n                    LocalAuthProviderIconView(\n                        provider: provider, size: size, cornerRadius: cornerRadius)\n                }\n            case .apiKey(let provider):\n                if let iconName = iconNameForAPIProvider(provider),\n                    let nsImage = ProviderIconThemeHelper.menuImage(\n                        named: iconName, size: NSSize(width: size, height: size))\n                {\n                    Image(nsImage: nsImage)\n                        .resizable()\n                        .interpolation(.high)\n                        .aspectRatio(contentMode: .fit)\n                        .frame(width: size, height: size)\n                } else {\n                    APIKeyProviderIconView(\n                        provider: provider, size: size, cornerRadius: cornerRadius)\n                }\n            }\n        }\n    }\n\n    private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String {\n        switch provider {\n        case .codex: return \"ChatGPTIcon\"\n        case .claude: return \"ClaudeIcon\"\n        case .gemini: return \"GeminiIcon\"\n        case .antigravity: return \"AntigravityIcon\"\n        case .qwen: return \"QwenIcon\"\n        }\n    }\n\n    private func iconNameForAPIProvider(_ provider: ProvidersRegistryService.Provider) -> String? {\n        // Use unified icon resource library helper\n        return ProviderIconResource.iconName(for: provider)\n    }\n}\n\n// MARK: - Test Result UI Components\nprivate struct TestResultCard: View {\n    let label: String\n    let result: ProviderTestResult\n    @State private var isExpanded: Bool = false\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            // Header\n            Button {\n                withAnimation { isExpanded.toggle() }\n            } label: {\n                HStack {\n                    Image(\n                        systemName: result.success\n                            ? \"checkmark.circle.fill\" : \"exclamationmark.triangle.fill\"\n                    )\n                    .foregroundStyle(result.success ? .green : .orange)\n                    Text(label)\n                        .font(.subheadline)\n                        .fontWeight(.medium)\n                    Spacer()\n                    Text(result.summary)\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                    Image(systemName: isExpanded ? \"chevron.up\" : \"chevron.down\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                }\n            }\n            .buttonStyle(.plain)\n\n            // Expandable details\n            if isExpanded {\n                VStack(alignment: .leading, spacing: 4) {\n                    ForEach(result.layers) { layer in\n                        LayerResultRow(layer: layer)\n                    }\n                }\n                .padding(.leading, 24)\n            }\n        }\n        .padding(8)\n        .background(Color(nsColor: .controlBackgroundColor))\n        .cornerRadius(6)\n    }\n}\n\nprivate struct LayerResultRow: View {\n    let layer: TestLayerResult\n    @State private var showDetails: Bool = false\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 2) {\n            HStack(spacing: 6) {\n                Image(systemName: layer.icon)\n                    .font(.caption)\n                    .foregroundStyle(layer.color)\n                Text(layer.layer.rawValue)\n                    .font(.caption)\n                    .fontWeight(.medium)\n                Text(\"•\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                Text(layer.message)\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                if let code = layer.httpCode {\n                    Text(\"(HTTP \\(code))\")\n                        .font(.caption2)\n                        .foregroundStyle(.secondary)\n                }\n                Spacer()\n                if layer.details != nil {\n                    Button {\n                        showDetails.toggle()\n                    } label: {\n                        Image(systemName: \"info.circle\")\n                            .font(.caption)\n                    }\n                    .buttonStyle(.plain)\n                }\n            }\n\n            if showDetails, let details = layer.details {\n                Text(details)\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                    .padding(.leading, 20)\n                    .padding(.top, 2)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/RecentSessionsListView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct RecentSessionsListView: View {\n  typealias ProjectInfo = (id: String, name: String)\n\n  var title: String = \"Recent Sessions\"\n  var sessions: [SessionSummary]\n  var emptyMessage: String\n  var projectInfoProvider: ((SessionSummary) -> ProjectInfo?)? = nil\n  var projectColumnWidth: CGFloat = 100\n  var onSelectSession: (SessionSummary) -> Void\n  var onSelectProject: ((String) -> Void)? = nil\n\n  @Environment(\\.colorScheme) private var colorScheme\n\n  private var hasProjectColumn: Bool { projectInfoProvider != nil }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      Text(title)\n        .font(.headline)\n      if sessions.isEmpty {\n        OverviewCard {\n          Text(emptyMessage)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .frame(maxWidth: .infinity, alignment: .leading)\n        }\n      } else {\n        OverviewCard {\n          VStack(spacing: 2) {\n            ForEach(Array(sessions.enumerated()), id: \\.element.id) { index, session in\n              sessionRow(session: session)\n                .padding(.vertical, 8)\n                .padding(.leading, hasProjectColumn ? 0 : 4)\n                .padding(.trailing, 8)\n                .contentShape(Rectangle())\n                .onTapGesture { onSelectSession(session) }\n                .onHover { hovering in\n                  if hovering {\n                    NSCursor.pointingHand.set()\n                  } else {\n                    NSCursor.arrow.set()\n                  }\n                }\n\n              if index < sessions.count - 1 {\n                Divider()\n                  .padding(.leading, dividerLeadingPadding)\n                  .padding(.trailing, 4)\n              }\n            }\n          }\n        }\n      }\n    }\n  }\n\n  private var dividerLeadingPadding: CGFloat {\n    hasProjectColumn ? projectColumnWidth + 36 : 36\n  }\n\n  private func sessionRow(session: SessionSummary) -> some View {\n    HStack(alignment: .center, spacing: 12) {\n      if let projectInfoProvider,\n        let info = projectInfoProvider(session)\n      {\n        projectLabel(for: info)\n          .frame(width: projectColumnWidth, alignment: .leading)\n      } else if hasProjectColumn {\n        Rectangle()\n          .fill(Color.clear)\n          .frame(width: projectColumnWidth, alignment: .leading)\n      }\n\n      let branding = session.source.branding\n      if let asset = branding.badgeAssetName {\n        Image(asset)\n          .resizable()\n          .renderingMode(.original)\n          .aspectRatio(contentMode: .fit)\n          .frame(width: 16, height: 16)\n          .modifier(\n            DarkModeInvertModifier(\n              active: session.source.baseKind == .codex && colorScheme == .dark\n            )\n          )\n      } else {\n        Image(systemName: branding.symbolName)\n          .font(.system(size: 14, weight: .semibold))\n          .foregroundStyle(branding.iconColor)\n          .frame(width: 16)\n      }\n\n      VStack(alignment: .leading, spacing: 4) {\n        Text(session.effectiveTitle)\n          .font(.subheadline)\n          .fontWeight(.medium)\n          .lineLimit(1)\n          .truncationMode(.tail)\n\n        HStack(spacing: 6) {\n          let date = session.lastUpdatedAt ?? session.startedAt\n          LiveRelativeDateText(date: date)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n\n          Text(\"·\")\n            .font(.caption)\n            .foregroundStyle(.tertiary)\n\n          Text(session.commentSnippet)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n            .truncationMode(.tail)\n        }\n      }\n\n      Spacer()\n      Image(systemName: \"chevron.right\")\n        .font(.caption)\n        .foregroundStyle(.tertiary)\n    }\n  }\n\n  @ViewBuilder\n  private func projectLabel(for info: ProjectInfo) -> some View {\n    if let onSelectProject {\n      Text(info.name)\n        .font(.subheadline)\n        .fontWeight(.semibold)\n        .lineLimit(1)\n        .truncationMode(.tail)\n        .contentShape(Rectangle())\n        .onTapGesture { onSelectProject(info.id) }\n    } else {\n      Text(info.name)\n        .font(.subheadline)\n        .fontWeight(.semibold)\n        .lineLimit(1)\n        .truncationMode(.tail)\n    }\n  }\n}\n\nprivate struct LiveRelativeDateText: View {\n  let date: Date\n  \n  var body: some View {\n    TimelineView(.periodic(from: .now, by: 60.0)) { context in\n      Text(Self.formatter.localizedString(for: date, relativeTo: context.date))\n    }\n  }\n  \n  private static let formatter: RelativeDateTimeFormatter = {\n    let f = RelativeDateTimeFormatter()\n    f.unitsStyle = .short\n    return f\n  }()\n}\n"
  },
  {
    "path": "views/RemoteHostsSettingsView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct RemoteHostsSettingsPane: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    @EnvironmentObject private var viewModel: SessionListViewModel\n    @ObservedObject private var permissionsManager = SandboxPermissionsManager.shared\n\n    @State private var availableRemoteHosts: [SSHHost] = []\n    @State private var isRequestingSSHAccess = false\n    @State private var selectedHostAlias: String? = nil\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 6) {\n            Text(\"Remote Hosts\").font(.title2).fontWeight(.bold)\n            Text(\"Choose which SSH hosts CodMate should mirror for remote Codex/Claude sessions.\")\n                .font(.subheadline)\n                .foregroundColor(.secondary)\n\n            // Header controls aligned to the far right (match MCP Servers style)\n            HStack {\n                Spacer(minLength: 8)\n                HStack(spacing: 10) {\n                    Button(role: .none) {\n                        DispatchQueue.main.async { preferences.enabledRemoteHosts = [] }\n                    } label: { Text(\"Clear All\") }\n                    .buttonStyle(.bordered)\n                    .disabled(preferences.enabledRemoteHosts.isEmpty)\n\n                    Button { Task { await viewModel.syncRemoteHosts(force: true, refreshAfter: true) } } label: {\n                        Label(\"Sync Hosts\", systemImage: \"arrow.triangle.2.circlepath\")\n                    }\n                    .buttonStyle(.bordered)\n                    .disabled(preferences.enabledRemoteHosts.isEmpty)\n\n                    Button(action: reloadRemoteHosts) {\n                        Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                    }\n                    .buttonStyle(.bordered)\n                    .disabled(!permissionsManager.hasPermission(for: .sshConfig))\n                }\n            }\n\n            // Permission gate\n            if !permissionsManager.hasPermission(for: .sshConfig) {\n                permissionCard\n            } else {\n                hostsList\n            }\n\n            unavailableSection\n            Text(\"CodMate mirrors only the hosts you enable. Hosts that prompt for passwords will open interactively when needed.\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n        .padding(.top, 24)\n        .padding(.horizontal, 24)\n        .padding(.bottom, 24)\n        .onAppear {\n            if permissionsManager.hasPermission(for: .sshConfig) && availableRemoteHosts.isEmpty {\n                DispatchQueue.main.async { reloadRemoteHosts() }\n            }\n        }\n        .onChange(of: permissionsManager.hasPermission(for: .sshConfig)) { granted in\n            if granted { reloadRemoteHosts() } else { availableRemoteHosts = [] }\n        }\n    }\n\n    // MARK: - Subviews\n\n    @ViewBuilder\n    private var permissionCard: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            Label(\"Grant Access to ~/.ssh\", systemImage: \"lock.square\")\n                .font(.headline)\n            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.\")\n                .font(.caption)\n                .foregroundColor(.secondary)\n            Button {\n                guard !isRequestingSSHAccess else { return }\n                isRequestingSSHAccess = true\n                Task {\n                    let granted = await permissionsManager.requestPermission(for: .sshConfig)\n                    await MainActor.run {\n                        isRequestingSSHAccess = false\n                        if granted { reloadRemoteHosts() }\n                    }\n                }\n            } label: {\n                HStack(spacing: 6) {\n                    if isRequestingSSHAccess { ProgressView().controlSize(.small) }\n                    Text(isRequestingSSHAccess ? \"Requesting…\" : \"Grant Access\")\n                }\n                .frame(maxWidth: .infinity)\n            }\n            .buttonStyle(.borderedProminent)\n        }\n        .padding()\n        .background(Color(nsColor: .separatorColor).opacity(0.2))\n        .cornerRadius(10)\n    }\n\n    @ViewBuilder\n    private var hostsList: some View {\n        let hosts = availableRemoteHosts\n        if hosts.isEmpty {\n            VStack(alignment: .leading, spacing: 8) {\n                Text(\"No SSH hosts were found in ~/.ssh/config.\")\n                    .font(.body)\n                    .foregroundColor(.secondary)\n                Text(\"Add host aliases to your SSH config, then refresh to enable remote session mirroring.\")\n                    .font(.caption)\n                    .foregroundStyle(.tertiary)\n            }\n            .padding(.vertical, 12)\n            .frame(maxWidth: .infinity, alignment: .leading)\n        } else {\n            // Estimate a unified name column width based on the longest alias\n            let maxAliasCount = hosts.map { $0.alias.count }.max() ?? 0\n            let nameColumnWidth = max(120.0, min(320.0, Double(maxAliasCount) * 8.0))\n\n            List(selection: $selectedHostAlias) {\n                ForEach(hosts, id: \\.alias) { host in\n                    let (statusText, statusColor) = syncStatusDescription(for: host.alias)\n\n                    HStack(alignment: .center, spacing: 0) {\n                        Toggle(\"\", isOn: bindingForRemoteHost(alias: host.alias))\n                            .toggleStyle(.switch)\n                            .labelsHidden()\n                            .controlSize(.small)\n                            .padding(.trailing, 8)\n\n                        HStack(alignment: .center, spacing: 8) {\n                            Image(systemName: \"antenna.radiowaves.left.and.right\")\n                            Text(host.alias).font(.body.weight(.medium))\n                        }\n                        .frame(width: nameColumnWidth, alignment: .leading)\n\n                        Spacer(minLength: 16)\n\n                        VStack(alignment: .leading, spacing: 2) {\n                            if let line = connectionLine(for: host), !line.isEmpty {\n                                Label(line, systemImage: \"link\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                                    .lineLimit(1)\n                                    .truncationMode(.middle)\n                            }\n                            HStack(spacing: 12) {\n                                if let pj = host.proxyJump, !pj.isEmpty {\n                                    Label(\"ProxyJump: \\(pj)\", systemImage: \"arrow.triangle.branch\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                        .lineLimit(1)\n                                        .truncationMode(.middle)\n                                }\n                                if let idf = host.identityFile, !idf.isEmpty {\n                                    Label(idf, systemImage: \"key\")\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                        .lineLimit(1)\n                                        .truncationMode(.middle)\n                                }\n                            }\n                        }\n                        .frame(maxWidth: .infinity, alignment: .leading)\n\n                        Text(statusText)\n                            .font(.caption2)\n                            .foregroundStyle(statusColor)\n                            .frame(minWidth: 120, alignment: .trailing)\n                    }\n                    .padding(.vertical, 8)\n                    .tag(host.alias as String?)\n                }\n            }\n            .frame(minHeight: 200, maxHeight: .infinity, alignment: .top)\n            .padding(.horizontal, -8)\n        }\n    }\n\n    @ViewBuilder\n    private var unavailableSection: some View {\n        let hostAliases = Set(availableRemoteHosts.map { $0.alias })\n        let dangling = preferences.enabledRemoteHosts.subtracting(hostAliases)\n        if permissionsManager.hasPermission(for: .sshConfig) && !dangling.isEmpty {\n            VStack(alignment: .leading, spacing: 6) {\n                Text(\"Unavailable Hosts\")\n                    .font(.subheadline)\n                    .fontWeight(.semibold)\n                Text(\"The following host aliases are enabled but not present in your current SSH config:\")\n                    .font(.caption)\n                    .foregroundColor(.secondary)\n                ForEach(Array(dangling).sorted(), id: \\.self) { alias in\n                    Text(\"• \\(alias)\")\n                        .font(.caption)\n                        .foregroundStyle(.tertiary)\n                }\n            }\n            .padding(.vertical, 6)\n        }\n    }\n\n    // MARK: - Helpers\n\n    private func reloadRemoteHosts() {\n        let resolver = SSHConfigResolver()\n        availableRemoteHosts = []\n        let hosts = resolver.resolvedHosts().sorted { $0.alias.lowercased() < $1.alias.lowercased() }\n        availableRemoteHosts = hosts\n        let hostAliases = Set(hosts.map { $0.alias })\n        let filtered = preferences.enabledRemoteHosts.filter { hostAliases.contains($0) }\n        if filtered.count != preferences.enabledRemoteHosts.count {\n            DispatchQueue.main.async { preferences.enabledRemoteHosts = Set(filtered) }\n        }\n\n        // Default-select the first host when entering the page or when selection becomes invalid\n        if let current = selectedHostAlias, hostAliases.contains(current) {\n            return\n        }\n        selectedHostAlias = hosts.first?.alias\n    }\n\n    private func bindingForRemoteHost(alias: String) -> Binding<Bool> {\n        Binding(\n            get: { preferences.enabledRemoteHosts.contains(alias) },\n            set: { newValue in\n                var hosts = preferences.enabledRemoteHosts\n                if newValue { hosts.insert(alias) } else { hosts.remove(alias) }\n                preferences.enabledRemoteHosts = hosts\n            }\n        )\n    }\n\n    private static let relativeFormatter: RelativeDateTimeFormatter = {\n        let f = RelativeDateTimeFormatter()\n        f.unitsStyle = .full\n        return f\n    }()\n\n    private func syncStatusDescription(for alias: String) -> (String, Color) {\n        guard let state = viewModel.remoteSyncStates[alias] else {\n            return (\"Not synced yet\", .secondary)\n        }\n        switch state {\n        case .idle:\n            return (\"Not synced yet\", .secondary)\n        case .syncing:\n            return (\"Syncing…\", .secondary)\n        case .succeeded(let date):\n            let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date())\n            return (\"Last synced \\(relative)\", .secondary)\n        case .failed(let date, let message):\n            let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date())\n            let detail = Self.syncFailureDetail(from: message)\n            if detail.isEmpty { return (\"Sync failed \\(relative)\", .red) }\n            return (\"Sync failed \\(relative): \\(detail)\", .red)\n        }\n    }\n\n    private static func syncFailureDetail(from rawMessage: String) -> String {\n        let firstLine = rawMessage\n            .split(whereSeparator: \\.isNewline)\n            .first\n            .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? \"\"\n        guard !firstLine.isEmpty else { return \"\" }\n\n        let prefix = \"sync failed\"\n        if firstLine.lowercased().hasPrefix(prefix) {\n            var separators = CharacterSet.whitespacesAndNewlines\n            separators.insert(charactersIn: \":-–—\")\n            let remainder = firstLine.dropFirst(prefix.count)\n            let sanitized = String(remainder).trimmingCharacters(in: separators)\n            return sanitized\n        }\n        return firstLine\n    }\n\n    private func connectionLine(for host: SSHHost) -> String? {\n        var parts: [String] = []\n        if let user = host.user, !user.isEmpty { parts.append(user + \"@\") }\n        let hn = host.hostname ?? host.alias\n        var conn = parts.joined() + hn\n        if let port = host.port { conn += \":\\(port)\" }\n        return conn\n    }\n}\n"
  },
  {
    "path": "views/SandboxApprovalEditor.swift",
    "content": "import SwiftUI\n\nstruct SandboxApprovalEditor: View {\n    @Binding var sandbox: SandboxMode\n    @Binding var approval: ApprovalPolicy\n    @Binding var fullAuto: Bool\n    @Binding var dangerouslyBypass: Bool\n\n    var body: some View {\n        Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 10) {\n            GridRow {\n                Text(\"Sandbox\").font(.subheadline)\n                Picker(\"\", selection: $sandbox) {\n                    ForEach(SandboxMode.allCases) { s in Text(s.title).tag(s) }\n                }\n                .labelsHidden()\n                .pickerStyle(.segmented)\n                .frame(width: 360)\n            }\n            GridRow {\n                Text(\"Approval\").font(.subheadline)\n                Picker(\"\", selection: $approval) {\n                    ForEach(ApprovalPolicy.allCases) { a in Text(a.title).tag(a) }\n                }\n                .labelsHidden()\n                .pickerStyle(.segmented)\n                .frame(width: 360)\n            }\n            GridRow {\n                Text(\"Presets\").font(.subheadline)\n                HStack(spacing: 12) {\n                    Toggle(\"Full Auto\", isOn: $fullAuto)\n                    Toggle(\"Danger Bypass\", isOn: $dangerouslyBypass)\n                }\n            }\n        }\n    }\n}\n\n"
  },
  {
    "path": "views/SandboxPermissionsView.swift",
    "content": "import SwiftUI\n\nstruct SandboxPermissionsView: View {\n    @ObservedObject var manager = SandboxPermissionsManager.shared\n    @State private var isRequesting = false\n    \n    var body: some View {\n        VStack(spacing: 20) {\n            Text(\"Folder Access Permissions\")\n                .font(.title)\n                .padding(.top)\n            \n            if !manager.needsAuthorization {\n                VStack(spacing: 12) {\n                    Image(systemName: \"checkmark.circle.fill\")\n                        .font(.system(size: 48))\n                        .foregroundStyle(.green)\n                    Text(\"All permissions granted\")\n                        .font(.headline)\n                    Text(\"CodMate has access to all required directories.\")\n                        .foregroundStyle(.secondary)\n                }\n                .frame(maxWidth: .infinity, maxHeight: .infinity)\n            } else {\n                VStack(alignment: .leading, spacing: 16) {\n                    Text(\"CodMate needs access to the following directories:\")\n                        .font(.headline)\n                    \n                    Text(\"Due to App Sandbox security, you must explicitly grant access to these folders. Your data never leaves your Mac.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                    \n                    Divider()\n                    \n                    ForEach(manager.missingPermissions) { directory in\n                        PermissionRow(\n                            directory: directory,\n                            hasPermission: manager.hasPermission(for: directory),\n                            onRequest: {\n                                isRequesting = true\n                                Task {\n                                    _ = await manager.requestPermission(for: directory)\n                                    isRequesting = false\n                                }\n                            }\n                        )\n                    }\n                    \n                    Divider()\n                    \n                    if !manager.missingPermissions.isEmpty {\n                        Button {\n                            isRequesting = true\n                            Task {\n                                _ = await manager.requestAllMissingPermissions()\n                                isRequesting = false\n                            }\n                        } label: {\n                            HStack {\n                                if isRequesting {\n                                    ProgressView()\n                                        .controlSize(.small)\n                                        .padding(.trailing, 4)\n                                }\n                                Text(\"Grant All Permissions\")\n                            }\n                            .frame(maxWidth: .infinity)\n                        }\n                        .buttonStyle(.borderedProminent)\n                        .controlSize(.large)\n                        .disabled(isRequesting)\n                    }\n                }\n                .padding()\n            }\n            \n            Spacer()\n        }\n        .frame(minWidth: 500, minHeight: 400)\n        .onAppear {\n            manager.checkPermissions()\n        }\n    }\n}\n\nprivate struct PermissionRow: View {\n    let directory: SandboxPermissionsManager.RequiredDirectory\n    let hasPermission: Bool\n    let onRequest: () -> Void\n    \n    var body: some View {\n        HStack(spacing: 12) {\n            VStack(alignment: .leading, spacing: 4) {\n                HStack {\n                    Text(directory.displayName)\n                        .font(.headline)\n                    if hasPermission {\n                        Image(systemName: \"checkmark.circle.fill\")\n                            .foregroundStyle(.green)\n                    }\n                }\n                Text(directory.description)\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                Text(directory.rawValue)\n                    .font(.caption)\n                    .foregroundStyle(.tertiary)\n            }\n            \n            Spacer()\n            \n            if !hasPermission {\n                Button(\"Grant Access\") {\n                    onRequest()\n                }\n                .buttonStyle(.bordered)\n            }\n        }\n        .padding(.vertical, 8)\n    }\n}\n\n#Preview {\n    SandboxPermissionsView()\n}\n"
  },
  {
    "path": "views/Search/GlobalSearchPanel.swift",
    "content": "import SwiftUI\n\nstruct GlobalSearchPanel: View {\n  @ObservedObject var viewModel: GlobalSearchViewModel\n  let maxWidth: CGFloat\n  let onSelect: (GlobalSearchResult) -> Void\n  let onClose: () -> Void\n  var contentHeight: CGFloat? = nil\n\n  var body: some View {\n    GlobalSearchPanelContent(\n      viewModel: viewModel,\n      onSelect: onSelect,\n      onClose: onClose,\n      contentHeight: contentHeight,\n      isFloating: true\n    )\n    .padding(16)\n    .frame(maxWidth: maxWidth)\n    .background(\n      RoundedRectangle(cornerRadius: 18, style: .continuous)\n        .fill(.ultraThinMaterial)\n    )\n    .overlay(\n      RoundedRectangle(cornerRadius: 18, style: .continuous)\n        .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)\n    )\n    .shadow(color: Color.black.opacity(0.2), radius: 22, x: 0, y: 18)\n  }\n}\n\nstruct GlobalSearchPopoverPanel: View {\n  @ObservedObject var viewModel: GlobalSearchViewModel\n  @Binding var size: CGSize\n  let minSize: CGSize\n  let maxSize: CGSize\n  let onSelect: (GlobalSearchResult) -> Void\n  let onClose: () -> Void\n\n  var body: some View {\n    GlobalSearchPanelContent(\n      viewModel: viewModel,\n      onSelect: onSelect,\n      onClose: onClose,\n      contentHeight: size.height,\n      isFloating: false\n    )\n    .padding(16)\n    .frame(width: size.width)\n    .overlay(alignment: .topTrailing) {\n      GlobalSearchSubmitProxy(viewModel: viewModel)\n    }\n    .overlay(alignment: .bottomLeading) {\n      PopoverResizeHandle(\n        size: $size,\n        minSize: minSize,\n        maxSize: maxSize,\n        expandFromLeadingEdge: true\n      )\n    }\n  }\n}\n\nprivate struct GlobalSearchPanelContent: View {\n  @ObservedObject var viewModel: GlobalSearchViewModel\n  let onSelect: (GlobalSearchResult) -> Void\n  let onClose: () -> Void\n  var contentHeight: CGFloat? = nil\n  var isFloating: Bool = false\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n      controls\n      content\n      progressRow\n    }\n  }\n\n  private var header: some View {\n    HStack {\n      Spacer(minLength: 0)\n      Picker(\"Scope\", selection: $viewModel.filter) {\n        ForEach(GlobalSearchFilter.allCases, id: \\.self) { filter in\n          Text(filter.title).tag(filter)\n        }\n      }\n      .labelsHidden()\n      .pickerStyle(.segmented)\n      .controlSize(.large)\n      .frame(minWidth: 320, maxWidth: 860)\n      Spacer(minLength: 0)\n    }\n  }\n\n  private var controls: some View {\n    HStack {\n      Spacer(minLength: 0)\n      ToolbarSearchField(\n        placeholder: \"Type to search\",\n        text: $viewModel.query,\n        onFocusChange: { viewModel.setFocus($0) },\n        onSubmit: { viewModel.submit() },\n        autofocus: viewModel.hasFocus,\n        onCancel: onClose\n      )\n      .frame(minWidth: 320, maxWidth: 860, minHeight: 36)\n      Spacer(minLength: 0)\n    }\n    .frame(maxWidth: .infinity)\n  }\n\n  @ViewBuilder\n  private var progressRow: some View {\n    if let progress = viewModel.ripgrepProgress {\n      let summary =\n        \"\\(progress.message) · Files: \\(progress.filesProcessed) · Matches: \\(progress.matchesFound)\"\n      HStack(spacing: 8) {\n        if progress.isFinished {\n          Image(systemName: progress.isCancelled ? \"xmark.circle\" : \"checkmark.circle\")\n            .foregroundStyle(progress.isCancelled ? Color.red : Color.green)\n        } else {\n          ProgressView().controlSize(.small)\n        }\n        Text(summary)\n          .font(.system(size: 10))\n          .foregroundStyle(.secondary)\n        if !progress.isFinished {\n          Button(\"Cancel\") {\n            viewModel.cancelBackgroundSearch()\n          }\n          .buttonStyle(.bordered)\n          .controlSize(.mini)\n        }\n      }\n      .padding(.vertical, 2)\n      .frame(maxWidth: .infinity, alignment: .trailing)\n    }\n  }\n\n  @ViewBuilder\n  private var content: some View {\n    let isEmpty = viewModel.filteredResults.isEmpty\n    if viewModel.isSearching && viewModel.filteredResults.isEmpty {\n      HStack(spacing: 8) {\n        ProgressView()\n          .controlSize(.small)\n        Text(\"Searching…\")\n          .font(.system(size: 13))\n          .foregroundStyle(.secondary)\n      }\n      .frame(maxWidth: .infinity, alignment: .leading)\n      .padding(.vertical, 8)\n    } else {\n      ScrollView(showsIndicators: true) {\n        LazyVStack(spacing: 0) {\n          let count = viewModel.filteredResults.count\n          ForEach(Array(viewModel.filteredResults.enumerated()), id: \\.1.id) { index, element in\n            Button {\n              onSelect(element)\n            } label: {\n              resultRow(element)\n                .background(rowBackground(for: index, total: count))\n            }\n            .buttonStyle(.plain)\n            .contentShape(Rectangle())\n          }\n        }\n      }\n      .frame(height: max(contentHeight ?? CGFloat(isEmpty ? 150 : 320), 150))\n      .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))\n      .overlay {\n        if isEmpty {\n          VStack(spacing: 4) {\n            Text(\"No matches yet\")\n              .font(.system(size: 13))\n              .foregroundStyle(.secondary)\n              .multilineTextAlignment(.center)\n            Text(\"Try another keyword or widen the scope.\")\n              .font(.system(size: 12))\n              .foregroundStyle(.tertiary)\n              .multilineTextAlignment(.center)\n            if isFloating {\n              Text(\"Press Esc to close\")\n                .font(.system(size: 11))\n                .foregroundStyle(.tertiary)\n                .multilineTextAlignment(.center)\n                .padding(.top, 2)\n            }\n          }\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n        }\n      }\n    }\n  }\n\n  private func resultRow(_ result: GlobalSearchResult) -> some View {\n    HStack(alignment: .top, spacing: 12) {\n      ZStack {\n        Circle()\n          .fill(Color.accentColor.opacity(0.15))\n        Image(systemName: result.kind.symbolName)\n          .font(.system(size: 14, weight: .medium))\n          .foregroundStyle(Color.accentColor)\n      }\n      .frame(width: 32, height: 32)\n\n      VStack(alignment: .leading, spacing: 4) {\n        HStack(alignment: .firstTextBaseline) {\n          Text(result.displayTitle)\n            .font(.system(size: 15, weight: .semibold))\n            .lineLimit(1)\n          Spacer()\n          if let detail = result.detailLine {\n            Text(detail)\n              .font(.system(size: 11))\n              .foregroundStyle(.tertiary)\n          }\n        }\n        if let snippet = result.snippet {\n          snippetText(snippet)\n            .font(.system(size: 13))\n            .foregroundStyle(.secondary)\n        } else if let note = result.note, let comment = note.comment, !comment.isEmpty {\n          Text(clean(comment))\n            .font(.system(size: 13))\n            .foregroundStyle(.secondary)\n            .lineLimit(2)\n        } else if let project = result.project, let overview = project.overview {\n          Text(clean(overview))\n            .font(.system(size: 13))\n            .foregroundStyle(.secondary)\n            .lineLimit(2)\n        }\n      }\n    }\n    .padding(.horizontal, 10)\n    .padding(.vertical, 8)\n    .frame(maxWidth: .infinity, alignment: .leading)\n  }\n\n  private func rowBackground(for index: Int, total: Int) -> some View {\n    let radius: CGFloat = 12\n    let isFirst = index == 0\n    let isLast = index == total - 1\n    let color = Color.white.opacity(index.isMultiple(of: 2) ? 0.05 : 0.02)\n    return UnevenRoundedRectangle(\n      cornerRadii: RectangleCornerRadii(\n        topLeading: isFirst ? radius : 0,\n        bottomLeading: isLast ? radius : 0,\n        bottomTrailing: isLast ? radius : 0,\n        topTrailing: isFirst ? radius : 0\n      ),\n      style: .continuous\n    )\n    .fill(color)\n  }\n\n  private func snippetText(_ snippet: GlobalSearchSnippet) -> Text {\n    let text = snippet.text\n    guard let highlight = snippet.highlightRange else {\n      return Text(text)\n    }\n    let lower = max(0, min(highlight.lowerBound, text.count))\n    let upper = max(lower, min(highlight.upperBound, text.count))\n    let startIdx = text.index(text.startIndex, offsetBy: lower)\n    let midIdx = text.index(text.startIndex, offsetBy: upper)\n    let prefix = String(text[..<startIdx])\n    let match = String(text[startIdx..<midIdx])\n    let suffix = String(text[midIdx...])\n    return Text(prefix)\n      .foregroundColor(.secondary)\n      + Text(match).foregroundColor(Color.accentColor)\n      + Text(suffix).foregroundColor(.secondary)\n  }\n\n  private func clean(_ text: String) -> String {\n    text.sanitizedSnippetText()\n  }\n}\n\nprivate struct GlobalSearchSubmitProxy: View {\n  @ObservedObject var viewModel: GlobalSearchViewModel\n\n  var body: some View {\n    Button(action: { viewModel.submit() }) {\n      EmptyView()\n    }\n    .keyboardShortcut(.return, modifiers: [])\n    .opacity(0)\n    .frame(width: 0, height: 0)\n    .allowsHitTesting(false)\n  }\n}\n\nprivate struct PopoverResizeHandle: View {\n  @Binding var size: CGSize\n  let minSize: CGSize\n  let maxSize: CGSize\n  @State private var dragOrigin: CGSize?\n  var expandFromLeadingEdge = false\n\n  var body: some View {\n    Image(systemName: iconName)\n      .font(.system(size: 10, weight: .semibold))\n      .foregroundStyle(.secondary)\n      .padding(8)\n      .gesture(\n        DragGesture(minimumDistance: 0)\n          .onChanged { value in\n            let origin = dragOrigin ?? size\n            if dragOrigin == nil { dragOrigin = size }\n            let deltaWidth = expandFromLeadingEdge ? -value.translation.width : value.translation.width\n            let proposed = CGSize(\n              width: origin.width + deltaWidth,\n              height: origin.height + value.translation.height\n            )\n            size = CGSize(\n              width: clamp(proposed.width, min: minSize.width, max: maxSize.width),\n              height: clamp(proposed.height, min: minSize.height, max: maxSize.height)\n            )\n          }\n          .onEnded { _ in\n            dragOrigin = nil\n          }\n      )\n      .accessibilityLabel(\"Resize search popover\")\n  }\n\n  private var iconName: String {\n    expandFromLeadingEdge ? \"arrow.up.right.and.arrow.down.left\" : \"arrow.up.left.and.arrow.down.right\"\n  }\n\n  private func clamp(_ value: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {\n    Swift.min(Swift.max(value, min), max)\n  }\n}\n"
  },
  {
    "path": "views/Search/ToolbarSearchField.swift",
    "content": "import SwiftUI\n\n#if os(macOS)\nimport AppKit\n\nstruct ToolbarSearchField: NSViewRepresentable {\n  let placeholder: String\n  @Binding var text: String\n  var onFocusChange: (Bool) -> Void\n  var onSubmit: () -> Void\n  var autofocus: Bool = false\n  var onCancel: (() -> Void)? = nil\n\n  func makeCoordinator() -> Coordinator { Coordinator(self) }\n\n  func makeNSView(context: Context) -> NSSearchField {\n    let field = NSSearchField(frame: .zero)\n    field.placeholderString = placeholder\n    field.delegate = context.coordinator\n    field.focusRingType = .none\n    field.sendsSearchStringImmediately = false\n    field.sendsWholeSearchString = true\n    field.cell?.usesSingleLineMode = true\n    field.translatesAutoresizingMaskIntoConstraints = false\n    field.bezelStyle = .roundedBezel\n    if autofocus {\n      DispatchQueue.main.async {\n        if field.window?.firstResponder !== field {\n          field.window?.makeFirstResponder(field)\n        }\n      }\n    }\n    return field\n  }\n\n  func updateNSView(_ nsView: NSSearchField, context: Context) {\n    let editor = nsView.currentEditor()\n    let window = nsView.window\n    let isFirstResponder = {\n      guard let window else { return false }\n      if let editor { return window.firstResponder === editor }\n      return window.firstResponder === nsView\n    }()\n\n    if !isFirstResponder, nsView.stringValue != text {\n      nsView.stringValue = text\n    }\n    if nsView.placeholderString != placeholder {\n      nsView.placeholderString = placeholder\n    }\n\n    let shouldAutofocus = autofocus\n\n    if shouldAutofocus && !isFirstResponder {\n      // Robust focusing for popover presentation: try now and re-try shortly after\n      DispatchQueue.main.async { [weak nsView] in\n        guard shouldAutofocus, let nsView, let window = nsView.window else { return }\n        if let editor = nsView.currentEditor(), window.firstResponder === editor { return }\n        window.makeFirstResponder(nsView)\n\n        // Re-try after the popover finishes becoming key to avoid focus races\n        DispatchQueue.main.asyncAfter(deadline: .now() + 0.08) { [weak nsView] in\n          guard shouldAutofocus, let nsView = nsView, let window = nsView.window else { return }\n          let editor = nsView.currentEditor()\n          let isFocused = (editor != nil && window.firstResponder === editor) || window.firstResponder === nsView\n          if !isFocused { window.makeFirstResponder(nsView) }\n        }\n        // Final retry for stubborn focus contention\n        DispatchQueue.main.asyncAfter(deadline: .now() + 0.16) { [weak nsView] in\n          guard shouldAutofocus, let nsView = nsView, let window = nsView.window else { return }\n          let editor = nsView.currentEditor()\n          let isFocused = (editor != nil && window.firstResponder === editor) || window.firstResponder === nsView\n          if !isFocused { window.makeFirstResponder(nsView) }\n        }\n      }\n    }\n  }\n\n  final class Coordinator: NSObject, NSSearchFieldDelegate {\n    let parent: ToolbarSearchField\n\n    init(_ parent: ToolbarSearchField) {\n      self.parent = parent\n    }\n\n    @MainActor\n    func controlTextDidBeginEditing(_ obj: Notification) {\n      parent.onFocusChange(true)\n    }\n\n    @MainActor\n    func controlTextDidEndEditing(_ obj: Notification) {\n      parent.onFocusChange(false)\n    }\n\n    @MainActor\n    func controlTextDidChange(_ obj: Notification) {\n      guard let field = obj.object as? NSSearchField else { return }\n      if let editor = field.currentEditor() as? NSTextView, editor.hasMarkedText() { return }\n      parent.text = field.stringValue\n    }\n\n    @MainActor\n    func searchFieldDidEndSearching(_ sender: NSSearchField) {\n      parent.text = sender.stringValue\n      parent.onFocusChange(false)\n    }\n\n    @MainActor\n    func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {\n      switch commandSelector {\n      case #selector(NSResponder.insertNewline(_:)):\n        parent.onSubmit()\n        return true\n      case #selector(NSResponder.cancelOperation(_:)):\n        let wasEmpty = parent.text.isEmpty\n        parent.text = \"\"\n        parent.onFocusChange(false)\n        if wasEmpty { parent.onCancel?() }\n        return true\n      default:\n        return false\n      }\n    }\n  }\n}\n#endif\n"
  },
  {
    "path": "views/SessionDetailView.swift",
    "content": "import AppKit\nimport SwiftUI\nimport UniformTypeIdentifiers\n\nstruct SessionDetailView: View {\n    let summary: SessionSummary\n    let isProcessing: Bool\n    let onResume: () -> Void\n    let onReveal: () -> Void\n    let onDelete: () -> Void\n    @Binding var columnVisibility: NavigationSplitViewVisibility\n\n    @EnvironmentObject private var viewModel: SessionListViewModel\n    @ObservedObject var preferences: SessionPreferencesStore\n    @State private var turns: [ConversationTurn] = []  // filtered + sorted for display\n    @State private var allTurns: [ConversationTurn] = []  // raw full timeline\n    @State private var loadingTimeline = false\n    @State private var isConversationExpanded = false\n    @State private var expandedTurnIDs: Set<String> = []\n    @State private var autoExpandVisible = false\n    @State private var searchText: String = \"\"\n    @State private var expandAllOnSearch = false\n    @State private var nowModeEnabled = true  // Auto-scroll to bottom when enabled\n    @State private var timelineRefreshToken = 0\n    @State private var lastLocalChangeAt: Date? = nil\n    @State private var localActivityClearTask: Task<Void, Never>? = nil\n    @State private var inlineFiltersExpanded = false\n    @State private var sessionVisibleKinds: Set<MessageVisibilityKind> = MessageVisibilityKind.timelineDefault\n    @State private var hasSessionVisibleKindsOverride = false\n    @Environment(\\.openWindow) private var openWindow\n    @State private var monitor: DirectoryMonitor? = nil\n    @State private var debounceReloadTask: Task<Void, Never>? = nil\n    @State private var filterTask: Task<Void, Never>? = nil\n    @State private var loadTask: Task<[ConversationTurn], Never>? = nil\n    @State private var environmentExpanded = false\n    @State private var environmentLoading = false\n    @State private var environmentInfo: EnvironmentContextInfo?\n    private let loader = SessionTimelineLoader()\n\n    // Three-stage loading support\n    @State private var previewTurns: [ConversationTurnPreview] = []\n    @State private var loadingStage: LoadingStage = .initial\n\n    enum LoadingStage {\n        case initial      // Not started\n        case preview      // Showing preview from cache\n        case loading      // Loading full data\n        case full         // Full data loaded\n    }\n\n    var body: some View {\n        GeometryReader { proxy in\n            VStack(alignment: .leading, spacing: 16) {\n                if !isConversationExpanded {\n                    sessionInfoCard\n                    environmentSection\n                    instructionsSection\n                    Divider()\n                }\n\n                conversationHeader\n                if inlineFiltersExpanded {\n                    inlineFiltersPanel\n                }\n                conversationScrollView\n            }\n            .padding(16)\n            .frame(\n                width: proxy.size.width,\n                height: proxy.size.height,\n                alignment: .topLeading\n            )\n        }\n        .task(id: summary.id) { await initialLoadAndMonitor() }\n        .onChange(of: searchText, initial: true) { _ in applyFilterAndSort() }\n        .onChange(of: preferences.timelineVisibleKinds, initial: true) { newValue in\n            guard !hasSessionVisibleKindsOverride else { return }\n            sessionVisibleKinds = newValue\n            applyFilterAndSort()\n        }\n        .onReceive(NotificationCenter.default.publisher(for: .codMateConversationFilter)) { note in\n            guard let target = note.userInfo?[\"sessionId\"] as? String, target == summary.id else { return }\n            guard let term = note.userInfo?[\"term\"] as? String else { return }\n            DispatchQueue.main.async {\n                searchText = term\n                expandAllOnSearch = false\n            }\n        }\n    }\n\n    // moved actions to fixed top bar\n\n    private var sessionInfoCard: some View {\n        GroupBox {\n            LazyVGrid(\n                columns: [\n                    GridItem(.flexible(), alignment: .topLeading),\n                    GridItem(.flexible(), alignment: .topLeading),\n                    GridItem(.flexible(), alignment: .topLeading),\n                    GridItem(.flexible(), alignment: .topLeading),\n                ], spacing: 12\n            ) {\n                infoRow(\n                    title: \"STARTED\",\n                    value: summary.startedAt.formatted(date: .numeric, time: .shortened),\n                    icon: \"calendar\")\n                infoRow(title: \"DURATION\", value: summary.readableDuration, icon: \"clock\")\n\n                if let model = summary.displayModel ?? summary.model {\n                    infoRow(title: \"MODEL\", value: model, icon: \"cpu\")\n                }\n                if let approval = summary.approvalPolicy {\n                    infoRow(title: \"APPROVAL\", value: approval, icon: \"checkmark.shield\")\n                }\n\n                infoRow(title: \"CLI VERSION\", value: summary.cliVersion, icon: \"terminal\")\n                infoRow(title: \"ORIGINATOR\", value: summary.originator, icon: \"person.circle\")\n\n                infoRow(\n                    title: \"WORKING DIRECTORY\",\n                    value: viewModel.displayWorkingDirectory(for: summary),\n                    icon: \"folder\")\n                infoRow(title: \"FILE SIZE\", value: summary.fileSizeDisplay, icon: \"externaldrive\")\n            }\n            .frame(maxWidth: .infinity, alignment: .leading)\n        }\n    }\n\n    // metrics moved to list row per request\n\n    private func infoRow(title: String, value: String, icon: String) -> some View {\n        HStack(alignment: .top, spacing: 12) {\n            Image(systemName: icon)\n                .frame(width: 20)\n                .foregroundStyle(.tertiary)\n            VStack(alignment: .leading, spacing: 2) {\n                Text(title.uppercased())\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                Text(value)\n                    .font(.body)\n            }\n        }\n    }\n\n    \n\n    @State private var instructionsExpanded = false\n    @State private var instructionsLoading = false\n    @State private var instructionsText: String?\n\n    private var environmentSection: some View {\n        GroupBox {\n            DisclosureGroup(isExpanded: $environmentExpanded) {\n                Group {\n                    if environmentLoading {\n                        ProgressView(\"Loading environment context…\")\n                    } else if let info = environmentInfo, info.hasContent {\n                        VStack(alignment: .leading, spacing: 6) {\n                            ForEach(info.entries) { entry in\n                                HStack(alignment: .firstTextBaseline, spacing: 12) {\n                                    Text(entry.key.uppercased())\n                                        .font(.caption)\n                                        .foregroundStyle(.secondary)\n                                        .frame(width: 120, alignment: .trailing)\n                                    Text(entry.value)\n                                        .font(.body)\n                                        .textSelection(.enabled)\n                                        .frame(maxWidth: .infinity, alignment: .leading)\n                                }\n                            }\n                            if let raw = info.rawText, !raw.isEmpty, info.entries.isEmpty {\n                                Text(raw)\n                                    .font(.body)\n                                    .textSelection(.enabled)\n                            }\n                            Text(\n                                \"Captured · \\(info.timestamp.formatted(date: .abbreviated, time: .shortened))\"\n                            )\n                            .font(.caption2)\n                            .foregroundStyle(.tertiary)\n                        }\n                        .frame(maxWidth: .infinity, alignment: .leading)\n                        .padding(.top, 2)\n                    } else {\n                        Text(\"No environment context captured.\")\n                            .foregroundStyle(.secondary)\n                            .frame(maxWidth: .infinity, alignment: .leading)\n                    }\n                }\n                .task(id: environmentExpanded) {\n                    guard environmentExpanded else { return }\n                    guard !summary.source.isRemote else {\n                        environmentInfo = nil\n                        environmentLoading = false\n                        return\n                    }\n                    guard environmentInfo == nil else { return }\n                    environmentLoading = true\n                    defer { environmentLoading = false }\n\n                    // Load environment context based on source type\n                    if summary.source.baseKind == .gemini {\n                        environmentInfo = await viewModel.geminiProvider.environmentContext(for: summary)\n                    } else if summary.source.baseKind == .claude {\n                        // Claude sessions can also benefit from the new method if needed\n                        environmentInfo = try? loader.loadEnvironmentContext(url: summary.fileURL)\n                    } else {\n                        // Codex sessions use the file-based method\n                        environmentInfo = try? loader.loadEnvironmentContext(url: summary.fileURL)\n                    }\n                }\n            } label: {\n                Label(\"Environment Context\", systemImage: \"macwindow\")\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                    .contentShape(Rectangle())\n                    .onTapGesture { environmentExpanded.toggle() }\n                    .hoverHand()\n            }\n        }\n    }\n\n    private var instructionsSection: some View {\n        GroupBox {\n            DisclosureGroup(isExpanded: $instructionsExpanded) {\n                Group {\n                    if instructionsLoading {\n                        ProgressView(\"Loading instructions…\")\n                    } else if let text = instructionsText ?? summary.instructions, !text.isEmpty {\n                        Text(text)\n                            .font(.system(.body, design: .rounded))\n                            .textSelection(.enabled)\n                            .frame(maxWidth: .infinity, alignment: .leading)\n                            .padding(.top, 2)\n                    } else {\n                        Text(\"No instructions found.\")\n                            .foregroundStyle(.secondary)\n                            .frame(maxWidth: .infinity, alignment: .leading)\n                    }\n                }\n                .task(id: instructionsExpanded) {\n                    guard instructionsExpanded else { return }\n                    guard instructionsText == nil else { return }\n\n                    if let cached = await viewModel.cachedInstructions(for: summary), !cached.isEmpty {\n                        instructionsText = cached\n                        return\n                    }\n\n                    guard !summary.source.isRemote else {\n                        instructionsLoading = false\n                        return\n                    }\n\n                    instructionsLoading = true\n                    defer { instructionsLoading = false }\n                    if let loaded = try? loader.loadInstructions(url: summary.fileURL) {\n                        instructionsText = loaded\n                    }\n                }\n            } label: {\n                Label(\"Task Instructions\", systemImage: \"list.bullet.rectangle\")\n                    .frame(maxWidth: .infinity, alignment: .leading)\n                    .contentShape(Rectangle())\n                    .onTapGesture { instructionsExpanded.toggle() }\n                    .hoverHand()\n            }\n        }\n    }\n\n    private var conversationHeader: some View {\n        HStack(spacing: 12) {\n            Label(\"Conversation\", systemImage: \"bubble.left.and.text.bubble.right\")\n                .font(.headline)\n\n            Spacer()\n\n            // Search (inline magnifier and clear button, custom style for compatibility)\n            conversationSearchField\n\n            Button {\n                openWindow(id: \"settings\")\n            } label: {\n                Label(\n                    \"Filters\",\n                    systemImage: \"line.3.horizontal.decrease.circle\"\n                )\n                .font(.callout)\n            }\n            .buttonStyle(.borderless)\n            .help(\"Open Settings to configure message type filters\")\n            .hoverHand()\n\n            // Now mode toggle (mimics Console.app)\n            Button {\n                nowModeEnabled.toggle()\n            } label: {\n                Label {\n                    Text(\"Now\")\n                } icon: {\n                    ZStack {\n                        // Background circle\n                        Circle()\n                            .fill(nowModeEnabled ? Color.primary : Color.clear)\n                            .frame(width: 14, height: 14)\n\n                        // Border circle (only visible when disabled)\n                        if !nowModeEnabled {\n                            Circle()\n                                .strokeBorder(Color.primary, lineWidth: 1.5)\n                                .frame(width: 14, height: 14)\n                        }\n\n                        // Arrow icon\n                        Image(systemName: \"arrow.up.backward\")\n                            .font(.system(size: 8, weight: .medium))\n                            .foregroundColor(nowModeEnabled ? Color(nsColor: .controlBackgroundColor) : Color.primary)\n                    }\n                }\n                .font(.callout)\n            }\n            .buttonStyle(.borderless)\n            .help(nowModeEnabled ? \"Auto-scroll to latest (Now mode enabled)\" : \"Enable auto-scroll to latest\")\n            .hoverHand()\n\n            Button {\n                autoExpandVisible.toggle()\n                expandedTurnIDs.removeAll()\n            } label: {\n                Label(\n                    autoExpandVisible ? \"Collapse Visible\" : \"Expand Visible\",\n                    systemImage: autoExpandVisible ? \"rectangle.compress.vertical\" : \"rectangle.expand.vertical\"\n                )\n                .font(.callout)\n            }\n            .buttonStyle(.borderless)\n            .disabled(turns.isEmpty)\n            .help(autoExpandVisible ? \"Collapse visible turns\" : \"Expand only visible turns\")\n            .hoverHand()\n\n            // Refresh current conversation file (match borderless style for consistency)\n            Button {\n                Task { await reloadConversation() }\n            } label: {\n                Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n                    .font(.callout)\n            }\n            .buttonStyle(.borderless)\n            .help(\"Reload latest records from this session file\")\n            .hoverHand()\n\n            Button {\n                withAnimation(.easeInOut(duration: 0.2)) {\n                    // Expand/collapse conversation without altering sidebar visibility\n                    isConversationExpanded.toggle()\n                }\n            } label: {\n                Image(\n                    systemName: isConversationExpanded\n                        ? \"arrow.up.right.and.arrow.down.left\"  // show Restore icon\n                        : \"arrow.down.left.and.arrow.up.right\"  // show Expand icon\n                )\n                .font(.body)\n            }\n            .buttonStyle(.borderless)\n            .help(isConversationExpanded ? \"Restore layout\" : \"Expand conversation\")\n            .hoverHand()\n        }\n    }\n\n    private var conversationScrollView: some View {\n        Group {\n            switch loadingStage {\n            case .initial, .loading:\n                ScrollView {\n                    ProgressView(\"Loading session content…\")\n                        .frame(maxWidth: .infinity, alignment: .center)\n                        .padding(.top, 32)\n                }\n\n            case .preview:\n                ScrollView {\n                    if previewTurns.isEmpty {\n                        ProgressView(\"Loading preview…\")\n                            .frame(maxWidth: .infinity, alignment: .center)\n                            .padding(.top, 32)\n                    } else {\n                        VStack(alignment: .leading, spacing: 12) {\n                            ForEach(previewTurns) { preview in\n                                ConversationTurnPreviewCard(preview: preview, branding: summary.source.branding)\n                            }\n                        }\n                        .opacity(0.85)  // Visual hint that this is preview data\n                    }\n                }\n\n            case .full:\n                if turns.isEmpty {\n                    Group {\n                        if #available(macOS 14.0, *) {\n                            ContentUnavailableView(\"No messages to display\", systemImage: \"text.bubble\")\n                        } else {\n                            UnavailableStateView(\n                                \"No messages to display\",\n                                systemImage: \"text.bubble\",\n                                imageFont: .title3,\n                                titleFont: .headline\n                            )\n                        }\n                    }\n                    .frame(maxWidth: .infinity, alignment: .center)\n                } else {\n                    ConversationTimelineView(\n                        turns: turns,\n                        expandedTurnIDs: $expandedTurnIDs,\n                        refreshToken: timelineRefreshToken,\n                        ascending: true,  // Fixed: oldest first (newest at bottom)\n                        branding: summary.source.branding,\n                        allowManualToggle: !autoExpandVisible,\n                        autoExpandVisible: autoExpandVisible,\n                        isActive: isConversationActive,\n                        nowModeEnabled: nowModeEnabled,\n                        onNowModeChange: { newValue in\n                            nowModeEnabled = newValue\n                        }\n                    )\n                    .id(autoExpandVisible)\n                }\n            }\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)\n    }\n}\n\n// MARK: - Export\nextension SessionDetailView {\n    // Custom search field to ensure macOS compatibility\n    private var conversationSearchField: some View {\n        HStack(spacing: 6) {\n            Image(systemName: \"magnifyingglass\")\n                .foregroundStyle(.secondary)\n                .padding(.leading, 4)\n            TextField(\"Filter in conversation\", text: $searchText)\n                .textFieldStyle(.plain)\n                .frame(minWidth: 160)\n            if !searchText.isEmpty {\n                Button {\n                    searchText = \"\"\n                } label: {\n                    Image(systemName: \"xmark.circle.fill\")\n                        .foregroundStyle(.tertiary)\n                }\n                .buttonStyle(.plain)\n                .keyboardShortcut(.cancelAction)\n                .hoverHand()\n            }\n        }\n        .padding(.vertical, 6)\n        .padding(.horizontal, 6)\n        .background(\n            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                .fill(Color(nsColor: .textBackgroundColor))\n                .overlay(\n                    RoundedRectangle(cornerRadius: 8, style: .continuous)\n                        .stroke(Color.secondary.opacity(0.25), lineWidth: 1)\n                )\n        )\n        .frame(minWidth: 220)\n    }\n\n    private var inlineFiltersPanel: some View {\n        VStack(alignment: .leading, spacing: 12) {\n            HStack {\n                Label(\"Message Type\", systemImage: \"line.3.horizontal.decrease.circle\")\n                    .font(.headline)\n                Spacer()\n                if hasSessionVisibleKindsOverride {\n                    Button(\"Reset\") { resetInlineFilters() }\n                        .buttonStyle(.borderless)\n                        .help(\"Reset to global defaults and clear session overrides\")\n                }\n            }\n\n            ForEach(visibilityGroups, id: \\.title) { group in\n                VStack(alignment: .leading, spacing: 6) {\n                    Text(group.title)\n                        .font(.caption.weight(.semibold))\n                        .foregroundStyle(.secondary)\n                    LazyVGrid(columns: filterColumns, alignment: .leading, spacing: 8) {\n                        ForEach(group.items, id: \\.kind) { item in\n                            FilterToggleRow(\n                                title: item.title,\n                                isOn: visibilityBinding(for: item.kind)\n                            )\n                        }\n                    }\n                }\n            }\n        }\n        .padding(12)\n        .background(\n            RoundedRectangle(cornerRadius: 12, style: .continuous)\n                .fill(Color(nsColor: .controlBackgroundColor))\n                .overlay(\n                    RoundedRectangle(cornerRadius: 12, style: .continuous)\n                        .stroke(Color.primary.opacity(0.08), lineWidth: 1)\n                )\n        )\n        .shadow(color: Color.black.opacity(0.08), radius: 10, x: 0, y: 4)\n        .transition(.opacity.combined(with: .move(edge: .top)))\n    }\n\n    private struct VisibilityGroup {\n        let title: String\n        let items: [VisibilityItem]\n    }\n\n    private struct VisibilityItem: Hashable {\n        let kind: MessageVisibilityKind\n        let title: String\n    }\n\n    private struct FilterToggleRow: View {\n        let title: String\n        @Binding var isOn: Bool\n\n        var body: some View {\n            HStack(spacing: 8) {\n                Toggle(\"\", isOn: $isOn)\n                    .labelsHidden()\n                    .toggleStyle(.switch)\n                    .controlSize(.small)\n                Text(title)\n                    .font(.callout)\n                    .foregroundStyle(.primary)\n            }\n        }\n    }\n\n    private var filterColumns: [GridItem] {\n        [\n            GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading),\n            GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading),\n            GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading),\n            GridItem(.flexible(minimum: 120), spacing: 12, alignment: .leading)\n        ]\n    }\n\n    private var visibilityGroups: [VisibilityGroup] {\n        [\n            VisibilityGroup(title: \"Core\", items: [\n                VisibilityItem(kind: .user, title: MessageVisibilityKind.user.settingsLabel),\n                VisibilityItem(kind: .assistant, title: MessageVisibilityKind.assistant.settingsLabel)\n            ]),\n            VisibilityGroup(title: \"Reasoning & Edits\", items: [\n                VisibilityItem(kind: .reasoning, title: MessageVisibilityKind.reasoning.settingsLabel),\n                VisibilityItem(kind: .codeEdit, title: MessageVisibilityKind.codeEdit.settingsLabel)\n            ]),\n            VisibilityGroup(title: \"Tools & Tokens\", items: [\n                VisibilityItem(kind: .tool, title: MessageVisibilityKind.tool.settingsLabel),\n                VisibilityItem(kind: .tokenUsage, title: MessageVisibilityKind.tokenUsage.settingsLabel)\n            ]),\n            VisibilityGroup(title: \"Other Info\", items: [\n                VisibilityItem(kind: .infoOther, title: MessageVisibilityKind.infoOther.settingsLabel)\n            ])\n        ]\n    }\n\n    private func visibilityBinding(for kind: MessageVisibilityKind) -> Binding<Bool> {\n        Binding(\n            get: { sessionVisibleKinds.contains(kind) },\n            set: { isOn in\n                if isOn {\n                    sessionVisibleKinds.insert(kind)\n                } else {\n                    sessionVisibleKinds.remove(kind)\n                }\n                hasSessionVisibleKindsOverride = true\n                Task { await viewModel.updateTimelineVisibleKindsOverride(for: summary.id, kinds: sessionVisibleKinds) }\n                applyFilterAndSort()\n            }\n        )\n    }\n\n    private func resetInlineFilters() {\n        hasSessionVisibleKindsOverride = false\n        sessionVisibleKinds = preferences.timelineVisibleKinds\n        Task { await viewModel.clearTimelineVisibleKindsOverride(for: summary.id) }\n        applyFilterAndSort()\n    }\n\n    // MARK: - Loading helpers\n    private func initialLoadAndMonitor() async {\n        let override = viewModel.timelineVisibleKindsOverride(for: summary.id)\n        sessionVisibleKinds = override ?? preferences.timelineVisibleKinds\n        hasSessionVisibleKindsOverride = override != nil\n        autoExpandVisible = false\n        expandedTurnIDs.removeAll()\n        lastLocalChangeAt = nil\n        localActivityClearTask?.cancel()\n\n        // Stage 1: Try to load previews from cache (fast path)\n        if let previews = await viewModel.loadTimelinePreviews(for: summary) {\n            await MainActor.run {\n                previewTurns = previews\n                loadingStage = .preview\n            }\n        }\n\n        // Stage 2: Load full timeline in background\n        await reloadConversation(resetUI: true)\n\n        // Configure file monitor for live reload\n        monitor?.cancel()\n        monitor = DirectoryMonitor(url: summary.fileURL) { [fileURL = summary.fileURL] in\n            // Debounce rapid write events\n            debounceReloadTask?.cancel()\n            debounceReloadTask = Task { @MainActor in\n                let activityStamp = Date()\n                markLocalActivity(activityStamp)\n                try? await Task.sleep(nanoseconds: 300_000_000)  // 300ms\n                // Confirm file still the same session file\n                guard fileURL == summary.fileURL else { return }\n                await reloadConversation()\n            }\n        }\n    }\n\n    @MainActor\n    private func reloadConversation(resetUI: Bool = false) async {\n        loadingTimeline = true\n        if loadingStage == .initial {\n            loadingStage = .loading\n        }\n        defer { loadingTimeline = false }\n\n        loadTask?.cancel()\n\n        if let cached = await viewModel.cachedTimeline(for: summary) {\n            allTurns = cached\n            loadingStage = .full\n            if resetUI {\n                expandedTurnIDs = []\n                environmentExpanded = false\n                environmentInfo = nil\n                environmentLoading = false\n            }\n            applyFilterAndSort(markRefresh: true)\n            return\n        }\n\n        let shouldLoadDirectlyFromFile = summary.source.baseKind == .codex && !summary.source.isRemote\n        let loaded: [ConversationTurn]\n        if shouldLoadDirectlyFromFile {\n            let fileURL = summary.fileURL\n            let task: Task<[ConversationTurn], Never> = Task.detached(priority: .userInitiated) {\n                if Task.isCancelled { return [] }\n                let loader = SessionTimelineLoader()\n                return (try? loader.load(url: fileURL)) ?? []\n            }\n            loadTask = task\n            loaded = await task.value\n        } else {\n            loaded = await viewModel.timeline(for: summary)\n        }\n\n        loadTask = nil\n        allTurns = loaded\n        loadingStage = .full\n\n        if resetUI {\n            expandedTurnIDs = []\n            environmentExpanded = false\n            environmentInfo = nil\n            environmentLoading = false\n        }\n\n        applyFilterAndSort(markRefresh: true)\n\n        if !loaded.isEmpty {\n            Task {\n                await viewModel.storeTimeline(loaded, for: summary)\n                await viewModel.updateTimelinePreviews(for: summary, turns: loaded)\n            }\n        }\n    }\n\n    @MainActor\n    private func applyFilterAndSort(markRefresh: Bool = false) {\n        filterTask?.cancel()\n        let term = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()\n        let all = allTurns\n        let kinds = effectiveVisibleKinds\n        let expandOnSearch = expandAllOnSearch\n        let shouldMarkRefresh = markRefresh\n\n        filterTask = Task.detached(priority: .userInitiated) {\n            var filtered = all\n            if !term.isEmpty {\n                filtered = filtered.filter { turn in\n                    containsTerm(turn, term: term)\n                }\n            }\n            filtered = filtered.filtering(visibleKinds: kinds)\n            // Fixed: always sort oldest first (newest at bottom)\n            filtered.sort { a, b in a.timestamp < b.timestamp }\n            let result = filtered\n            await MainActor.run {\n                turns = result\n                if expandOnSearch {\n                    autoExpandVisible = true\n                    expandedTurnIDs.removeAll()\n                    expandAllOnSearch = false\n                }\n                if shouldMarkRefresh {\n                    timelineRefreshToken &+= 1\n                }\n            }\n        }\n    }\n\n    private var isConversationActive: Bool {\n        viewModel.isActivelyUpdating(summary.id) || isLocallyActive\n    }\n\n    private var isLocallyActive: Bool {\n        guard let lastLocalChangeAt else { return false }\n        return Date().timeIntervalSince(lastLocalChangeAt) < 3.0\n    }\n\n    private func markLocalActivity(_ stamp: Date) {\n        lastLocalChangeAt = stamp\n        localActivityClearTask?.cancel()\n        localActivityClearTask = Task { @MainActor in\n            try? await Task.sleep(nanoseconds: 3_200_000_000)\n            if lastLocalChangeAt == stamp {\n                lastLocalChangeAt = nil\n            }\n        }\n    }\n\n    private var effectiveVisibleKinds: Set<MessageVisibilityKind> {\n        if hasSessionVisibleKindsOverride {\n            return sessionVisibleKinds\n                .intersection(preferences.timelineVisibleKinds)\n                .subtracting([.turnContext])\n        }\n        return preferences.timelineVisibleKinds.subtracting([.turnContext])\n    }\n\n    private func exportMarkdown() {\n        let panel = NSSavePanel()\n        panel.title = \"Export Markdown\"\n        panel.allowedContentTypes = [.plainText]\n        let base = sanitizedExportFileName(summary.effectiveTitle, fallback: summary.displayName)\n        panel.nameFieldStringValue = base + \".md\"\n        if panel.runModal() == .OK, let url = panel.url {\n            let md = MarkdownExportBuilder.build(\n                session: summary,\n                turns: allTurns,\n                visibleKinds: preferences.markdownVisibleKinds,\n                exportURL: url\n            )\n            try? md.data(using: String.Encoding.utf8)?.write(to: url)\n        }\n    }\n\n}\n\n// MARK: - Helpers\nprivate func containsTerm(_ turn: ConversationTurn, term: String) -> Bool {\n    func contains(_ s: String?) -> Bool { (s ?? \"\").lowercased().contains(term) }\n    if contains(turn.userMessage?.text) { return true }\n    for e in turn.outputs {\n        if contains(e.title) || contains(e.text) { return true }\n        if let md = e.metadata,\n           md.values.contains(where: { $0.lowercased().contains(term) }) {\n            return true\n        }\n    }\n    return false\n}\n\nprivate func sanitizedExportFileName(_ s: String, fallback: String, maxLength: Int = 120) -> String\n{\n    var text = s.trimmingCharacters(in: .whitespacesAndNewlines)\n    if text.isEmpty { return fallback }\n    let disallowed = CharacterSet(charactersIn: \"/:\")\n        .union(.newlines)\n        .union(.controlCharacters)\n    text = text.unicodeScalars.map { disallowed.contains($0) ? Character(\" \") : Character($0) }\n        .reduce(into: String(), { $0.append($1) })\n    while text.contains(\"  \") { text = text.replacingOccurrences(of: \"  \", with: \" \") }\n    text = text.trimmingCharacters(in: .whitespacesAndNewlines)\n    if text.isEmpty { text = fallback }\n    if text.count > maxLength {\n        let idx = text.index(text.startIndex, offsetBy: maxLength)\n        text = String(text[..<idx])\n    }\n    return text\n}\n\n#if DEBUG\nprivate struct SessionDetailPreviewContainer: View {\n    @State private var visibility: NavigationSplitViewVisibility = .all\n    let summary: SessionSummary\n    let isProcessing: Bool\n\n    var body: some View {\n        SessionDetailView(\n            summary: summary,\n            isProcessing: isProcessing,\n            onResume: { print(\"Resume session\") },\n            onReveal: { print(\"Reveal in Finder\") },\n            onDelete: { print(\"Delete session\") },\n            columnVisibility: $visibility,\n            preferences: SessionPreferencesStore()\n        )\n    }\n}\n\n#Preview {\n    // Mock SessionSummary data\n    let mockSummary = SessionSummary(\n        id: \"session-123\",\n        fileURL: URL(fileURLWithPath: \"/Users/developer/.codex/sessions/session-123.json\"),\n        fileSizeBytes: 15420,\n        startedAt: Date().addingTimeInterval(-3600),  // 1 hour ago\n        endedAt: Date().addingTimeInterval(-1800),  // 30 minutes ago\n        activeDuration: nil,\n        cliVersion: \"1.2.3\",\n        cwd: \"/Users/developer/projects/codmate\",\n        originator: \"developer\",\n        instructions:\n            \"Please help optimize this SwiftUI app's performance, especially list scroll stutter.\",\n        model: \"gpt-4o-mini\",\n        approvalPolicy: \"auto\",\n        userMessageCount: 5,\n        assistantMessageCount: 4,\n        toolInvocationCount: 3,\n        responseCounts: [\"reasoning\": 2],\n        turnContextCount: 8,\n        totalTokens: 1200,\n        eventCount: 12,\n        lineCount: 156,\n        lastUpdatedAt: Date().addingTimeInterval(-1800),\n        source: .codexLocal,\n        remotePath: nil\n    )\n\n    SessionDetailPreviewContainer(summary: mockSummary, isProcessing: false)\n        .frame(width: 600, height: 800)\n}\n#endif\n\n// MARK: - Preview Card Component\n\n/// Lightweight preview card for conversation turns, shown during initial loading\nprivate struct ConversationTurnPreviewCard: View {\n    let preview: ConversationTurnPreview\n    let branding: SessionSourceBranding\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 8) {\n            // Header: timestamp and metadata badges\n            HStack(spacing: 8) {\n                Text(preview.timestamp, style: .time)\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n\n                if preview.hasToolCalls {\n                    Label(\"Tools\", systemImage: \"hammer.fill\")\n                        .font(.caption2)\n                        .foregroundStyle(.secondary)\n                        .labelStyle(.iconOnly)\n                }\n\n                if preview.hasThinking {\n                    Label(MessageVisibilityKind.reasoning.settingsLabel, systemImage: \"brain\")\n                        .font(.caption2)\n                        .foregroundStyle(.secondary)\n                        .labelStyle(.iconOnly)\n                }\n\n                Text(\"\\(preview.outputCount) output\\(preview.outputCount == 1 ? \"\" : \"s\")\")\n                    .font(.caption2)\n                    .foregroundStyle(.tertiary)\n\n                Spacer()\n            }\n\n            // User message preview\n            if let userPreview = preview.userPreview {\n                HStack(alignment: .top, spacing: 8) {\n                    Image(systemName: \"person.circle.fill\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n\n                    Text(userPreview)\n                        .font(.callout)\n                        .lineLimit(2)\n                        .foregroundStyle(.primary)\n                }\n            }\n\n            // Assistant/output preview\n            if let outputsPreview = preview.outputsPreview {\n                HStack(alignment: .top, spacing: 8) {\n                    Image(systemName: branding.symbolName)\n                        .font(.caption)\n                        .foregroundStyle(branding.iconColor)\n\n                    Text(outputsPreview)\n                        .font(.callout)\n                        .lineLimit(2)\n                        .foregroundStyle(.secondary)\n                }\n            }\n        }\n        .padding(12)\n        .background {\n            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                .fill(Color(nsColor: .controlBackgroundColor))\n        }\n        .overlay {\n            RoundedRectangle(cornerRadius: 8, style: .continuous)\n                .strokeBorder(Color(nsColor: .separatorColor), lineWidth: 0.5)\n        }\n    }\n}\n\n#if DEBUG\n#Preview(\"Processing State\") {\n    let mockSummary = SessionSummary(\n        id: \"session-456\",\n        fileURL: URL(fileURLWithPath: \"/Users/developer/.codex/sessions/session-456.json\"),\n        fileSizeBytes: 8200,\n        startedAt: Date().addingTimeInterval(-7200),\n        endedAt: nil,\n        activeDuration: nil,\n        cliVersion: \"1.2.3\",\n        cwd: \"/Users/developer/projects/test\",\n        originator: \"developer\",\n        instructions: \"Create a simple to-do app\",\n        model: \"gpt-4o\",\n        approvalPolicy: \"manual\",\n        userMessageCount: 3,\n        assistantMessageCount: 2,\n        toolInvocationCount: 1,\n        responseCounts: [:],\n        turnContextCount: 5,\n        totalTokens: 650,\n        eventCount: 6,\n        lineCount: 89,\n        lastUpdatedAt: Date().addingTimeInterval(-300),\n        source: .codexLocal,\n        remotePath: nil\n    )\n\n    SessionDetailPreviewContainer(summary: mockSummary, isProcessing: true)\n        .frame(width: 600, height: 800)\n}\n#endif\n"
  },
  {
    "path": "views/SessionListColumnView.swift",
    "content": "import AppKit\nimport SwiftUI\n\nstruct SessionListColumnView: View {\n  let sections: [SessionDaySection]\n  @Binding var selection: Set<SessionSummary.ID>\n  @Binding var sortOrder: SessionSortOrder\n  let isLoading: Bool\n  let isEnriching: Bool\n  let enrichmentProgress: Int\n  let enrichmentTotal: Int\n  let onResume: (SessionSummary) -> Void\n  let onReveal: (SessionSummary) -> Void\n  let onDeleteRequest: (SessionSummary) -> Void\n  let onExportMarkdown: (SessionSummary) -> Void\n  // running state probe\n  var isRunning: ((SessionSummary) -> Bool)? = nil\n  // live updating probe (file activity)\n  var isUpdating: ((SessionSummary) -> Bool)? = nil\n  // awaiting follow-up probe\n  var isAwaitingFollowup: ((SessionSummary) -> Bool)? = nil\n  // notify which item is the user's primary (last clicked) for detail focus\n  var onPrimarySelect: ((SessionSummary) -> Void)? = nil\n  // callback for launching new session with task context\n  var onNewSessionWithTaskContext: ((CodMateTask, SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil\n  @EnvironmentObject private var viewModel: SessionListViewModel\n  @Environment(\\.colorScheme) private var colorScheme\n  @State private var showNewProjectSheet = false\n  @State private var draftTaskFromSession: CodMateTask? = nil\n  @State private var taskEditingMode: EditTaskSheet.Mode = .edit\n  @State private var newProjectPrefill: ProjectEditorSheet.Prefill? = nil\n  @State private var newProjectAssignIDs: [String] = []\n  @State private var lastClickedID: String? = nil\n  @State private var containerWidth: CGFloat = 0\n  @FocusState private var quickSearchFocused: Bool\n\n  var body: some View {\n    VStack(spacing: 0) {\n      header\n        .padding(.horizontal, 8)\n        .padding(.top, 0)\n        .padding(.bottom, 8)\n\n      contentView\n    }\n    .padding(.vertical, 16)\n    .padding(.horizontal, 6)\n    .sheet(isPresented: $showNewProjectSheet) {\n      ProjectEditorSheet(\n        isPresented: $showNewProjectSheet,\n        mode: .new,\n        prefill: newProjectPrefill,\n        autoAssignSessionIDs: newProjectAssignIDs\n      )\n      .environmentObject(viewModel)\n    }\n    .sheet(item: $draftTaskFromSession) { task in\n      if let workspaceVM = viewModel.workspaceVM {\n        EditTaskSheet(\n          task: task,\n          mode: taskEditingMode,\n          workspaceVM: workspaceVM,\n          onSave: { updatedTask in\n            Task {\n              await workspaceVM.updateTask(updatedTask)\n              draftTaskFromSession = nil\n            }\n          },\n          onCancel: {\n            draftTaskFromSession = nil\n          }\n        )\n      }\n    }\n    .background(\n      GeometryReader { geo in\n        Color.clear\n          .preference(key: ListColumnWidthKey.self, value: geo.size.width)\n      }\n    )\n    .onPreferenceChange(ListColumnWidthKey.self) { w in\n      containerWidth = w\n    }\n  }\n\n  @ViewBuilder\n  private var contentView: some View {\n    // In Tasks mode, show TaskListView instead of regular sessions list\n    if viewModel.projectWorkspaceMode == .tasks, let workspaceVM = viewModel.workspaceVM {\n      TaskListView(\n        workspaceVM: workspaceVM,\n        selection: $selection,\n        onResume: onResume,\n        onReveal: onReveal,\n        onDeleteRequest: onDeleteRequest,\n        onExportMarkdown: onExportMarkdown,\n        isRunning: isRunning,\n        isUpdating: isUpdating,\n        isAwaitingFollowup: isAwaitingFollowup,\n        onPrimarySelect: onPrimarySelect,\n        onNewSessionWithTaskContext: onNewSessionWithTaskContext\n      )\n    } else {\n      // Regular sessions list for other modes\n      if sections.isEmpty {\n        if isLoading {\n          VStack {\n            Spacer()\n            ProgressView(\"Scanning…\")\n            Spacer()\n          }\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n          .padding(.horizontal, -2)\n        } else {\n          emptyStateView\n            .padding(.horizontal, -2)\n        }\n      } else {\n        sessionsListView\n      }\n    }\n  }\n\n  private var emptyStateView: some View {\n    let selected = selectedProject()\n    let isOtherProject = selected?.id == SessionListViewModel.otherProjectId\n\n    return ZStack {\n      Color.clear\n      \n      VStack(spacing: 12) {\n        Spacer(minLength: 12)\n\n        // Different message for Other project bucket\n        if isOtherProject {\n          Group {\n            if #available(macOS 14.0, *) {\n              unavailableView(\n                title: \"No Unassigned Sessions\",\n                systemImage: \"tray\",\n                description:\n                  \"Sessions can only be created within a project. Select a project from the sidebar to start a new session.\"\n              )\n            } else {\n              fallbackUnavailableView(\n                title: \"No Unassigned Sessions\",\n                systemImage: \"tray\",\n                description:\n                  \"Sessions can only be created within a project. Select a project from the sidebar to start a new session.\"\n              )\n            }\n          }\n          .frame(maxWidth: .infinity)\n        } else {\n          Group {\n            if #available(macOS 14.0, *) {\n              unavailableView(\n                title: \"No Sessions\",\n                systemImage: \"tray\",\n                description: \"Adjust directories or launch Codex CLI to generate new session logs.\"\n              )\n            } else {\n              fallbackUnavailableView(\n                title: \"No Sessions\",\n                systemImage: \"tray\",\n                description: \"Adjust directories or launch Codex CLI to generate new session logs.\"\n              )\n            }\n          }\n          .frame(maxWidth: .infinity)\n        }\n\n        // Primary action: New (hidden for Other project, shown for regular projects)\n        if let project = selected, !isOtherProject {\n          let embeddedPreferredNew =\n            viewModel.preferences.defaultResumeUseEmbeddedTerminal && !AppSandbox.isEnabled\n          let anchor = projectAnchor(for: project)\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {\n              if embeddedPreferredNew {\n                // Defer to shared embedded flow (exactly as detail bar does)\n                viewModel.newSession(project: project)\n              } else {\n                startExternalNewForProject(project)\n              }\n            },\n            items: buildNewMenuItems(anchor: anchor, project: project)\n          )\n          .help(\"Start a new session in \\(projectDisplayName(project))\")\n        } else if !isOtherProject {\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {},\n            items: []\n          )\n          .opacity(0.6)\n          .help(\"Select a project in the sidebar to start a new session\")\n        }\n\n        Spacer()\n      }\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    .contentShape(Rectangle())\n    .contextMenu { backgroundContextMenu() }\n  }\n\n  @available(macOS 14.0, *)\n  private func unavailableView(title: String, systemImage: String, description: String) -> some View {\n    ContentUnavailableView(title, systemImage: systemImage, description: Text(description))\n  }\n\n  private func fallbackUnavailableView(title: String, systemImage: String, description: String)\n    -> some View\n  {\n    UnavailableStateView(\n      title,\n      systemImage: systemImage,\n      description: description,\n      titleColor: .primary\n    )\n  }\n\n  @ViewBuilder\n  private var sessionsListView: some View {\n    List(selection: $selection) {\n      ForEach(sections) { section in\n        Section {\n          ForEach(section.sessions, id: \\.id) { session in\n            sessionRow(for: session)\n          }\n        } header: {\n          HStack {\n            Text(section.title)\n            Spacer()\n            Label(section.totalDuration.readableFormattedDuration, systemImage: \"clock\")\n            Label(\"\\(section.totalEvents)\", systemImage: \"chart.bar\")\n          }\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n        }\n      }\n    }\n    .padding(.horizontal, -2)\n    .listStyle(.inset)\n    .contextMenu { backgroundContextMenu() }\n  }\n\n  @ViewBuilder\n  private func sessionRow(for session: SessionSummary) -> some View {\n    EquatableSessionListRow(\n      summary: session,\n      isRunning: isRunning?(session) ?? false,\n      isSelected: selectionContains(session.id),\n      isUpdating: isUpdating?(session) ?? false,\n      awaitingFollowup: isAwaitingFollowup?(session) ?? false,\n      inProject: viewModel.projectIdForSession(session.id) != nil,\n      projectTip: projectTip(for: session),\n      inTaskContainer: false\n    )\n    .tag(session.id)\n    .contentShape(Rectangle())\n    .onTapGesture(count: 2) {\n      selection = [session.id]\n      onPrimarySelect?(session)\n      Task { await viewModel.beginEditing(session: session) }\n    }\n    .onTapGesture { handleClick(on: session) }\n    .onDrag {\n      let ids: [String]\n      if selectionContains(session.id) && selection.count > 1 {\n        ids = Array(selection)\n      } else {\n        ids = [session.id]\n      }\n      let payloads: [String] = ids.compactMap { id in\n        if let summary = viewModel.sessionSummary(for: id) {\n          return viewModel.sessionDragIdentifier(for: summary)\n        }\n        return id\n      }\n      return NSItemProvider(object: payloads.joined(separator: \"\\n\") as NSString)\n    }\n    .listRowInsets(EdgeInsets())\n    .contextMenu {\n      sessionContextMenu(for: session)\n    }\n  }\n\n  @ViewBuilder\n  private func sessionContextMenu(for session: SessionSummary) -> some View {\n    let project = projectForSession(session)\n\n    if session.source == .codexLocal || session.source == .geminiLocal {\n      let resumeItems = buildResumeMenuItems(for: session)\n      if !resumeItems.isEmpty {\n        Menu { SplitMenuItemsView(items: resumeItems) } label: {\n          let icon = assetIconForSessionSource(session.source)\n          Label {\n            Text(\"Resume\")\n          } icon: {\n            if let menuIcon = menuAssetNSImage(\n              named: icon,\n              invertForDarkMode: icon == \"ChatGPTIcon\" && colorScheme == .dark\n            ) {\n              Image(nsImage: menuIcon)\n                .frame(width: 14, height: 14)\n            } else {\n              Image(icon)\n                .resizable()\n                .scaledToFit()\n                .frame(width: 14, height: 14)\n                .clipped()\n                .modifier(DarkModeInvertModifier(active: icon == \"ChatGPTIcon\" && colorScheme == .dark))\n            }\n          }\n        }\n      }\n    }\n    Divider()\n    Button {\n      Task { await viewModel.beginEditing(session: session) }\n    } label: {\n      Label(\"Edit Title & Comment\", systemImage: \"pencil\")\n    }\n    Button {\n      Task { @MainActor in\n        await viewModel.generateTitleAndComment(for: session, force: false)\n      }\n    } label: {\n      Label(\"Generate Title & Comment\", systemImage: \"sparkles\")\n    }\n\n    if let project, project.id != SessionListViewModel.otherProjectId {\n      let newItems = buildNewMenuItems(anchor: session, project: project)\n      if newItems.isEmpty {\n        Button {\n          viewModel.newSession(project: project)\n        } label: {\n          Label(\"New Session\", systemImage: \"plus\")\n        }\n      } else {\n        Menu {\n          SplitMenuItemsView(items: newItems)\n        } label: {\n          Label(\"New Session…\", systemImage: \"plus\")\n        }\n      }\n      Button {\n        draftTaskFromSession = CodMateTask(\n          title: \"\",\n          description: nil,\n          projectId: project.id,\n          sessionIds: [session.id]\n        )\n      } label: {\n        Label(\"New Task…\", systemImage: \"checklist\")\n      }\n    }\n\n    if !viewModel.projects.isEmpty {\n      Menu {\n        Button {\n          newProjectPrefill = prefillForProject(from: session)\n          newProjectAssignIDs = [session.id]\n          showNewProjectSheet = true\n        } label: {\n          Label(\"New Project…\", systemImage: \"square.grid.2x2\")\n        }\n        Divider()\n        ForEach(viewModel.projects) { p in\n          Button(p.name.isEmpty ? p.id : p.name) {\n            Task { await viewModel.assignSessions(to: p.id, ids: [session.id]) }\n          }\n        }\n      } label: {\n        Label(\"Assign to Project…\", systemImage: \"rectangle.stack.badge.plus\")\n      }\n    }\n    Button {\n      onExportMarkdown(session)\n    } label: {\n      Label(\"Export Markdown\", systemImage: \"square.and.arrow.up\")\n    }\n    Divider()\n    Button {\n      copyAbsolutePath(session)\n    } label: {\n      Label(\"Copy Absolute Path\", systemImage: \"doc.on.doc\")\n    }\n    Button {\n      onReveal(session)\n    } label: {\n      Label(\"Reveal in Finder\", systemImage: \"finder\")\n    }\n    Button(role: .destructive) {\n      if !selectionContains(session.id) {\n        selection = [session.id]\n      }\n      onDeleteRequest(session)\n    } label: {\n      let isBatchDelete = selectionContains(session.id) && selection.count > 1\n      Label(\n        isBatchDelete ? \"Move Sessions to Trash\" : \"Move Session to Trash\",\n        systemImage: \"trash\")\n    }\n\n    if shouldShowTaskCollapseControls {\n      Divider()\n      Button {\n        postTaskCollapseNotification(.codMateCollapseAllTasks)\n      } label: {\n        Label(\"Collapse all Tasks\", systemImage: \"arrow.down.right.and.arrow.up.left\")\n      }\n      Button {\n        postTaskCollapseNotification(.codMateExpandAllTasks)\n      } label: {\n        Label(\"Expand all Tasks\", systemImage: \"arrow.up.left.and.arrow.down.right\")\n      }\n    }\n  }\n\n  private var header: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      // Quick search with optional Task collapse controls in Tasks mode\n      HStack(spacing: 8) {\n        HStack(spacing: 6) {\n          Image(systemName: \"magnifyingglass\")\n            .foregroundStyle(.secondary)\n            .padding(.leading, 4)\n          TextField(\"Search title or comment\", text: $viewModel.quickSearchText)\n            .textFieldStyle(.plain)\n            .focused($quickSearchFocused)\n            .onSubmit {\n              viewModel.immediateApplyQuickSearch(viewModel.quickSearchText)\n            }\n          if !viewModel.quickSearchText.isEmpty {\n            Button {\n              viewModel.quickSearchText = \"\"\n            } label: {\n              Image(systemName: \"xmark.circle.fill\").foregroundStyle(.tertiary)\n            }\n            .buttonStyle(.plain)\n          }\n        }\n        .padding(.vertical, 6)\n        .padding(.horizontal, 6)\n        .background(\n          RoundedRectangle(cornerRadius: 8, style: .continuous)\n            .fill(Color(nsColor: .textBackgroundColor))\n            .overlay(\n              RoundedRectangle(cornerRadius: 8, style: .continuous)\n                .stroke(Color.secondary.opacity(0.25), lineWidth: 1)\n            )\n        )\n        .frame(maxWidth: .infinity)\n        // 当全局搜索触发时，确保本地搜索框让出焦点，避免与 Cmd+F 竞争\n        .onReceive(NotificationCenter.default.publisher(for: .codMateFocusGlobalSearch)) { _ in\n          quickSearchFocused = false\n        }\n\n        if shouldShowTaskCollapseControls {\n          CollapseExpandButtonGroup(\n            collapseHelp: \"Collapse all Tasks\",\n            expandHelp: \"Expand all Tasks\",\n            onCollapse: { postTaskCollapseNotification(.codMateCollapseAllTasks) },\n            onExpand: { postTaskCollapseNotification(.codMateExpandAllTasks) }\n          )\n        }\n      }\n\n      HStack(spacing: 8) {\n        EqualWidthSegmentedControl(\n          items: Array(SessionSortOrder.allCases),\n          selection: $sortOrder,\n          title: { $0.title }\n        )\n        .frame(maxWidth: .infinity)\n      }\n      .transition(.opacity.combined(with: .move(edge: .leading)))\n    }\n    .frame(maxWidth: .infinity)\n  }\n}\n\nextension SessionListColumnView {\n  fileprivate var shouldShowTaskCollapseControls: Bool {\n    viewModel.projectWorkspaceMode == .tasks && viewModel.workspaceVM != nil\n  }\n\n  fileprivate func postTaskCollapseNotification(_ name: Notification.Name) {\n    var info: [AnyHashable: Any]? = nil\n    if let projectId = viewModel.selectedProjectIDs.first { info = [\"projectId\": projectId] }\n    NotificationCenter.default.post(name: name, object: nil, userInfo: info)\n  }\n}\n\nprivate struct ListColumnWidthKey: PreferenceKey {\n  static var defaultValue: CGFloat = 0\n  static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {\n    value = nextValue()\n  }\n}\n\nextension SessionListColumnView {\n  private func selectedProject() -> Project? {\n    guard viewModel.selectedProjectIDs.count == 1,\n      let pid = viewModel.selectedProjectIDs.first\n    else { return nil }\n\n    // Check if it's the synthetic Other project\n    if pid == SessionListViewModel.otherProjectId {\n      return Project(\n        id: SessionListViewModel.otherProjectId,\n        name: \"Other\",\n        directory: nil,\n        trustLevel: nil,\n        overview: nil,\n        instructions: nil,\n        profileId: nil,\n        profile: nil,\n        parentId: nil,\n        sources: ProjectSessionSource.allSet\n      )\n    }\n\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  private func projectDisplayName(_ p: Project) -> String {\n    let trimmed = p.name.trimmingCharacters(in: .whitespacesAndNewlines)\n    if !trimmed.isEmpty { return trimmed }\n    if let dir = p.directory, !dir.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {\n      let base = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent\n      return base.isEmpty ? p.id : base\n    }\n    return p.id\n  }\n\n  private func projectForSession(_ session: SessionSummary) -> Project? {\n    guard let pid = viewModel.projectIdForSession(session.id) else { return nil }\n    if pid == SessionListViewModel.otherProjectId { return nil }\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  func selectionContains(_ id: SessionSummary.ID) -> Bool {\n    selection.contains(id)\n  }\n\n  private func backgroundContextMenu() -> some View {\n    let project = selectedProject()\n    let anchor = project.flatMap { projectAnchor(for: $0) }\n    return Group {\n      if let project {\n        newSessionMenu(for: project, anchor: anchor)\n        if viewModel.workspaceVM != nil {\n          Button {\n            taskEditingMode = .new\n            draftTaskFromSession = CodMateTask(title: \"\", description: nil, projectId: selectedProject()?.id ?? \"\")\n          } label: {\n            Label(\"New Task…\", systemImage: \"checklist\")\n          }\n        }\n      }\n      if shouldShowTaskCollapseControls {\n        Divider()\n        Button {\n          postTaskCollapseNotification(.codMateCollapseAllTasks)\n        } label: {\n          Label(\"Collapse all Tasks\", systemImage: \"arrow.down.right.and.arrow.up.left\")\n        }\n        Button {\n          postTaskCollapseNotification(.codMateExpandAllTasks)\n        } label: {\n          Label(\"Expand all Tasks\", systemImage: \"arrow.up.left.and.arrow.down.right\")\n        }\n      }\n    }\n  }\n\n  private func projectAnchor(for project: Project) -> SessionSummary? {\n    // Prefer currently visible sessions for this project; fall back to any cached session.\n    if let visible = sections.flatMap({ $0.sessions }).first(\n      where: { viewModel.projectIdForSession($0.id) == project.id })\n    {\n      return visible\n    }\n    return viewModel.allSessions.first { viewModel.projectIdForSession($0.id) == project.id }\n  }\n\n  // Build external Terminal flow exactly like newSession(project:) external branch,\n  // but force external when App Sandbox blocks embedded terminals.\n  private func startExternalNewForProject(_ project: Project) {\n    guard\n      let profile = ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n        id: viewModel.preferences.defaultResumeExternalAppId\n      )\n    else { return }\n    let dir: String = {\n      let d = (project.directory ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n      return d.isEmpty ? NSHomeDirectory() : d\n    }()\n    let command = buildProjectCommand(project: project, directory: dir)\n    if profile.usesWarpCommands {\n      guard viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile)\n      else { return }\n      viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir)\n    } else {\n      if profile.isNone {\n        _ = viewModel.copyNewProjectCommandsIfEnabled(project: project, destinationApp: profile)\n        if viewModel.shouldCopyCommandsToClipboard\n          && viewModel.preferences.commandCopyNotificationsEnabled\n        {\n          Task {\n            await SystemNotifier.shared.notify(\n              title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n          }\n        }\n        return\n      }\n      if !profile.supportsCommandResolved, viewModel.shouldCopyCommandsToClipboard {\n        let pb = NSPasteboard.general\n        pb.clearContents()\n        pb.setString(command + \"\\n\", forType: .string)\n      }\n      if profile.isTerminal {\n        _ = viewModel.openAppleTerminal(at: dir)\n      } else {\n        let cmd = profile.supportsCommandResolved ? command : nil\n        viewModel.openPreferredTerminalViaScheme(profile: profile, directory: dir, command: cmd)\n      }\n    }\n    if viewModel.shouldCopyCommandsToClipboard && viewModel.preferences.commandCopyNotificationsEnabled {\n      Task {\n        await SystemNotifier.shared.notify(\n          title: \"CodMate\", body: \"Command copied. Paste it in the opened terminal.\")\n      }\n    }\n    // Hint + targeted refresh aligns with viewModel.newSession external path\n    viewModel.setIncrementalHintForCodexToday()\n    Task { await viewModel.refreshIncrementalForNewCodexToday() }\n  }\n\n  private func buildProjectCommand(project: Project, directory: String) -> String {\n    let cd = \"cd \" + directory.replacingOccurrences(of: \" \", with: \"\\\\ \")\n    let cmd = viewModel.buildNewProjectCLIInvocation(project: project)\n    return cd + \"\\n\" + cmd\n  }\n\n  private func projectTip(for session: SessionSummary) -> String? {\n    guard let pid = viewModel.projectIdForSession(session.id),\n      let p = viewModel.projects.first(where: { $0.id == pid })\n    else { return nil }\n    let name = p.name.trimmingCharacters(in: .whitespacesAndNewlines)\n    let display = name.isEmpty ? p.id : name\n    let raw = (p.overview ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !raw.isEmpty else { return display }\n    let snippet = raw.count > 20 ? String(raw.prefix(20)) + \"…\" : raw\n    return display + \"\\n\" + snippet\n  }\n\n  private func prefillForProject(from session: SessionSummary) -> ProjectEditorSheet.Prefill {\n    let dir =\n      FileManager.default.fileExists(atPath: session.cwd)\n      ? session.cwd\n      : session.fileURL.deletingLastPathComponent().path\n    var name = session.userTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? \"\"\n    if name.isEmpty { name = URL(fileURLWithPath: dir, isDirectory: true).lastPathComponent }\n    // overview: prefer userComment; fallback instruction snippet\n    let overview =\n      (session.userComment?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n        $0.isEmpty ? nil : $0\n      }\n      ?? (session.instructions?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {\n        s in\n        if s.isEmpty { return nil }\n        // limit to ~220 chars to keep it short\n        return s.count <= 220 ? s : String(s.prefix(220)) + \"…\"\n      }\n    return ProjectEditorSheet.Prefill(\n      name: name,\n      directory: dir,\n      trustLevel: nil,\n      overview: overview,\n      profileId: nil\n    )\n  }\n\n  private func handleClick(on session: SessionSummary) {\n    // Determine current modifiers (command/control/shift)\n    let mods = NSApp.currentEvent?.modifierFlags ?? []\n    let isToggle = mods.contains(.command) || mods.contains(.control)\n    let isRange = mods.contains(.shift)\n    let id = session.id\n    if isRange, let anchor = lastClickedID {\n      let flat = sections.flatMap { $0.sessions.map(\\.id) }\n      if let a = flat.firstIndex(of: anchor), let b = flat.firstIndex(of: id) {\n        let lo = min(a, b)\n        let hi = max(a, b)\n        let rangeIDs = Set(flat[lo...hi])\n        selection = rangeIDs\n      } else {\n        selection = [id]\n      }\n      onPrimarySelect?(session)\n    } else if isToggle {\n      if selection.contains(id) {\n        selection.remove(id)\n      } else {\n        selection.insert(id)\n      }\n      lastClickedID = id\n      onPrimarySelect?(session)\n    } else {\n      selection = [id]\n      lastClickedID = id\n      onPrimarySelect?(session)\n    }\n  }\n\n  private func workingDirectory(for session: SessionSummary) -> String {\n    viewModel.resolvedWorkingDirectory(for: session)\n  }\n\n  private func assetIconForSessionSource(_ source: SessionSource) -> String {\n    switch source.baseKind {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    }\n  }\n\n  private func buildResumeMenuItems(for session: SessionSummary) -> [SplitMenuItem] {\n    var items: [SplitMenuItem] = []\n\n    if viewModel.preferences.isEmbeddedTerminalEnabled {\n      items.append(\n        SplitMenuItem(\n          id: \"resume-embedded-\\(session.id)\",\n          kind: .action(\n            title: \"CodMate\",\n            systemImage: \"macwindow\",\n            run: {\n              NotificationCenter.default.post(\n                name: .codMateResumeSession,\n                object: nil,\n                userInfo: [\"sessionId\": session.id, \"forceEmbedded\": true]\n              )\n            }\n          )\n        )\n      )\n    }\n\n    for profile in externalTerminalOrderedProfiles(includeNone: false) {\n      items.append(\n        SplitMenuItem(\n          id: \"resume-\\(profile.id)-\\(session.id)\",\n          kind: .action(\n            title: profile.displayTitle,\n            systemImage: \"terminal\",\n            run: {\n              NotificationCenter.default.post(\n                name: .codMateResumeSession,\n                object: nil,\n                userInfo: [\"sessionId\": session.id, \"profileId\": profile.id]\n              )\n            }\n          )\n        )\n      )\n    }\n\n    return items\n  }\n\n  private func launchNewSession(\n    for session: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    let dir = workingDirectory(for: session)\n    viewModel.launchNewSessionWithProfile(\n      session: session,\n      using: source,\n      profile: profile,\n      workingDirectory: dir\n    )\n  }\n\n  private func copyAbsolutePath(_ session: SessionSummary) {\n    let pb = NSPasteboard.general\n    pb.clearContents()\n    pb.setString(session.fileURL.path, forType: .string)\n  }\n\n  // Build menu items matching Timeline “New” split control for a given session anchor.\n  private func buildNewMenuItems(anchor: SessionSummary?, project: Project? = nil)\n    -> [SplitMenuItem]\n  {\n    let allowed: Set<ProjectSessionSource>\n    if let anchor {\n      allowed = Set(viewModel.allowedSources(for: anchor))\n    } else if let project {\n      let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources\n      allowed = Set(sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n    } else {\n      allowed = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n    }\n    let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini]\n    let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted()\n\n    func sourceKey(_ source: SessionSource) -> String {\n      switch source {\n      case .codexLocal: return \"codex-local\"\n      case .codexRemote(let host): return \"codex-\\(host)\"\n      case .claudeLocal: return \"claude-local\"\n      case .claudeRemote(let host): return \"claude-\\(host)\"\n      case .geminiLocal: return \"gemini-local\"\n      case .geminiRemote(let host): return \"gemini-\\(host)\"\n      }\n    }\n\n    func launchItems(for source: SessionSource) -> [SplitMenuItem] {\n      let key = sourceKey(source)\n      var items = externalTerminalMenuItems(idPrefix: key) { profile in\n        if let anchor {\n          launchNewSession(for: anchor, using: source, profile: profile)\n        } else if let project {\n          viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile)\n        }\n      }\n      if viewModel.preferences.isEmbeddedTerminalEnabled {\n        let embedded = embeddedTerminalProfile()\n        items.insert(\n          SplitMenuItem(\n            id: \"\\(key)-\\(embedded.id)\",\n            kind: .action(\n              title: embedded.displayTitle,\n              systemImage: \"macwindow\",\n              run: {\n                if let anchor {\n                  launchNewSession(for: anchor, using: source, profile: embedded)\n                } else if let project {\n                  viewModel.launchNewSessionFromProject(\n                    project: project, using: source, profile: embedded)\n                }\n              })\n          ), at: 0)\n      }\n      return items\n    }\n\n    func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource {\n      switch base {\n      case .codex: return .codexRemote(host: host)\n      case .claude: return .claudeRemote(host: host)\n      case .gemini: return .geminiRemote(host: host)\n      }\n    }\n\n    func providerAssetIcon(_ source: ProjectSessionSource) -> String {\n      switch source {\n      case .codex: return \"ChatGPTIcon\"\n      case .claude: return \"ClaudeIcon\"\n      case .gemini: return \"GeminiIcon\"\n      }\n    }\n\n    var menuItems: [SplitMenuItem] = []\n    for base in requestedOrder where allowed.contains(base) {\n      var providerItems = launchItems(for: base.sessionSource)\n      if !enabledRemoteHosts.isEmpty {\n        providerItems.append(.init(kind: .separator))\n        for host in enabledRemoteHosts {\n          let remote = remoteSource(for: base, host: host)\n          providerItems.append(\n            .init(\n              id: \"remote-\\(base.rawValue)-\\(host)\",\n              kind: .submenu(title: host, systemImage: \"network\", items: launchItems(for: remote))\n            ))\n        }\n      }\n      menuItems.append(\n        .init(\n          id: \"provider-\\(base.rawValue)\",\n          kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems)\n        ))\n    }\n\n    if menuItems.isEmpty, let anchor {\n      let fallback = anchor.source\n      menuItems.append(\n        .init(\n          id: \"fallback-\\(sourceKey(fallback))\",\n          kind: .submenu(\n            title: fallback.branding.displayName,\n            systemImage: \"terminal\",\n            items: launchItems(for: fallback)\n          )))\n    }\n    return menuItems\n  }\n\n  private func newSessionMenu(for project: Project, anchor: SessionSummary?) -> some View {\n    let items = buildNewMenuItems(anchor: anchor, project: project)\n    return Menu {\n      SplitMenuItemsView(items: items)\n    } label: {\n      Label(\"New Session…\", systemImage: \"plus\")\n    }\n  }\n}\n\n// SplitPrimaryMenuButton and helpers are shared in SplitControls.swift\n\n// Native NSSearchField wrapper to get unified macOS search field chrome\nprivate struct SearchField: NSViewRepresentable {\n  let placeholder: String\n  @Binding var text: String\n  var onSubmit: ((String) -> Void)? = nil\n\n  init(_ placeholder: String, text: Binding<String>, onSubmit: ((String) -> Void)? = nil) {\n    self.placeholder = placeholder\n    self._text = text\n    self.onSubmit = onSubmit\n  }\n\n  func makeCoordinator() -> Coordinator { Coordinator(self) }\n\n  func makeNSView(context: Context) -> NSSearchField {\n    let field = NSSearchField(frame: .zero)\n    field.placeholderString = placeholder\n    field.delegate = context.coordinator\n    field.focusRingType = .none\n    // Avoid premature submit during IME composition; we handle Return/Escape in delegate instead\n    field.sendsSearchStringImmediately = false\n    field.sendsWholeSearchString = true\n    // Do not steal initial focus; if the system puts focus here, drop it back to window\n    DispatchQueue.main.async {\n      if let win = field.window,\n        win.firstResponder === field || win.firstResponder === field.currentEditor()\n      {\n        win.makeFirstResponder(nil)\n      }\n    }\n    context.coordinator.configure(field: field)\n    return field\n  }\n\n  func updateNSView(_ nsView: NSSearchField, context: Context) {\n    // Avoid programmatic writes while user is editing (prevents breaking IME composition)\n    if let editor = nsView.currentEditor(), nsView.window?.firstResponder === editor { return }\n    if nsView.stringValue != text { nsView.stringValue = text }\n    if nsView.placeholderString != placeholder { nsView.placeholderString = placeholder }\n  }\n\n  class Coordinator: NSObject, NSSearchFieldDelegate {\n    var parent: SearchField\n    weak var field: NSSearchField?\n    private var observers: [NSObjectProtocol] = []\n    private var isFocusBlocked = false\n    init(_ parent: SearchField) { self.parent = parent }\n\n    deinit {\n      for observer in observers {\n        NotificationCenter.default.removeObserver(observer)\n      }\n    }\n\n    func configure(field: NSSearchField) {\n      self.field = field\n      field.refusesFirstResponder = isFocusBlocked\n      if observers.isEmpty {\n        let center = NotificationCenter.default\n        let resign = center.addObserver(\n          forName: .codMateResignQuickSearch,\n          object: nil,\n          queue: .main\n        ) { [weak self] _ in self?.resignIfNeeded() }\n        let block = center.addObserver(\n          forName: .codMateQuickSearchFocusBlocked,\n          object: nil,\n          queue: .main\n        ) { [weak self] note in\n          Task { @MainActor in self?.handleFocusBlocked(note: note) }\n        }\n        observers.append(contentsOf: [resign, block])\n      }\n    }\n\n    private func resignIfNeeded() {\n      guard let field, let window = field.window else { return }\n      if window.firstResponder === field || window.firstResponder === field.currentEditor() {\n        window.makeFirstResponder(nil)\n      }\n    }\n\n    @MainActor\n    private func handleFocusBlocked(note: Notification) {\n      let active = (note.userInfo?[\"active\"] as? Bool) ?? false\n      isFocusBlocked = active\n      field?.refusesFirstResponder = active\n      if active { resignIfNeeded() }\n    }\n\n    @MainActor\n    func controlTextDidChange(_ notification: Notification) {\n      guard let field = notification.object as? NSSearchField else { return }\n      // Skip updates while IME is composing (marked text present)\n      if let editor = field.currentEditor() as? NSTextView, editor.hasMarkedText() { return }\n      parent.text = field.stringValue\n    }\n\n    @MainActor\n    func searchFieldDidEndSearching(_ sender: NSSearchField) {\n      let value = sender.stringValue\n      parent.text = value\n      parent.onSubmit?(value)\n    }\n\n    // Intercept Return/Escape; respect IME composition\n    @MainActor\n    func control(\n      _ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector\n    ) -> Bool {\n      // If composing with IME, let the editor handle the key (do not submit)\n      if textView.hasMarkedText() { return false }\n      if commandSelector == #selector(NSResponder.insertNewline(_:)) {\n        let value = textView.string\n        parent.text = value\n        parent.onSubmit?(value)\n        return true\n      }\n      if commandSelector == #selector(NSResponder.cancelOperation(_:)) {\n        parent.text = \"\"\n        parent.onSubmit?(\"\")\n        return true\n      }\n      return false\n    }\n  }\n}\n\n// MARK: - Equal-width segmented control backed by NSSegmentedControl\nprivate struct EqualWidthSegmentedControl<Item: Identifiable & Hashable>: NSViewRepresentable {\n  let items: [Item]\n  @Binding var selection: Item\n  var title: (Item) -> String\n\n  func makeCoordinator() -> Coordinator { Coordinator(self) }\n\n  func makeNSView(context: Context) -> NSView {\n    let container = NSView()\n    container.translatesAutoresizingMaskIntoConstraints = false\n    let control = NSSegmentedControl()\n    control.translatesAutoresizingMaskIntoConstraints = false\n    control.segmentStyle = .rounded\n    control.trackingMode = .selectOne\n    control.target = context.coordinator\n    control.action = #selector(Coordinator.changed(_:))\n    rebuildSegments(control)\n    if #available(macOS 13.0, *) { control.segmentDistribution = .fillEqually }\n\n    control.setContentHuggingPriority(.defaultLow, for: .horizontal)\n    control.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)\n\n    container.addSubview(control)\n    NSLayoutConstraint.activate([\n      control.leadingAnchor.constraint(equalTo: container.leadingAnchor),\n      control.trailingAnchor.constraint(equalTo: container.trailingAnchor),\n      control.topAnchor.constraint(equalTo: container.topAnchor),\n      control.bottomAnchor.constraint(equalTo: container.bottomAnchor),\n    ])\n    context.coordinator.control = control\n    return container\n  }\n\n  func updateNSView(_ container: NSView, context: Context) {\n    guard let control = context.coordinator.control else { return }\n    if control.segmentCount != items.count { rebuildSegments(control) }\n    // Update labels if needed\n    for (i, it) in items.enumerated() { control.setLabel(title(it), forSegment: i) }\n    // Selection\n    if let idx = items.firstIndex(of: selection) {\n      control.selectedSegment = idx\n    } else {\n      control.selectedSegment = -1\n    }\n\n    // Ensure segments expand after the middle column resizes from 0 → normal.\n    let containerWidth = container.bounds.width\n    if context.coordinator.lastContainerWidth != containerWidth {\n      context.coordinator.lastContainerWidth = containerWidth\n      if #available(macOS 13.0, *) {\n        control.segmentDistribution = .fillEqually\n      }\n      // Force a fresh layout pass now and in next runloop to avoid \"scrunched\" state.\n      control.invalidateIntrinsicContentSize()\n      control.needsLayout = true\n      control.layoutSubtreeIfNeeded()\n      DispatchQueue.main.async {\n        control.invalidateIntrinsicContentSize()\n        control.needsLayout = true\n        control.layoutSubtreeIfNeeded()\n      }\n    }\n\n    if #available(macOS 13.0, *) {\n      // Nothing else; fillEqually handles widths.\n    } else {\n      // Fallback: try to equalize manually each update\n      let superWidth = control.superview?.bounds.width ?? containerWidth\n      if superWidth > 0 {\n        let width = max(60.0, superWidth / CGFloat(max(1, items.count)))\n        for i in 0..<control.segmentCount { control.setWidth(width, forSegment: i) }\n      }\n    }\n  }\n\n  private func rebuildSegments(_ control: NSSegmentedControl) {\n    control.segmentCount = items.count\n    for (i, it) in items.enumerated() {\n      control.setLabel(title(it), forSegment: i)\n    }\n  }\n\n  final class Coordinator: NSObject {\n    weak var control: NSSegmentedControl?\n    var parent: EqualWidthSegmentedControl\n    var lastContainerWidth: CGFloat = -1\n    init(_ parent: EqualWidthSegmentedControl) { self.parent = parent }\n    @objc func changed(_ sender: NSSegmentedControl) {\n      let idx = sender.selectedSegment\n      guard idx >= 0 && idx < parent.items.count else { return }\n      parent.selection = parent.items[idx]\n    }\n  }\n}\n\nextension TimeInterval {\n  fileprivate var readableFormattedDuration: String {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = durationUnits\n    formatter.unitsStyle = .abbreviated\n    return formatter.string(from: self) ?? \"—\"\n  }\n\n  private var durationUnits: NSCalendar.Unit {\n    if self >= 3600 {\n      return [.hour, .minute]\n    } else if self >= 60 {\n      return [.minute, .second]\n    }\n    return [.second]\n  }\n}\n\n#Preview {\n  // Mock SessionDaySection data\n  let mockSections = [\n    SessionDaySection(\n      id: Date().addingTimeInterval(-86400),  // Yesterday\n      title: \"Yesterday\",\n      totalDuration: 7200,  // 2 hours\n      totalEvents: 15,\n      sessions: [\n        SessionSummary(\n          id: \"session-1\",\n          fileURL: URL(\n            fileURLWithPath: \"/Users/developer/.codex/sessions/session-1.json\"),\n          fileSizeBytes: 12340,\n          startedAt: Date().addingTimeInterval(-7200),\n          endedAt: Date().addingTimeInterval(-3600),\n          activeDuration: nil,\n          cliVersion: \"1.2.3\",\n          cwd: \"/Users/developer/projects/codmate\",\n          originator: \"developer\",\n          instructions: \"Optimize SwiftUI list performance\",\n          model: \"gpt-4o-mini\",\n          approvalPolicy: \"auto\",\n          userMessageCount: 3,\n          assistantMessageCount: 2,\n          toolInvocationCount: 1,\n          responseCounts: [:],\n          turnContextCount: 5,\n          totalTokens: 740,\n          eventCount: 6,\n          lineCount: 89,\n          lastUpdatedAt: Date().addingTimeInterval(-3600),\n          source: .codexLocal,\n          remotePath: nil\n        ),\n        SessionSummary(\n          id: \"session-2\",\n          fileURL: URL(\n            fileURLWithPath: \"/Users/developer/.codex/sessions/session-2.json\"),\n          fileSizeBytes: 8900,\n          startedAt: Date().addingTimeInterval(-10800),\n          endedAt: Date().addingTimeInterval(-9000),\n          activeDuration: nil,\n          cliVersion: \"1.2.3\",\n          cwd: \"/Users/developer/projects/test\",\n          originator: \"developer\",\n          instructions: \"Create a to-do app\",\n          model: \"gpt-4o\",\n          approvalPolicy: \"manual\",\n          userMessageCount: 4,\n          assistantMessageCount: 3,\n          toolInvocationCount: 2,\n          responseCounts: [\"reasoning\": 1],\n          turnContextCount: 7,\n          totalTokens: 1120,\n          eventCount: 9,\n          lineCount: 120,\n          lastUpdatedAt: Date().addingTimeInterval(-9000),\n          source: .codexLocal,\n          remotePath: nil\n        ),\n      ]\n    ),\n    SessionDaySection(\n      id: Date().addingTimeInterval(-172800),  // Day before yesterday\n      title: \"Dec 15, 2024\",\n      totalDuration: 5400,  // 1.5 hours\n      totalEvents: 12,\n      sessions: [\n        SessionSummary(\n          id: \"session-3\",\n          fileURL: URL(\n            fileURLWithPath: \"/Users/developer/.codex/sessions/session-3.json\"),\n          fileSizeBytes: 15600,\n          startedAt: Date().addingTimeInterval(-172800),\n          endedAt: Date().addingTimeInterval(-158400),\n          activeDuration: nil,\n          cliVersion: \"1.2.2\",\n          cwd: \"/Users/developer/documents\",\n          originator: \"developer\",\n          instructions: \"Write technical documentation\",\n          model: \"gpt-4o-mini\",\n          approvalPolicy: \"auto\",\n          userMessageCount: 6,\n          assistantMessageCount: 5,\n          toolInvocationCount: 3,\n          responseCounts: [\"reasoning\": 2],\n          turnContextCount: 11,\n          totalTokens: 2100,\n          eventCount: 14,\n          lineCount: 200,\n          lastUpdatedAt: Date().addingTimeInterval(-158400),\n          source: .codexLocal,\n          remotePath: nil\n        )\n      ]\n    ),\n  ]\n\n  SessionListColumnView(\n    sections: mockSections,\n    selection: .constant(Set<String>()),\n    sortOrder: .constant(.mostRecent),\n    isLoading: false,\n    isEnriching: false,\n    enrichmentProgress: 0,\n    enrichmentTotal: 0,\n    onResume: { session in print(\"Resume: \\(session.displayName)\") },\n    onReveal: { session in print(\"Reveal: \\(session.displayName)\") },\n    onDeleteRequest: { session in print(\"Delete: \\(session.displayName)\") },\n    onExportMarkdown: { session in print(\"Export: \\(session.displayName)\") }\n  )\n  .frame(width: 500, height: 600)\n}\n\n#Preview(\"Loading State\") {\n  SessionListColumnView(\n    sections: [],\n    selection: .constant(Set<String>()),\n    sortOrder: .constant(.mostRecent),\n    isLoading: true,\n    isEnriching: false,\n    enrichmentProgress: 0,\n    enrichmentTotal: 0,\n    onResume: { _ in },\n    onReveal: { _ in },\n    onDeleteRequest: { _ in },\n    onExportMarkdown: { _ in }\n  )\n  .frame(width: 500, height: 600)\n}\n\n#Preview(\"Empty State\") {\n  SessionListColumnView(\n    sections: [],\n    selection: .constant(Set<String>()),\n    sortOrder: .constant(.mostRecent),\n    isLoading: false,\n    isEnriching: false,\n    enrichmentProgress: 0,\n    enrichmentTotal: 0,\n    onResume: { _ in },\n    onReveal: { _ in },\n    onDeleteRequest: { _ in },\n    onExportMarkdown: { _ in }\n  )\n  .frame(width: 500, height: 600)\n}\n"
  },
  {
    "path": "views/SessionListRowView.swift",
    "content": "import SwiftUI\n\nstruct SessionSourceBranding {\n  let displayName: String\n  let symbolName: String\n  let iconColor: Color\n  let badgeBackground: Color\n  let badgeAssetName: String?\n  let providerKind: UsageProviderKind\n}\n\nextension SessionSource {\n  var isGemini: Bool {\n    switch self {\n    case .geminiLocal, .geminiRemote: return true\n    default: return false\n    }\n  }\n  var branding: SessionSourceBranding {\n    switch self {\n    case .codexLocal:\n      return SessionSourceBranding(\n        displayName: \"Codex\",\n        symbolName: \"sparkles\",\n        iconColor: Color.accentColor,\n        badgeBackground: Color.accentColor.opacity(0.08),\n        badgeAssetName: \"ChatGPTIcon\",\n        providerKind: .codex\n      )\n    case .codexRemote(let host):\n      return SessionSourceBranding(\n        displayName: \"Codex (\\(host))\",\n        symbolName: \"sparkles\",\n        iconColor: Color.accentColor,\n        badgeBackground: Color.accentColor.opacity(0.08),\n        badgeAssetName: \"ChatGPTIcon\",\n        providerKind: .codex\n      )\n    case .claudeLocal:\n      return SessionSourceBranding(\n        displayName: \"Claude\",\n        symbolName: \"cloud.fill\",\n        iconColor: Color.purple,\n        badgeBackground: Color.purple.opacity(0.10),\n        badgeAssetName: \"ClaudeIcon\",\n        providerKind: .claude\n      )\n    case .claudeRemote(let host):\n      return SessionSourceBranding(\n        displayName: \"Claude (\\(host))\",\n        symbolName: \"cloud.fill\",\n        iconColor: Color.purple,\n        badgeBackground: Color.purple.opacity(0.10),\n        badgeAssetName: \"ClaudeIcon\",\n        providerKind: .claude\n      )\n    case .geminiLocal:\n      return SessionSourceBranding(\n        displayName: \"Gemini\",\n        symbolName: \"sparkles.rectangle.stack.fill\",\n        iconColor: Color.blue,\n        badgeBackground: Color.blue.opacity(0.1),\n        badgeAssetName: \"GeminiIcon\",\n        providerKind: .gemini\n      )\n    case .geminiRemote(let host):\n      return SessionSourceBranding(\n        displayName: \"Gemini (\\(host))\",\n        symbolName: \"sparkles.rectangle.stack.fill\",\n        iconColor: Color.blue,\n        badgeBackground: Color.blue.opacity(0.1),\n        badgeAssetName: \"GeminiIcon\",\n        providerKind: .gemini\n      )\n    }\n  }\n}\n\nstruct SessionListRowView: View {\n  let summary: SessionSummary\n  var isRunning: Bool = false\n  var isSelected: Bool = false\n  var isUpdating: Bool = false\n  var awaitingFollowup: Bool = false\n  var inProject: Bool = false\n  var projectTip: String? = nil\n  var inTaskContainer: Bool = false\n  @Environment(\\.accessibilityReduceMotion) private var reduceMotion\n  @Environment(\\.colorScheme) private var colorScheme\n  @State private var breathing = false\n\n  var body: some View {\n    let branding = summary.source.branding\n    HStack(alignment: .top, spacing: 12) {\n      if !inTaskContainer {\n        let container = RoundedRectangle(cornerRadius: 9, style: .continuous)\n        ZStack {\n          if !isRunning {\n            container\n              .fill(Color.white)\n              .shadow(color: Color.black.opacity(0.08), radius: 1.5, x: 0, y: 1)\n            container\n              .stroke(\n                isSelected ? branding.iconColor.opacity(0.5) : Color.black.opacity(0.06),\n                lineWidth: isSelected ? 1.5 : 1)\n          }\n\n          if isRunning {\n            RainbowSpinnerView(spins: true)\n              .padding(2)\n              .opacity(\n                reduceMotion ? 1.0 : (awaitingFollowup ? (breathing ? 1.0 : 0.55) : 1.0)\n              )\n          } else if awaitingFollowup && !isUpdating {\n            // Draw a non-spinning beachball and apply a subtle breathing fade\n            RainbowSpinnerView(spins: false)\n              .padding(2)\n              .opacity(reduceMotion ? 1.0 : (breathing ? 1.0 : 0.55))\n          } else if !isUpdating, let asset = branding.badgeAssetName {\n            let hasWhiteIconBackground = !isRunning\n            let shouldInvertCodexDark =\n              summary.source.baseKind == .codex && colorScheme == .dark && !hasWhiteIconBackground\n            Image(asset)\n              .resizable()\n              .renderingMode(.original)\n              .aspectRatio(contentMode: .fit)\n              .padding(4)\n              .modifier(\n                DarkModeInvertModifier(active: shouldInvertCodexDark)\n              )\n          } else if !isUpdating {\n            Image(systemName: branding.symbolName)\n              .font(.system(size: 14, weight: .semibold))\n              .foregroundStyle(branding.iconColor)\n          }\n        }\n        .frame(width: 32, height: 32)\n        .help(\"\\(branding.displayName) session\")\n      }\n\n      VStack(alignment: .leading, spacing: 4) {\n        Text(summary.effectiveTitle)\n          .font(.headline)\n          .lineLimit(1)\n          .truncationMode(.tail)\n        if let remoteHost = summary.remoteHost {\n          Text(remoteHost)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 6)\n            .padding(.vertical, 2)\n            .background(\n              RoundedRectangle(cornerRadius: 4, style: .continuous)\n                .fill(Color.secondary.opacity(0.12))\n            )\n        }\n        HStack(spacing: 8) {\n          Text(summary.startedAt.formatted(date: .numeric, time: .shortened))\n            .layoutPriority(1)\n          Text(summary.readableDuration)\n            .layoutPriority(1)\n          if let model = summary.displayModel ?? summary.model {\n            Text(model)\n              .foregroundStyle(.secondary)\n              .lineLimit(1)\n          }\n        }\n        .font(.caption)\n        .foregroundStyle(.secondary)\n        .lineLimit(1)\n\n        Text(summary.commentSnippet)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n          .lineLimit(2)\n\n        // Compact metrics moved from detail view\n        HStack(spacing: 8) {\n          metric(icon: \"person\", value: summary.userMessageCount)\n          metric(icon: \"sparkles\", value: summary.assistantMessageCount)\n          metric(icon: \"hammer\", value: summary.toolInvocationCount)\n          if let reasoning = summary.responseCounts[\"reasoning\"], reasoning > 0 {\n            metric(icon: \"brain\", value: reasoning)\n          }\n        }\n        .font(.caption2.monospacedDigit())\n        .foregroundStyle(.secondary)\n      }\n      .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)\n      .padding(.trailing, 32)\n\n      Spacer(minLength: 0)\n    }\n    .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))\n    .padding(.vertical, 8)\n    .buttonStyle(.plain)\n    .overlay(alignment: .topTrailing) {\n      HStack(spacing: 0) {\n        // Single-slot trailing indicator:\n        //  - When updating, show the timer icon\n        //  - Else if in a task container, show beachball (running) or provider branding\n        //  - Else if in a project, show project glyph\n        if isUpdating {\n          Image(systemName: \"timer\")\n            .foregroundStyle(Color.orange)\n            .font(.system(size: 16, weight: .semibold))\n            .modifier(UpdatePulseModifier(active: true))\n            .help(\"Updating…\")\n        } else if inTaskContainer {\n          if isRunning {\n            RainbowSpinnerView(spins: !reduceMotion, size: 18)\n              .opacity(\n                reduceMotion ? 1.0 : (awaitingFollowup ? (breathing ? 1.0 : 0.55) : 1.0)\n              )\n          } else if let asset = branding.badgeAssetName {\n            let shouldInvertCodexDark = summary.source.baseKind == .codex && colorScheme == .dark\n            if isSelected && !summary.source.isGemini && !shouldInvertCodexDark {\n              Image(asset)\n                .resizable()\n                .renderingMode(.template)\n                .aspectRatio(contentMode: .fit)\n                .frame(width: 18, height: 18)\n                .foregroundStyle(Color.white)\n                .help(branding.displayName)\n            } else {\n              Image(asset)\n                .resizable()\n                .renderingMode(.original)\n                .aspectRatio(contentMode: .fit)\n                .frame(width: 18, height: 18)\n                .modifier(\n                  DarkModeInvertModifier(active: shouldInvertCodexDark)\n                )\n                .help(branding.displayName)\n            }\n          } else {\n            Image(systemName: branding.symbolName)\n              .foregroundStyle(isSelected ? Color.white : branding.iconColor)\n              .font(.system(size: 12, weight: .semibold))\n              .help(branding.displayName)\n          }\n        } else if inProject {\n          Image(systemName: \"square.grid.2x2\")\n            .foregroundStyle(Color.secondary)\n            .font(.system(size: 12, weight: .regular))\n            .help(projectTip ?? \"Project\")\n        }\n      }\n      .padding(.leading, 8)\n      .padding(.trailing, 8)\n      .padding(.top, 8)\n      .allowsHitTesting(false)\n    }\n    .onAppear {\n      // Start breathing for running rows (legacy; may be imperceptible) or\n      // attention pulse for follow-up rows.\n      guard !reduceMotion else { return }\n      if isRunning || awaitingFollowup {\n        withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {\n          breathing = true\n        }\n      }\n    }\n    .onChange(of: isRunning) { newValue in\n      if newValue {\n        if reduceMotion {\n          breathing = false\n        } else {\n          breathing = false\n          withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {\n            breathing = true\n          }\n        }\n      } else {\n        // Smoothly fade out the background when session stops running\n        withAnimation(.easeOut(duration: 0.2)) {\n          breathing = false\n        }\n      }\n    }\n    .onChange(of: awaitingFollowup) { needed in\n      guard !reduceMotion else { return }\n      if needed {\n        withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {\n          breathing = true\n        }\n      } else {\n        withAnimation(.easeOut(duration: 0.2)) { breathing = false }\n      }\n    }\n  }\n}\n\nprivate struct UpdatePulseModifier: ViewModifier {\n  let active: Bool\n\n  func body(content: Content) -> some View {\n    if #available(macOS 14.0, *) {\n      content.symbolEffect(.pulse, isActive: active)\n    } else {\n      content\n    }\n  }\n}\n\nstruct EquatableSessionListRow: View, Equatable {\n  let summary: SessionSummary\n  let isRunning: Bool\n  let isSelected: Bool\n  let isUpdating: Bool\n  let awaitingFollowup: Bool\n  let inProject: Bool\n  let projectTip: String?\n  let inTaskContainer: Bool\n\n  static func == (lhs: EquatableSessionListRow, rhs: EquatableSessionListRow) -> Bool {\n    lhs.summary == rhs.summary\n      && lhs.isRunning == rhs.isRunning\n      && lhs.isSelected == rhs.isSelected\n      && lhs.isUpdating == rhs.isUpdating\n      && lhs.awaitingFollowup == rhs.awaitingFollowup\n      && lhs.inProject == rhs.inProject\n      && lhs.projectTip == rhs.projectTip\n      && lhs.inTaskContainer == rhs.inTaskContainer\n  }\n\n  var body: some View {\n    SessionListRowView(\n      summary: summary,\n      isRunning: isRunning,\n      isSelected: isSelected,\n      isUpdating: isUpdating,\n      awaitingFollowup: awaitingFollowup,\n      inProject: inProject,\n      projectTip: projectTip,\n      inTaskContainer: inTaskContainer\n    )\n  }\n}\n\nprivate func metric(icon: String, value: Int) -> some View {\n  HStack(spacing: 4) {\n    Image(systemName: icon)\n    Text(\"\\(value)\")\n  }\n}\n\n// Legacy SpinningBeachballView replaced by RainbowSpinnerView (CoreAnimation-based)\n// Kept for reference - all usages have been migrated to RainbowSpinnerView\n\nextension SessionListRowView {}\n\n#Preview {\n  let mockSummary = SessionSummary(\n    id: \"session-preview\",\n    fileURL: URL(fileURLWithPath: \"/Users/developer/.codex/sessions/session-preview.json\"),\n    fileSizeBytes: 12340,\n    startedAt: Date().addingTimeInterval(-3600),\n    endedAt: Date().addingTimeInterval(-1800),\n    activeDuration: nil,\n    cliVersion: \"1.2.3\",\n    cwd: \"/Users/developer/projects/codmate\",\n    originator: \"developer\",\n    instructions:\n      \"Please help optimize this SwiftUI app's performance, especially scroll stutter in lists. It should remain smooth with large datasets.\",\n    model: \"gpt-4o-mini\",\n    approvalPolicy: \"auto\",\n    userMessageCount: 5,\n    assistantMessageCount: 4,\n    toolInvocationCount: 3,\n    responseCounts: [\"reasoning\": 2],\n    turnContextCount: 8,\n    totalTokens: 980,\n    eventCount: 12,\n    lineCount: 156,\n    lastUpdatedAt: Date().addingTimeInterval(-1800),\n    source: .codexLocal,\n    remotePath: nil\n  )\n\n  SessionListRowView(summary: mockSummary)\n    .frame(width: 400, height: 120)\n    .padding()\n}\n\n#Preview(\"Short Instructions\") {\n  let mockSummary = SessionSummary(\n    id: \"session-short\",\n    fileURL: URL(fileURLWithPath: \"/Users/developer/.codex/sessions/session-short.json\"),\n    fileSizeBytes: 5600,\n    startedAt: Date().addingTimeInterval(-7200),\n    endedAt: Date().addingTimeInterval(-6900),\n    activeDuration: nil,\n    cliVersion: \"1.2.3\",\n    cwd: \"/Users/developer/projects/test\",\n    originator: \"developer\",\n    instructions: \"Create a to-do app\",\n    model: \"gpt-4o\",\n    approvalPolicy: \"manual\",\n    userMessageCount: 2,\n    assistantMessageCount: 1,\n    toolInvocationCount: 0,\n    responseCounts: [:],\n    turnContextCount: 3,\n    totalTokens: 320,\n    eventCount: 3,\n    lineCount: 45,\n    lastUpdatedAt: Date().addingTimeInterval(-6900),\n    source: .codexLocal,\n    remotePath: nil\n  )\n\n  SessionListRowView(summary: mockSummary)\n    .frame(width: 300, height: 100)\n    .padding()\n}\n\n#Preview(\"No Instructions\") {\n  let mockSummary = SessionSummary(\n    id: \"session-no-instructions\",\n    fileURL: URL(\n      fileURLWithPath: \"/Users/developer/.codex/sessions/session-no-instructions.json\"),\n    fileSizeBytes: 3200,\n    startedAt: Date().addingTimeInterval(-10800),\n    endedAt: Date().addingTimeInterval(-10500),\n    activeDuration: nil,\n    cliVersion: \"1.2.2\",\n    cwd: \"/Users/developer/documents\",\n    originator: \"developer\",\n    instructions: nil,\n    model: \"gpt-4o-mini\",\n    approvalPolicy: \"auto\",\n    userMessageCount: 1,\n    assistantMessageCount: 1,\n    toolInvocationCount: 0,\n    responseCounts: [:],\n    turnContextCount: 2,\n    totalTokens: 150,\n    eventCount: 2,\n    lineCount: 20,\n    lastUpdatedAt: Date().addingTimeInterval(-10500),\n    source: .codexLocal,\n    remotePath: nil\n  )\n\n  SessionListRowView(summary: mockSummary)\n    .frame(width: 400, height: 100)\n    .padding()\n}\n"
  },
  {
    "path": "views/SessionNavigationView.swift",
    "content": "import SwiftUI\n#if os(macOS)\nimport AppKit\n#endif\n\nstruct SessionNavigationView<ProjectsContent: View>: View {\n    let state: SidebarState\n    let actions: SidebarActions\n    let projectWorkspaceMode: ProjectWorkspaceMode\n    let isAllOrOtherSelected: Bool\n    @ViewBuilder var projectsContent: () -> ProjectsContent\n\n    var body: some View {\n        VStack(spacing: 0) {\n            VStack(spacing: 8) {\n                HStack(spacing: 8) {\n                    Text(\"Projects\").font(.caption).foregroundStyle(.secondary)\n                    Spacer(minLength: 4)\n                    Button(action: actions.requestNewProject) {\n                        Image(systemName: \"plus\")\n                    }\n                    .buttonStyle(.bordered)\n                    .controlSize(.small)\n                    .help(\"New Project\")\n                }\n\n                VStack(spacing: 8) {\n                    scopeAllRow(\n                        title: \"All\",\n                        isSelected: state.selectedProjectIDs.isEmpty,\n                        icon: \"rectangle.stack\",\n                        count: (state.visibleAllCount, state.totalSessionCount),\n                        action: actions.selectAllProjects\n                    )\n                    projectsContent()\n                }\n            }\n            .padding(.horizontal, 8)\n            .padding(.top, 8)\n            .frame(maxHeight: .infinity)\n\n            // Calendar only visible in Overview/Tasks modes, or Sessions mode (for Others)\n            if shouldShowCalendar {\n                calendarSection\n                    .padding(.top, 8)\n            }\n        }\n        .frame(idealWidth: 240)\n    }\n\n    // Show calendar only for Overview, Tasks, or Sessions (Others)\n    private var shouldShowCalendar: Bool {\n        switch projectWorkspaceMode {\n        case .overview, .tasks, .settings:\n            return true\n        case .sessions:\n            // Sessions mode is only used for \"Others\" project\n            return isAllOrOtherSelected\n        case .review, .agents, .memory:\n            return false\n        }\n    }\n\n    private func scopeAllRow(\n        title: String,\n        isSelected: Bool,\n        icon: String,\n        count: (visible: Int, total: Int)? = nil,\n        action: @escaping () -> Void\n    ) -> some View {\n        HStack(spacing: 8) {\n            Image(systemName: icon)\n                .foregroundStyle(isSelected ? Color.white : Color.secondary)\n                .font(.caption)\n            Text(title)\n                .font(.caption)\n                .foregroundStyle(isSelected ? Color.white : Color.primary)\n            Spacer(minLength: 8)\n            if let pair = count {\n                Text(\"\\(pair.visible)/\\(pair.total)\")\n                    .font(.caption2.monospacedDigit())\n                    .foregroundStyle(isSelected ? Color.white.opacity(0.9) : Color.secondary)\n            }\n        }\n        .frame(height: 16)\n        .padding(8)\n        .background(isSelected ? Color.accentColor : Color.clear)\n        .cornerRadius(8)\n        .contentShape(Rectangle())\n        .onTapGesture { action() }\n    }\n\n    private var calendarSection: some View {\n        VStack(spacing: 4) {\n            calendarHeader\n\n            Picker(\"\", selection: dimensionBinding) {\n                ForEach(DateDimension.allCases) { dim in\n                    Text(dim.title).tag(dim)\n                }\n            }\n            .labelsHidden()\n            .pickerStyle(.segmented)\n            .controlSize(.small)\n\n            CalendarMonthView(\n                monthStart: state.monthStart,\n                counts: state.calendarCounts,\n                selectedDays: state.selectedDays,\n                enabledDays: state.enabledProjectDays\n            ) { picked in\n                handleDaySelection(picked)\n            }\n        }\n        .padding(8)\n    }\n\n    private var dimensionBinding: Binding<DateDimension> {\n        Binding(\n            get: { state.dateDimension },\n            set: { actions.setDateDimension($0) }\n        )\n    }\n\n    private var calendarHeader: some View {\n        let cal = Calendar.current\n        let monthTitle: String = {\n            let df = DateFormatter()\n            df.dateFormat = \"MMM yyyy\"\n            return df.string(from: state.monthStart)\n        }()\n        return GeometryReader { geometry in\n            let columnWidth = geometry.size.width / 16\n            HStack(spacing: 0) {\n                Button {\n                    if let next = cal.date(byAdding: .month, value: -1, to: state.monthStart) {\n                        actions.setMonthStart(next)\n                    }\n                } label: {\n                    Image(systemName: \"chevron.left\")\n                        .frame(width: columnWidth, height: 24)\n                }\n                .buttonStyle(.plain)\n\n                Spacer(minLength: 0)\n\n                Button {\n                    jumpToToday()\n                } label: {\n                    Text(monthTitle)\n                        .font(.headline)\n                        .multilineTextAlignment(.center)\n                }\n                .buttonStyle(.plain)\n\n                Spacer(minLength: 0)\n\n                Button {\n                    if let next = cal.date(byAdding: .month, value: 1, to: state.monthStart) {\n                        actions.setMonthStart(next)\n                    }\n                } label: {\n                    Image(systemName: \"chevron.right\")\n                        .frame(width: columnWidth, height: 24)\n                }\n                .buttonStyle(.plain)\n            }\n            .frame(width: geometry.size.width)\n        }\n        .frame(height: 24)\n    }\n\n    private func jumpToToday() {\n        let cal = Calendar.current\n        let today = cal.startOfDay(for: Date())\n        if let month = cal.date(from: cal.dateComponents([.year, .month], from: today)) {\n            actions.setMonthStart(month)\n        } else {\n            actions.setMonthStart(today)\n        }\n        actions.setSelectedDay(today)\n    }\n\n    private func handleDaySelection(_ picked: Date) {\n        #if os(macOS)\n        let useToggle = (NSApp.currentEvent?.modifierFlags.contains(.command) ?? false)\n        #else\n        let useToggle = false\n        #endif\n        if useToggle {\n            actions.toggleSelectedDay(picked)\n        } else {\n            if let current = state.selectedDay,\n               Calendar.current.isDate(current, inSameDayAs: picked) {\n                actions.setSelectedDay(nil)\n            } else {\n                actions.setSelectedDay(picked)\n            }\n        }\n    }\n}\n\nprivate enum SidebarMode: Hashable { case directories, projects }\n\n#Preview {\n    let cal = Calendar.current\n    let monthStart = cal.date(from: DateComponents(year: 2024, month: 12, day: 1))!\n    let state = SidebarState(\n        totalSessionCount: 15,\n        isLoading: false,\n        visibleAllCount: 12,\n        selectedProjectIDs: [],\n        selectedDay: nil,\n        selectedDays: [],\n        dateDimension: .updated,\n        monthStart: monthStart,\n        calendarCounts: [1: 2, 3: 4],\n        enabledProjectDays: nil\n    )\n    let actions = SidebarActions(\n        selectAllProjects: {},\n        requestNewProject: {},\n        requestNewTask: {},\n        setDateDimension: { _ in },\n        setMonthStart: { _ in },\n        setSelectedDay: { _ in },\n        toggleSelectedDay: { _ in }\n    )\n\n    return SessionNavigationView(\n        state: state,\n        actions: actions,\n        projectWorkspaceMode: .tasks,\n        isAllOrOtherSelected: true\n    ) {\n        EmptyView()\n    }\n    .frame(width: 280, height: 600)\n}\n\n#Preview(\"Loading State\") {\n    let cal = Calendar.current\n    let monthStart = cal.date(from: DateComponents(year: 2024, month: 12, day: 1))!\n    let state = SidebarState(\n        totalSessionCount: 0,\n        isLoading: true,\n        visibleAllCount: 0,\n        selectedProjectIDs: [],\n        selectedDay: nil,\n        selectedDays: [],\n        dateDimension: .created,\n        monthStart: monthStart,\n        calendarCounts: [:],\n        enabledProjectDays: nil\n    )\n    let actions = SidebarActions(\n        selectAllProjects: {},\n        requestNewProject: {},\n        requestNewTask: {},\n        setDateDimension: { _ in },\n        setMonthStart: { _ in },\n        setSelectedDay: { _ in },\n        toggleSelectedDay: { _ in }\n    )\n\n    return SessionNavigationView(\n        state: state,\n        actions: actions,\n        projectWorkspaceMode: .overview,\n        isAllOrOtherSelected: true\n    ) {\n        EmptyView()\n    }\n    .frame(width: 280, height: 600)\n}\n\n#Preview(\"Calendar Day Selected\") {\n    let cal = Calendar.current\n    let today = cal.startOfDay(for: Date())\n    let start = cal.date(from: DateComponents(year: 2024, month: 11, day: 1))!\n    let state = SidebarState(\n        totalSessionCount: 8,\n        isLoading: false,\n        visibleAllCount: 4,\n        selectedProjectIDs: [],\n        selectedDay: today,\n        selectedDays: [today],\n        dateDimension: .updated,\n        monthStart: start,\n        calendarCounts: [cal.component(.day, from: today): 3],\n        enabledProjectDays: nil\n    )\n    let actions = SidebarActions(\n        selectAllProjects: {},\n        requestNewProject: {},\n        requestNewTask: {},\n        setDateDimension: { _ in },\n        setMonthStart: { _ in },\n        setSelectedDay: { _ in },\n        toggleSelectedDay: { _ in }\n    )\n\n    return SessionNavigationView(\n        state: state,\n        actions: actions,\n        projectWorkspaceMode: .tasks,\n        isAllOrOtherSelected: false\n    ) {\n        EmptyView()\n    }\n    .frame(width: 280, height: 600)\n}\n\n#Preview(\"Path Selected\") {\n    let cal = Calendar.current\n    let state = SidebarState(\n        totalSessionCount: 5,\n        isLoading: false,\n        visibleAllCount: 5,\n        selectedProjectIDs: [\"demo\"],\n        selectedDay: nil,\n        selectedDays: [],\n        dateDimension: .updated,\n        monthStart: cal.startOfDay(for: Date()),\n        calendarCounts: [:],\n        enabledProjectDays: [1, 3, 5]\n    )\n    let actions = SidebarActions(\n        selectAllProjects: {},\n        requestNewProject: {},\n        requestNewTask: {},\n        setDateDimension: { _ in },\n        setMonthStart: { _ in },\n        setSelectedDay: { _ in },\n        toggleSelectedDay: { _ in }\n    )\n\n    return SessionNavigationView(\n        state: state,\n        actions: actions,\n        projectWorkspaceMode: .review,\n        isAllOrOtherSelected: false\n    ) {\n        EmptyView()\n    }\n    .frame(width: 280, height: 600)\n}\n"
  },
  {
    "path": "views/SessionPathGroup.swift",
    "content": "import SwiftUI\n\nstruct SessionPathGroup: View {\n    @Binding var config: SessionPathConfig\n    let diagnostics: SessionsDiagnostics.Probe?\n    let canDelete: Bool\n    let showToggle: Bool\n    let showHeader: Bool\n    var onDelete: (() -> Void)? = nil\n    @State private var showingDiagnostics = false\n    @State private var showingAddIgnore = false\n    @State private var newIgnorePath = \"\"\n    @State private var isHovered = false\n\n    private var localAuthProvider: LocalAuthProvider? {\n        LocalAuthProvider(rawValue: config.kind.rawValue)\n    }\n    \n    private var isEnabled: Bool {\n        showToggle ? config.enabled : true\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 0) {\n            // Header: Icon + Name + Delete (hover) + Switch (always visible)\n            if showHeader {\n                HStack(alignment: .center, spacing: 12) {\n                    // Brand icon\n                    if let provider = localAuthProvider {\n                        LocalAuthProviderIconView(provider: provider, size: 16, cornerRadius: 3)\n                    }\n\n                    Text(config.displayName ?? config.kind.displayName)\n                        .font(.headline)\n                        .fontWeight(.medium)\n\n                    Spacer()\n\n                    // Delete button (only visible on hover, transparent background)\n                    if canDelete, let onDelete = onDelete {\n                        Button {\n                            onDelete()\n                        } label: {\n                            Image(systemName: \"trash\")\n                                .font(.system(size: 13))\n                                .foregroundStyle(.secondary)\n                        }\n                        .buttonStyle(.plain)\n                        .opacity(isHovered ? 1.0 : 0.0)\n                        .help(\"Delete\")\n                    }\n\n                    if showToggle {\n                        Toggle(\"\", isOn: $config.enabled)\n                            .toggleStyle(.switch)\n                            .labelsHidden()\n                            .controlSize(.small)\n                    }\n                }\n                .padding(10)\n            }\n\n            // Content: Only shown when enabled\n            if isEnabled {\n                VStack(alignment: .leading, spacing: 0) {\n                    Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                        // Path (first item)\n                        GridRow {\n                            Text(\"Path\")\n                                .font(.subheadline)\n                                .fontWeight(.medium)\n                            Text(config.path)\n                                .font(.subheadline)\n                                .lineLimit(1)\n                                .truncationMode(.middle)\n                                .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n\n                        Divider()\n\n                        // Ignored Subpaths\n                        GridRow {\n                            Text(\"Ignored Subpaths\")\n                                .font(.subheadline)\n                                .fontWeight(.medium)\n\n                            HStack(spacing: 6) {\n                                Spacer()\n\n                                ForEach(config.ignoredSubpaths, id: \\.self) { subpath in\n                                    TagView(\n                                        text: subpath,\n                                        isEnabled: !config.disabledSubpaths.contains(subpath),\n                                        isClosable: true,\n                                        isRemovable: true,\n                                        onClose: {\n                                            removeIgnorePath(subpath)\n                                        },\n                                        onToggle: { isEnabled in\n                                            toggleSubpath(subpath, enabled: isEnabled)\n                                        }\n                                    )\n                                }\n\n                                // Add new tag button\n                                Button {\n                                    showingAddIgnore = true\n                                } label: {\n                                    HStack(spacing: 4) {\n                                        Image(systemName: \"plus\")\n                                            .font(.system(size: 11))\n                                        Text(\"New Tag\")\n                                            .font(.caption)\n                                    }\n                                    .padding(.horizontal, 8)\n                                    .padding(.vertical, 4)\n                                    .foregroundStyle(.secondary)\n                                    .background(Color.secondary.opacity(0.1))\n                                    .clipShape(RoundedRectangle(cornerRadius: 6))\n                                }\n                                .buttonStyle(.plain)\n                            }\n                            .frame(maxWidth: .infinity, alignment: .trailing)\n                        }\n\n                        Divider()\n\n                        // Diagnostics Summary (after Ignored Subpaths)\n                        if let diagnostics = diagnostics {\n                            GridRow {\n                                Text(\"Diagnostics\")\n                                    .font(.subheadline)\n                                    .fontWeight(.medium)\n                                \n                                DisclosureGroup(isExpanded: $showingDiagnostics) {\n                                    Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {\n                                        GridRow {\n                                            Text(\"Exists\").font(.caption)\n                                            Text(diagnostics.exists ? \"Yes\" : \"No\")\n                                                .font(.caption)\n                                                .frame(maxWidth: .infinity, alignment: .trailing)\n                                        }\n                                        if diagnostics.isDirectory {\n                                            GridRow {\n                                                Text(\"Files\").font(.caption)\n                                                Text(\"\\(diagnostics.enumeratedCount)\")\n                                                    .font(.caption)\n                                                    .frame(maxWidth: .infinity, alignment: .trailing)\n                                            }\n                                        }\n                                        if let error = diagnostics.enumeratorError {\n                                            GridRow {\n                                                Text(\"Error\").font(.caption)\n                                                Text(error)\n                                                    .font(.caption)\n                                                    .foregroundStyle(.red)\n                                                    .frame(maxWidth: .infinity, alignment: .trailing)\n                                            }\n                                        }\n                                        if !diagnostics.sampleFiles.isEmpty {\n                                            GridRow {\n                                                Text(\"Sample Files\")\n                                                    .font(.caption)\n                                                    .fontWeight(.medium)\n                                                VStack(alignment: .trailing, spacing: 4) {\n                                                    ForEach(diagnostics.sampleFiles.prefix(5), id: \\.self) { file in\n                                                        Text(file)\n                                                            .font(.caption2)\n                                                            .foregroundStyle(.secondary)\n                                                            .monospaced()\n                                                            .lineLimit(1)\n                                                    }\n                                                    if diagnostics.sampleFiles.count > 5 {\n                                                        Text(\"(\\(diagnostics.sampleFiles.count - 5) more...)\")\n                                                            .font(.caption2)\n                                                            .foregroundStyle(.tertiary)\n                                                    }\n                                                }\n                                                .frame(maxWidth: .infinity, alignment: .trailing)\n                                            }\n                                        }\n                                    }\n                                    .frame(maxWidth: .infinity, alignment: .leading)\n                                    .padding(.top, 4)\n                                } label: {\n                                    EmptyView()\n                                }\n                            }\n                        }\n                    }\n                }\n                .padding(10)\n            }\n        }\n        .background(Color(nsColor: .separatorColor).opacity(0.35))\n        .cornerRadius(10)\n        .onHover { hovering in\n            isHovered = hovering\n        }\n        .alert(\"Add Ignored Path\", isPresented: $showingAddIgnore) {\n            TextField(\"Path substring\", text: $newIgnorePath)\n            Button(\"Cancel\", role: .cancel) {\n                newIgnorePath = \"\"\n            }\n            Button(\"Add\") {\n                addIgnorePath()\n            }\n            .disabled(newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n        } message: {\n            Text(\n                \"Enter a path substring to ignore. Files containing this substring will be skipped during scanning.\"\n            )\n        }\n    }\n\n    private func addIgnorePath() {\n        let trimmed = newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty, !config.ignoredSubpaths.contains(trimmed) else {\n            newIgnorePath = \"\"\n            return\n        }\n        var updated = config\n        updated.ignoredSubpaths.append(trimmed)\n        config = updated\n        newIgnorePath = \"\"\n    }\n\n    private func removeIgnorePath(_ subpath: String) {\n        var updated = config\n        updated.ignoredSubpaths.removeAll { $0 == subpath }\n        updated.disabledSubpaths.remove(subpath)  // Also remove from disabled set if present\n        config = updated\n    }\n\n    private func toggleSubpath(_ subpath: String, enabled: Bool) {\n        var updated = config\n        if enabled {\n            updated.disabledSubpaths.remove(subpath)\n        } else {\n            updated.disabledSubpaths.insert(subpath)\n        }\n        config = updated\n    }\n}\n"
  },
  {
    "path": "views/SessionPathRow.swift",
    "content": "import SwiftUI\n\nstruct SessionPathRow: View {\n    @Binding var config: SessionPathConfig\n    @ObservedObject var preferences: SessionPreferencesStore\n    let diagnostics: SessionsDiagnostics.Probe?\n    let canDelete: Bool\n    var onDelete: (() -> Void)? = nil\n    @State private var showingDiagnostics = false\n    @State private var showingAddIgnore = false\n    @State private var newIgnorePath = \"\"\n    \n    var body: some View {\n        settingsCard {\n            VStack(alignment: .leading, spacing: 12) {\n                // Header: Toggle + Name + Delete\n                HStack {\n                    Toggle(\"\", isOn: $config.enabled)\n                        .labelsHidden()\n                    \n                    VStack(alignment: .leading, spacing: 4) {\n                        Text(config.displayName ?? config.kind.displayName)\n                            .font(.subheadline)\n                            .fontWeight(.medium)\n                        Text(config.path)\n                            .font(.caption)\n                            .foregroundStyle(.secondary)\n                            .monospaced()\n                            .lineLimit(1)\n                            .textSelection(.enabled)\n                    }\n                    \n                    Spacer()\n                    \n                    if canDelete, let onDelete = onDelete {\n                        Button {\n                            onDelete()\n                        } label: {\n                            Label(\"Delete\", systemImage: \"trash\")\n                        }\n                        .buttonStyle(.bordered)\n                        .controlSize(.small)\n                    }\n                }\n                \n                // Diagnostics Summary\n                if let diagnostics = diagnostics {\n                    DisclosureGroup(isExpanded: $showingDiagnostics) {\n                        VStack(alignment: .leading, spacing: 8) {\n                            if diagnostics.exists {\n                                Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) {\n                                    GridRow {\n                                        Text(\"Exists\").font(.caption)\n                                        Text(diagnostics.exists ? \"Yes\" : \"No\")\n                                            .frame(maxWidth: .infinity, alignment: .trailing)\n                                    }\n                                    if diagnostics.isDirectory {\n                                        GridRow {\n                                            Text(\"Files\").font(.caption)\n                                            Text(\"\\(diagnostics.enumeratedCount)\")\n                                                .frame(maxWidth: .infinity, alignment: .trailing)\n                                        }\n                                    }\n                                    if let error = diagnostics.enumeratorError {\n                                        GridRow {\n                                            Text(\"Error\").font(.caption)\n                                            Text(error)\n                                                .font(.caption)\n                                                .foregroundStyle(.red)\n                                                .frame(maxWidth: .infinity, alignment: .trailing)\n                                        }\n                                    }\n                                }\n                                \n                                if !diagnostics.sampleFiles.isEmpty {\n                                    Divider()\n                                    VStack(alignment: .leading, spacing: 4) {\n                                        Text(\"Sample Files\")\n                                            .font(.caption)\n                                            .fontWeight(.medium)\n                                        ForEach(diagnostics.sampleFiles.prefix(5), id: \\.self) { file in\n                                            Text(file)\n                                                .font(.caption2)\n                                                .foregroundStyle(.secondary)\n                                                .monospaced()\n                                                .lineLimit(1)\n                                        }\n                                        if diagnostics.sampleFiles.count > 5 {\n                                            Text(\"(\\(diagnostics.sampleFiles.count - 5) more...)\")\n                                                .font(.caption2)\n                                                .foregroundStyle(.tertiary)\n                                        }\n                                    }\n                                }\n                            } else {\n                                Text(\"Directory does not exist\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                            }\n                        }\n                        .padding(.top, 4)\n                    } label: {\n                        HStack {\n                            Text(\"Diagnostics\")\n                                .font(.caption)\n                                .fontWeight(.medium)\n                            Spacer()\n                            if diagnostics.exists {\n                                Text(\"\\(diagnostics.enumeratedCount) files\")\n                                    .font(.caption)\n                                    .foregroundStyle(.secondary)\n                            }\n                        }\n                    }\n                }\n                \n                // Ignored Subpaths\n                VStack(alignment: .leading, spacing: 8) {\n                    HStack {\n                        Text(\"Ignored Subpaths\")\n                            .font(.caption)\n                            .fontWeight(.medium)\n                        Spacer()\n                        Button {\n                            showingAddIgnore = true\n                        } label: {\n                            Label(\"Add\", systemImage: \"plus\")\n                        }\n                        .buttonStyle(.borderless)\n                        .controlSize(.small)\n                    }\n                    \n                    if config.ignoredSubpaths.isEmpty {\n                        Text(\"No ignored paths\")\n                            .font(.caption2)\n                            .foregroundStyle(.tertiary)\n                    } else {\n                        ForEach(config.ignoredSubpaths, id: \\.self) { subpath in\n                            HStack {\n                                Text(subpath)\n                                    .font(.caption2)\n                                    .monospaced()\n                                    .foregroundStyle(.secondary)\n                                Spacer()\n                                Button {\n                                    removeIgnorePath(subpath)\n                                } label: {\n                                    Image(systemName: \"xmark.circle.fill\")\n                                        .foregroundStyle(.secondary)\n                                }\n                                .buttonStyle(.borderless)\n                                .controlSize(.small)\n                            }\n                        }\n                    }\n                }\n            }\n        }\n        .alert(\"Add Ignored Path\", isPresented: $showingAddIgnore) {\n            TextField(\"Path substring\", text: $newIgnorePath)\n            Button(\"Cancel\", role: .cancel) {\n                newIgnorePath = \"\"\n            }\n            Button(\"Add\") {\n                addIgnorePath()\n            }\n            .disabled(newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n        } message: {\n            Text(\"Enter a path substring to ignore. Files containing this substring will be skipped during scanning.\")\n        }\n    }\n    \n    private func addIgnorePath() {\n        let trimmed = newIgnorePath.trimmingCharacters(in: .whitespacesAndNewlines)\n        guard !trimmed.isEmpty else { return }\n        var updated = config\n        if !updated.ignoredSubpaths.contains(trimmed) {\n            updated.ignoredSubpaths.append(trimmed)\n            config = updated\n        }\n        newIgnorePath = \"\"\n    }\n    \n    private func removeIgnorePath(_ subpath: String) {\n        var updated = config\n        updated.ignoredSubpaths.removeAll { $0 == subpath }\n        config = updated\n    }\n    \n    @ViewBuilder\n    private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n        VStack(alignment: .leading, spacing: 8) {\n            content()\n        }\n        .padding(10)\n        .background(Color(nsColor: .separatorColor).opacity(0.35))\n        .cornerRadius(10)\n    }\n}\n"
  },
  {
    "path": "views/SessionsPathPane.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct SessionsPathPane: View {\n    @ObservedObject var preferences: SessionPreferencesStore\n    let fixedKind: SessionSource.Kind?\n    @State private var diagnostics: [String: SessionsDiagnostics.Probe] = [:]\n    @State private var loadingDiagnostics = false\n    @State private var showingAddPath = false\n    @State private var selectedKind: SessionSource.Kind\n    \n    init(preferences: SessionPreferencesStore, fixedKind: SessionSource.Kind? = nil) {\n        self.preferences = preferences\n        self.fixedKind = fixedKind\n        _selectedKind = State(initialValue: fixedKind ?? .codex)\n    }\n    \n    var body: some View {\n        let isFixed = fixedKind != nil\n\n        VStack(alignment: .leading, spacing: 18) {\n            // Default Paths Section\n            VStack(alignment: .leading, spacing: 10) {\n                Text(\"Default Paths\").font(.headline).fontWeight(.semibold)\n                \n                VStack(alignment: .leading, spacing: 12) {\n                    ForEach(defaultPaths.indices, id: \\.self) { index in\n                        let config = defaultPaths[index]\n                        SessionPathGroup(\n                            config: Binding(\n                                get: { \n                                    if let idx = findConfigIndex(config) {\n                                        return preferences.sessionPathConfigs[idx]\n                                    }\n                                    return config\n                                },\n                                set: { updateConfig($0) }\n                            ),\n                            diagnostics: diagnostics[config.id],\n                            canDelete: false,\n                            showToggle: !isFixed,\n                            showHeader: !isFixed\n                        )\n                        .disabled(!preferences.isCLIEnabled(config.kind))\n                        .opacity(preferences.isCLIEnabled(config.kind) ? 1.0 : 0.6)\n                    }\n                }\n            }\n            \n            // Custom Paths Section\n            VStack(alignment: .leading, spacing: 10) {\n                HStack {\n                    Text(\"Custom Paths\").font(.headline).fontWeight(.semibold)\n                    Spacer(minLength: 8)\n                    Button {\n                        showingAddPath = true\n                    } label: {\n                        Label(\"Add Custom Path\", systemImage: \"plus\")\n                    }\n                    .buttonStyle(.bordered)\n                }\n                \n                if customPaths.isEmpty {\n                    Text(\"No custom paths added yet.\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .padding(.vertical, 8)\n                } else {\n                    VStack(alignment: .leading, spacing: 12) {\n                        ForEach(customPaths.indices, id: \\.self) { index in\n                        let config = customPaths[index]\n                        SessionPathGroup(\n                            config: Binding(\n                                get: { \n                                    if let idx = findConfigIndex(config) {\n                                            return preferences.sessionPathConfigs[idx]\n                                        }\n                                        return config\n                                    },\n                                    set: { updateConfig($0) }\n                            ),\n                            diagnostics: diagnostics[config.id],\n                            canDelete: true,\n                            showToggle: true,\n                            showHeader: true,\n                            onDelete: {\n                                deleteConfig(config)\n                            }\n                        )\n                        .disabled(!preferences.isCLIEnabled(config.kind))\n                        .opacity(preferences.isCLIEnabled(config.kind) ? 1.0 : 0.6)\n                    }\n                }\n            }\n        }\n        }\n        .task {\n            ensureDefaultEnabled()\n            await refreshDiagnostics()\n        }\n        .sheet(isPresented: $showingAddPath) {\n            AddSessionPathSheet(\n                selectedKind: $selectedKind,\n                preferences: preferences,\n                fixedKind: fixedKind,\n                onAdd: { kind, path in\n                    addCustomPath(kind: kind, path: path)\n                }\n            )\n        }\n    }\n    \n    private var scopedConfigs: [SessionPathConfig] {\n        preferences.sessionPathConfigs.filter { config in\n            guard let fixedKind else { return true }\n            return config.kind == fixedKind\n        }\n    }\n    \n    private var defaultPaths: [SessionPathConfig] {\n        scopedConfigs.filter { $0.isDefault }\n            .sorted { $0.kind.rawValue < $1.kind.rawValue }\n    }\n    \n    private var customPaths: [SessionPathConfig] {\n        scopedConfigs.filter { !$0.isDefault }\n            .sorted { $0.path < $1.path }\n    }\n    \n    private func updateConfig(_ newConfig: SessionPathConfig) {\n        var configs = preferences.sessionPathConfigs\n        if let index = configs.firstIndex(where: { $0.id == newConfig.id }) {\n            configs[index] = newConfig\n            preferences.sessionPathConfigs = configs\n        }\n        Task {\n            ensureDefaultEnabled()\n            await refreshDiagnostics()\n        }\n    }\n    \n    private func deleteConfig(_ config: SessionPathConfig) {\n        var configs = preferences.sessionPathConfigs\n        configs.removeAll { $0.id == config.id }\n        preferences.sessionPathConfigs = configs\n        Task {\n            await refreshDiagnostics()\n        }\n    }\n    \n    private func findConfigIndex(_ config: SessionPathConfig) -> Int? {\n        preferences.sessionPathConfigs.firstIndex { $0.id == config.id }\n    }\n    \n    private func addCustomPath(kind: SessionSource.Kind, path: String) {\n        let newConfig = SessionPathConfig(\n            kind: kind,\n            path: path,\n            enabled: true,\n            displayName: nil\n        )\n        var configs = preferences.sessionPathConfigs\n        configs.append(newConfig)\n        preferences.sessionPathConfigs = configs\n        Task {\n            await refreshDiagnostics()\n        }\n    }\n    \n    private func ensureDefaultEnabled() {\n        guard let fixedKind else { return }\n        var configs = preferences.sessionPathConfigs\n        var didChange = false\n        for index in configs.indices {\n            if configs[index].isDefault && configs[index].kind == fixedKind && !configs[index].enabled {\n                configs[index].enabled = true\n                didChange = true\n            }\n        }\n        if didChange {\n            preferences.sessionPathConfigs = configs\n        }\n    }\n    \n    private func refreshDiagnostics() async {\n        loadingDiagnostics = true\n        defer { loadingDiagnostics = false }\n        \n        let diagnosticsService = SessionsDiagnosticsService()\n        var newDiagnostics: [String: SessionsDiagnostics.Probe] = [:]\n        \n        for config in scopedConfigs {\n            let url = URL(fileURLWithPath: config.path)\n            let probe = await diagnosticsService.probe(root: url, fileExtension: fileExtension(for: config.kind))\n            newDiagnostics[config.id] = probe\n        }\n        \n        await MainActor.run {\n            diagnostics = newDiagnostics\n        }\n    }\n    \n    private func fileExtension(for kind: SessionSource.Kind) -> String {\n        switch kind {\n        case .codex, .claude: return \"jsonl\"\n        case .gemini: return \"json\"\n        }\n    }\n}\n\n// MARK: - Add Session Path Sheet\n\nstruct AddSessionPathSheet: View {\n    @Binding var selectedKind: SessionSource.Kind\n    @ObservedObject var preferences: SessionPreferencesStore\n    let fixedKind: SessionSource.Kind?\n    let onAdd: (SessionSource.Kind, String) -> Void\n    @Environment(\\.dismiss) private var dismiss\n    @State private var selectedPath: String = \"\"\n    \n    var body: some View {\n        VStack(alignment: .leading, spacing: 20) {\n            Text(\"Add Custom Session Path\")\n                .font(.title2)\n                .fontWeight(.bold)\n            \n            if fixedKind == nil {\n                VStack(alignment: .leading, spacing: 12) {\n                    Text(\"Type\")\n                        .font(.subheadline)\n                        .fontWeight(.medium)\n                    Picker(\"\", selection: $selectedKind) {\n                        Text(\"Codex\").tag(SessionSource.Kind.codex)\n                        Text(\"Claude\").tag(SessionSource.Kind.claude)\n                        Text(\"Gemini\").tag(SessionSource.Kind.gemini)\n                    }\n                    .pickerStyle(.segmented)\n                }\n            }\n            \n            VStack(alignment: .leading, spacing: 12) {\n                Text(\"Path\")\n                    .font(.subheadline)\n                    .fontWeight(.medium)\n                HStack {\n                    TextField(\"Select directory...\", text: $selectedPath)\n                        .textFieldStyle(.roundedBorder)\n                        .disabled(true)\n                    Button(\"Choose...\") {\n                        selectDirectory()\n                    }\n                    .buttonStyle(.bordered)\n                }\n            }\n            \n            HStack {\n                Spacer()\n                Button(\"Cancel\") {\n                    dismiss()\n                }\n                .buttonStyle(.bordered)\n                Button(\"Add\") {\n                    guard !selectedPath.isEmpty else { return }\n                    onAdd(selectedKind, selectedPath)\n                    dismiss()\n                }\n                .buttonStyle(.borderedProminent)\n                .disabled(selectedPath.isEmpty || !preferences.isCLIEnabled(selectedKind))\n            }\n        }\n        .padding(20)\n        .frame(width: 500)\n    }\n    \n    private func selectDirectory() {\n        let panel = NSOpenPanel()\n        panel.canChooseFiles = false\n        panel.canChooseDirectories = true\n        panel.allowsMultipleSelection = false\n        panel.prompt = \"Select\"\n        \n        if panel.runModal() == .OK, let url = panel.url {\n            selectedPath = url.path\n        }\n    }\n}\n"
  },
  {
    "path": "views/SettingsCompatibility.swift",
    "content": "import SwiftUI\n\nextension View {\n    @ViewBuilder\n    func codmatePresentationSizingIfAvailable() -> some View {\n        if #available(macOS 15.0, *) {\n            self.presentationSizing(.automatic)\n        } else {\n            self\n        }\n    }\n\n    @ViewBuilder\n    func codmateNavigationSplitViewBalancedIfAvailable() -> some View {\n        if #available(macOS 14.0, *) {\n            self.navigationSplitViewStyle(.balanced)\n        } else {\n            self\n        }\n    }\n\n    @ViewBuilder\n    func codmateToolbarRemovingSidebarToggleIfAvailable() -> some View {\n        if #available(macOS 14.0, *) {\n            self.toolbar(removing: .sidebarToggle)\n        } else {\n            self\n        }\n    }\n\n    @ViewBuilder\n    func codmatePlainTextEditorStyleIfAvailable() -> some View {\n        if #available(macOS 14.0, *) {\n            self.textEditorStyle(.plain)\n        } else {\n            self\n        }\n    }\n}\n\n@available(macOS, introduced: 13.0, obsoleted: 14.0)\nextension View {\n    func onChange<Value: Equatable>(\n        of value: Value,\n        initial: Bool = false,\n        _ action: @escaping (Value) -> Void\n    ) -> some View {\n        modifier(OnChangeCompatModifier(value: value, initial: initial, action: action))\n    }\n\n    func onChange<Value: Equatable>(\n        of value: Value,\n        initial: Bool = false,\n        _ action: @escaping (Value, Value) -> Void\n    ) -> some View {\n        modifier(OnChangeCompatOldNewModifier(value: value, initial: initial, action: action))\n    }\n}\n\nprivate struct OnChangeCompatModifier<Value: Equatable>: ViewModifier {\n    let value: Value\n    let initial: Bool\n    let action: (Value) -> Void\n    @State private var hasInitialized = false\n\n    func body(content: Content) -> some View {\n        content\n            .onAppear {\n                guard !hasInitialized else { return }\n                hasInitialized = true\n                if initial {\n                    action(value)\n                }\n            }\n            .onChange(of: value) { newValue in\n                action(newValue)\n            }\n    }\n}\n\nprivate struct OnChangeCompatOldNewModifier<Value: Equatable>: ViewModifier {\n    let value: Value\n    let initial: Bool\n    let action: (Value, Value) -> Void\n    @State private var hasInitialized = false\n    @State private var previousValue: Value?\n\n    func body(content: Content) -> some View {\n        content\n            .onAppear {\n                guard !hasInitialized else { return }\n                hasInitialized = true\n                previousValue = value\n                if initial {\n                    action(value, value)\n                }\n            }\n            .onChange(of: value) { newValue in\n                let oldValue = previousValue ?? newValue\n                previousValue = newValue\n                action(oldValue, newValue)\n            }\n    }\n}\n"
  },
  {
    "path": "views/SettingsTabContent.swift",
    "content": "import SwiftUI\n\n/// Shared container for settings tab panes to ensure consistent padding and top alignment.\nstruct SettingsTabContent<Content: View>: View {\n    let content: () -> Content\n\n    init(@ViewBuilder _ content: @escaping () -> Content) {\n        self.content = content\n    }\n\n    var body: some View {\n        VStack(alignment: .leading, spacing: 0) {\n            content()\n                .frame(maxWidth: .infinity, alignment: .topLeading)\n            Spacer(minLength: 0)\n        }\n        .padding(.horizontal, 8)\n        .padding(.vertical, 8)\n    }\n}\n\n"
  },
  {
    "path": "views/SettingsView.swift",
    "content": "import AppKit\nimport SwiftUI\nimport UniformTypeIdentifiers\nimport GhosttyKit\n\nstruct SettingsView: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  @Binding private var selectedCategory: SettingCategory\n  @Binding private var selectedExtensionsTab: ExtensionsSettingsTab\n  @StateObject private var codexVM = CodexVM()\n  @StateObject private var geminiVM = GeminiVM()\n  @StateObject private var claudeVM = ClaudeCodeVM()\n  @StateObject private var updateViewModel = UpdateViewModel()\n  @StateObject private var wizardGuard = WizardGuard()\n  @EnvironmentObject private var viewModel: SessionListViewModel\n  @ObservedObject private var permissionsManager = SandboxPermissionsManager.shared\n  @State private var availableRemoteHosts: [SSHHost] = []\n  @State private var isRequestingSSHAccess = false\n  @State private var availableThemes: [String] = []\n  @State private var lastStableCategory: SettingCategory\n\n  init(\n    preferences: SessionPreferencesStore, selection: Binding<SettingCategory>,\n    extensionsTab: Binding<ExtensionsSettingsTab>\n  ) {\n    self._preferences = ObservedObject(wrappedValue: preferences)\n    self._selectedCategory = selection\n    self._selectedExtensionsTab = extensionsTab\n    self._lastStableCategory = State(initialValue: selection.wrappedValue)\n  }\n\n  var body: some View {\n    ZStack(alignment: .topLeading) {\n      WindowConfigurator { window in\n        window.isMovableByWindowBackground = false\n        window.identifier = NSUserInterfaceItemIdentifier(\"CodMateSettingsWindow\")\n        window.delegate = SettingsWindowDelegate.shared\n        if window.toolbar == nil {\n          let toolbar = NSToolbar(identifier: \"CodMateSettingsToolbar\")\n          SettingsToolbarCoordinator.shared.configure(toolbar: toolbar)\n          window.toolbar = toolbar\n        }\n        window.title = \"Settings\"\n        // Ensure the system titlebar bottom hairline is shown to unify\n        // appearance across all settings pages.\n        window.titlebarSeparatorStyle = .line\n\n        var minSize = window.contentMinSize\n        minSize.width = max(minSize.width, 800)\n        minSize.height = max(minSize.height, 560)\n        window.contentMinSize = minSize\n\n        var maxSize = window.contentMaxSize\n        if maxSize.width > 0 { maxSize.width = max(maxSize.width, 2000) }\n        if maxSize.height > 0 { maxSize.height = max(maxSize.height, 1400) }\n        window.contentMaxSize = maxSize\n      }\n      .frame(width: 0, height: 0)\n\n      NavigationSplitView {\n        List(SettingCategory.allCases, selection: $selectedCategory) { category in\n          let isSelected = (category == selectedCategory)\n          HStack(alignment: .center, spacing: 8) {\n            Image(systemName: category.icon)\n              .foregroundStyle(isSelected ? Color.white : Color.accentColor)\n              .frame(width: 26, alignment: .center)\n            VStack(alignment: .leading, spacing: 0) {\n              Text(category.title)\n                .font(.headline)\n              Text(category.description)\n                .font(.caption)\n                .foregroundColor(.secondary)\n            }\n            .frame(maxWidth: .infinity, alignment: .leading)\n            Spacer(minLength: 0)\n          }\n          .padding(.vertical, 6)\n          .tag(category)\n        }\n        .listStyle(.sidebar)\n        .controlSize(.small)\n        .environment(\\.defaultMinListRowHeight, 18)\n        .navigationSplitViewColumnWidth(min: 240, ideal: 260, max: 300)\n        .disabled(wizardGuard.isActive)\n      } detail: {\n        selectedCategoryView\n          .frame(maxWidth: .infinity, alignment: .topLeading)\n          .task { await codexVM.loadAll() }\n          .navigationSplitViewColumnWidth(min: 640, ideal: 800, max: 1800)\n      }\n      .codmateNavigationSplitViewBalancedIfAvailable()\n      .codmateToolbarRemovingSidebarToggleIfAvailable()\n    }\n    .frame(minWidth: 900, minHeight: 520)\n    .environmentObject(wizardGuard)\n    .onChange(of: selectedCategory) { newValue in\n      if wizardGuard.isActive {\n        if newValue != lastStableCategory {\n          selectedCategory = lastStableCategory\n        }\n      } else {\n        lastStableCategory = newValue\n      }\n    }\n  }\n\n  private final class SettingsWindowDelegate: NSObject, NSWindowDelegate {\n    static let shared = SettingsWindowDelegate()\n\n    func windowShouldClose(_ sender: NSWindow) -> Bool {\n      // Check if main window is still visible\n      let mainWindowId = NSUserInterfaceItemIdentifier(\"CodMateMainWindow\")\n      let mainWindowVisible = NSApplication.shared.windows.contains { window in\n        window.identifier == mainWindowId && window.isVisible\n      }\n\n      // Only hide Dock icon if:\n      // 1. No other app windows are visible, AND\n      // 2. User preference is \"Menu Bar Only\" mode\n      let defaults = UserDefaults.standard\n      let rawVisibility = defaults.string(forKey: \"codmate.systemMenu.visibility\") ?? \"visible\"\n      let visibility = SystemMenuVisibility(rawValue: rawVisibility) ?? .visible\n\n      if !mainWindowVisible && visibility == .menuOnly {\n        NSApplication.shared.setActivationPolicy(.accessory)\n      }\n\n      return true\n    }\n  }\n\n  private final class SettingsToolbarCoordinator: NSObject, NSToolbarDelegate {\n    static let shared = SettingsToolbarCoordinator()\n    private let spacerID = NSToolbarItem.Identifier(\"CodMateSettingsSpacer\")\n\n    func configure(toolbar: NSToolbar) {\n      toolbar.delegate = self\n      toolbar.allowsUserCustomization = false\n      toolbar.allowsExtensionItems = false\n      toolbar.displayMode = .iconOnly\n    }\n\n    func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {\n      [spacerID]\n    }\n\n    func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {\n      [spacerID]\n    }\n\n    func toolbar(\n      _ toolbar: NSToolbar,\n      itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier,\n      willBeInsertedIntoToolbar flag: Bool\n    ) -> NSToolbarItem? {\n      guard itemIdentifier == spacerID else { return nil }\n      let item = NSToolbarItem(itemIdentifier: itemIdentifier)\n      let view = NSView(frame: .zero)\n      view.translatesAutoresizingMaskIntoConstraints = false\n      view.isHidden = true\n      view.widthAnchor.constraint(equalToConstant: 1).isActive = true\n      view.heightAnchor.constraint(equalToConstant: 1).isActive = true\n      item.view = view\n      return item\n    }\n  }\n\n  @ViewBuilder\n  private var selectedCategoryView: some View {\n    switch selectedCategory {\n    case .general:\n      generalSettings\n    case .terminal:\n      terminalSettings\n    case .notifications:\n      notificationsSettings\n    case .command:\n      commandSettings\n    case .providers:\n      providersSettings\n    case .codex:\n      codexSettings\n    case .gemini:\n      geminiSettings\n    case .remoteHosts:\n      RemoteHostsSettingsPane(preferences: preferences)\n    case .gitReview:\n      gitReviewSettings\n    case .claudeCode:\n      claudeCodeSettings\n    case .advanced:\n      advancedSettings\n    case .mcpServer:\n      extensionsSettings\n    case .about:\n      AboutSettingsView(updateViewModel: updateViewModel)\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n    }\n  }\n\n  private var generalSettings: some View {\n    settingsScroll {\n      VStack(alignment: .leading, spacing: 20) {\n        VStack(alignment: .leading, spacing: 6) {\n          Text(\"General Settings\")\n            .font(.title2)\n            .fontWeight(.bold)\n          Text(\"Configure basic application settings\")\n            .font(.subheadline)\n            .foregroundColor(.secondary)\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"App Behavior\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Confirm before quit\", systemImage: \"exclamationmark.triangle\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Show confirmation dialog when quitting the app\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $preferences.confirmBeforeQuit)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Launch at login\", systemImage: \"power\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Automatically start CodMate when you log in\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $preferences.launchAtLogin)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n            }\n          }\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"System Menu\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"System menu bar icon\", systemImage: \"menubar.rectangle\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Control whether the menu bar icon appears\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Picker(\"\", selection: $preferences.systemMenuVisibility) {\n                  ForEach(SystemMenuVisibility.allCases) { visibility in\n                    Text(visibility.title).tag(visibility)\n                  }\n                }\n                .labelsHidden()\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .pickerStyle(.segmented)\n                .padding(2)\n                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))\n              }\n            }\n          }\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Editor\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {\n              GridRow {\n                let editors = EditorApp.installedEditors\n                VStack(alignment: .leading, spacing: 0) {\n                  Label(\"Default Editor\", systemImage: \"pencil.and.outline\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Used for quick open actions in Review and elsewhere\")\n                    .font(.caption).foregroundStyle(.secondary)\n                }\n                if editors.isEmpty {\n                  Text(\"No supported editors found. Install VS Code, Cursor, Zed, or Antigravity.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                } else {\n                  Picker(\"\", selection: $preferences.defaultFileEditor) {\n                    ForEach(editors) { app in\n                      editorLabel(for: app).tag(app)\n                    }\n                  }\n                  .labelsHidden()\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                }\n              }\n            }\n          }\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Search\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Global search panel\", systemImage: \"magnifyingglass\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Choose how the ⌘F panel appears\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Picker(\"Search panel style\", selection: $preferences.searchPanelStyle) {\n                  ForEach(GlobalSearchPanelStyle.allCases) { style in\n                    Text(style.title).tag(style)\n                  }\n                }\n                .labelsHidden()\n                .pickerStyle(.segmented)\n                .padding(2)\n                .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))\n                .overlay(\n                  RoundedRectangle(cornerRadius: 8, style: .continuous)\n                    .stroke(Color.secondary.opacity(0.12), lineWidth: 1)\n                )\n                .disabled(false)\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .gridColumnAlignment(.trailing)\n                .gridCellAnchor(.trailing)\n              }\n            }\n          }\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Message Types\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            messageTypeVisibilitySection()\n          }\n        }\n\n        #if APPSTORE\n          VStack(alignment: .leading, spacing: 10) {\n            Text(\"App Store Version\").font(.headline).fontWeight(.semibold)\n            settingsCard {\n              VStack(alignment: .leading, spacing: 12) {\n                HStack(alignment: .top, spacing: 12) {\n                  Image(systemName: \"info.circle.fill\")\n                    .font(.title2)\n                    .foregroundStyle(.blue)\n                  VStack(alignment: .leading, spacing: 8) {\n                    Text(\"About This Version\")\n                      .font(.subheadline)\n                      .fontWeight(.semibold)\n                    Text(\n                      \"You're using the Mac App Store version of CodMate, which includes enhanced security through App Sandbox.\"\n                    )\n                    .font(.caption)\n                    .foregroundColor(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                  }\n                }\n\n                Divider()\n\n                VStack(alignment: .leading, spacing: 8) {\n                  Label(\"Embedded Terminal Behavior\", systemImage: \"terminal\")\n                    .font(.subheadline)\n                    .fontWeight(.medium)\n                  Text(\n                    \"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.\"\n                  )\n                  .font(.caption)\n                  .foregroundColor(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n\n                  Text(\n                    \"To run CLI sessions, use the \\\"Copy Command\\\" or \\\"Open in Terminal.app\\\" buttons to execute commands in the external Terminal app.\"\n                  )\n                  .font(.caption)\n                  .foregroundColor(.blue)\n                  .fixedSize(horizontal: false, vertical: true)\n                }\n\n                Divider()\n\n                VStack(alignment: .leading, spacing: 8) {\n                  Label(\"Git Review Functionality\", systemImage: \"square.and.pencil\")\n                    .font(.subheadline)\n                    .fontWeight(.medium)\n                  Text(\n                    \"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.\"\n                  )\n                  .font(.caption)\n                  .foregroundColor(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n                }\n              }\n              .padding(4)\n            }\n          }\n        #endif\n      }\n      .padding(.bottom, 16)\n    }\n  }\n\n  // MARK: - Message Type Visibility Section\n  @ViewBuilder\n  private func messageTypeVisibilitySection() -> some View {\n    VStack(alignment: .leading, spacing: 16) {\n\n      // Timeline visibility section\n      VStack(alignment: .leading, spacing: 8) {\n        HStack {\n          Image(systemName: \"eye\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n          Text(\"Timeline visibility\")\n            .font(.subheadline)\n            .fontWeight(.medium)\n          Spacer()\n          Button(action: {\n            preferences.timelineVisibleKinds = MessageVisibilityKind.timelineDefault\n          }) {\n            Image(systemName: \"arrow.counterclockwise.circle.fill\")\n              .font(.subheadline)\n              .foregroundStyle(.secondary)\n          }\n          .buttonStyle(.plain)\n          .frame(width: 24, height: 24)\n          .help(\"Restore timeline visibility to defaults\")\n        }\n        Text(\"Choose which message types appear in the conversation timeline\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n\n        messageTypeGrid(for: $preferences.timelineVisibleKinds)\n      }\n\n      Divider()\n\n      // Markdown export section\n      VStack(alignment: .leading, spacing: 8) {\n        HStack {\n          Image(systemName: \"doc.text\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n          Text(\"Markdown export\")\n            .font(.subheadline)\n            .fontWeight(.medium)\n          Spacer()\n          Button(action: {\n            preferences.markdownVisibleKinds = MessageVisibilityKind.markdownDefault\n          }) {\n            Image(systemName: \"arrow.counterclockwise.circle.fill\")\n              .font(.subheadline)\n              .foregroundStyle(.secondary)\n          }\n          .buttonStyle(.plain)\n          .frame(width: 24, height: 24)\n          .help(\"Restore markdown export to defaults\")\n        }\n        Text(\"Choose which message types are included when exporting Markdown\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n\n        messageTypeGrid(for: $preferences.markdownVisibleKinds)\n      }\n    }\n    .frame(maxWidth: .infinity, alignment: .leading)\n  }\n\n  @ViewBuilder\n  private func messageTypeGrid(for selection: Binding<Set<MessageVisibilityKind>>) -> some View {\n    let columns = [\n      GridItem(.flexible(), spacing: 12),\n      GridItem(.flexible(), spacing: 12),\n      GridItem(.flexible(), spacing: 12),\n      GridItem(.flexible(), spacing: 12),\n    ]\n\n    LazyVGrid(columns: columns, alignment: .leading, spacing: 12) {\n      ForEach(messageTypeRows) { row in\n        if let kind = row.kind {\n          HStack(spacing: 6) {\n            Toggle(\"\", isOn: binding(selection, kind))\n              .labelsHidden()\n              .toggleStyle(.switch)\n              .controlSize(.small)\n            Text(row.title)\n              .font(.caption)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n        }\n      }\n    }\n  }\n\n  private func binding(\n    _ selection: Binding<Set<MessageVisibilityKind>>, _ kind: MessageVisibilityKind\n  ) -> Binding<Bool> {\n    Binding<Bool>(\n      get: { selection.wrappedValue.contains(kind) },\n      set: { newVal in\n        var s = selection.wrappedValue\n        if newVal { s.insert(kind) } else { s.remove(kind) }\n        selection.wrappedValue = s\n      }\n    )\n  }\n\n  private struct MessageTypeRow: Identifiable {\n    let id: String\n    let title: String\n    let kind: MessageVisibilityKind?\n    let level: Int\n    let isGroup: Bool\n  }\n\n  private var messageTypeRows: [MessageTypeRow] {\n    [\n      MessageTypeRow(\n        id: MessageVisibilityKind.user.rawValue, title: MessageVisibilityKind.user.settingsLabel,\n        kind: .user, level: 0,\n        isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.assistant.rawValue,\n        title: MessageVisibilityKind.assistant.settingsLabel, kind: .assistant,\n        level: 0, isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.reasoning.rawValue,\n        title: MessageVisibilityKind.reasoning.settingsLabel, kind: .reasoning,\n        level: 0, isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.codeEdit.rawValue,\n        title: MessageVisibilityKind.codeEdit.settingsLabel, kind: .codeEdit,\n        level: 0, isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.tool.rawValue, title: MessageVisibilityKind.tool.settingsLabel,\n        kind: .tool, level: 0,\n        isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.tokenUsage.rawValue,\n        title: MessageVisibilityKind.tokenUsage.settingsLabel, kind: .tokenUsage,\n        level: 0, isGroup: false),\n      MessageTypeRow(\n        id: MessageVisibilityKind.infoOther.rawValue,\n        title: MessageVisibilityKind.infoOther.settingsLabel, kind: .infoOther,\n        level: 0, isGroup: false),\n    ]\n  }\n\n  private var codexSettings: some View {\n    settingsScroll {\n      CodexSettingsView(codexVM: codexVM, preferences: preferences)\n    }\n  }\n\n  private var geminiSettings: some View {\n    settingsScroll {\n      GeminiSettingsView(vm: geminiVM, preferences: preferences)\n    }\n  }\n\n  private var claudeCodeSettings: some View {\n    settingsScroll {\n      ClaudeCodeSettingsView(vm: claudeVM, preferences: preferences)\n    }\n  }\n\n  private var gitReviewSettings: some View {\n    settingsScroll {\n      GitReviewSettingsView(preferences: preferences)\n    }\n  }\n\n  private var providersSettings: some View {\n    settingsScroll {\n      ProvidersSettingsView(preferences: preferences)\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n    }\n  }\n\n  // MARK: - Advanced\n  private var advancedSettings: some View {\n    settingsScroll {\n      AdvancedSettingsView(preferences: preferences)\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n    }\n  }\n\n  private var terminalSettings: some View {\n    settingsScroll {\n      if AppSandbox.isEnabled {\n        VStack(alignment: .leading, spacing: 20) {\n          VStack(alignment: .leading, spacing: 6) {\n            Text(\"Terminal Settings\")\n              .font(.title2)\n              .fontWeight(.bold)\n            Text(\"Embedded terminal features are unavailable in the App Store build.\")\n              .font(.subheadline)\n              .foregroundColor(.secondary)\n          }\n\n          VStack(alignment: .leading, spacing: 10) {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n              // Row: Copy to clipboard (always relevant)\n              // Row: Default external app (still relevant)\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Auto open external terminal\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"CodMate helps open the terminal app for external sessions\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                let terminals = externalTerminalOrderedProfiles(includeNone: true)\n                Picker(\"\", selection: $preferences.defaultResumeExternalAppId) {\n                  ForEach(terminals) { profile in\n                    Text(profile.displayTitle).tag(profile.id)\n                  }\n                }\n                .labelsHidden()\n                .pickerStyle(.menu)\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .gridColumnAlignment(.trailing)\n                .gridCellAnchor(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Copy new or resume commands to clipboard\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Automatically copy new or resume commands when starting sessions\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Toggle(\"\", isOn: $preferences.defaultResumeCopyToClipboard)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Prompt for Warp tab title\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Show an input dialog before copying Warp commands\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Toggle(\"\", isOn: $preferences.promptForWarpTitle)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n            }\n          }\n        }\n        .padding(.bottom, 16)\n      } else {\n        VStack(alignment: .leading, spacing: 20) {\n          VStack(alignment: .leading, spacing: 6) {\n            Text(\"Terminal Settings\")\n              .font(.title2)\n              .fontWeight(.bold)\n            Text(\"Configure terminal behavior and resume preferences\")\n              .font(.subheadline)\n              .foregroundColor(.secondary)\n          }\n\n          VStack(alignment: .leading, spacing: 10) {\n            Text(\"Embedded Terminal\").font(.headline).fontWeight(.semibold)\n            settingsCard {\n              Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                // Row: Embedded terminal toggle\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Run in embedded terminal\", systemImage: \"terminal\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Use the built-in terminal instead of an external one\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Toggle(\"\", isOn: $preferences.defaultResumeUseEmbeddedTerminal)\n                    .labelsHidden()\n                    .toggleStyle(.switch)\n                    .controlSize(.small)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                    .gridColumnAlignment(.trailing)\n                    .disabled(AppDistribution.isAppStore || AppSandbox.isEnabled)\n                }\n\n                gridDivider\n\n                if AppSandbox.isEnabled {\n                  // Row: Use CLI console (no shell)\n                  GridRow {\n                    VStack(alignment: .leading, spacing: 2) {\n                      Label(\"Use embedded CLI console (no shell)\", systemImage: \"text.terminal\")\n                        .font(.subheadline).fontWeight(.medium)\n                      Text(\"Starts codex/claude directly\")\n                        .font(.caption)\n                        .foregroundStyle(.secondary)\n                        .fixedSize(horizontal: false, vertical: true)\n                    }\n                    Toggle(\"\", isOn: $preferences.useEmbeddedCLIConsole)\n                      .labelsHidden()\n                      .toggleStyle(.switch)\n                      .controlSize(.small)\n                      .frame(maxWidth: .infinity, alignment: .trailing)\n                      .gridColumnAlignment(.trailing)\n                      .disabled(AppDistribution.isAppStore || AppSandbox.isEnabled)\n                  }\n\n                  gridDivider\n                }\n\n                // Row: Font family & size (system font panel)\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Font & size\", systemImage: \"textformat\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Opens the macOS font panel to pick a monospaced font.\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  FontPickerButton(\n                    fontName: $preferences.terminalFontName,\n                    fontSize: $preferences.terminalFontSize\n                  )\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n                  .disabled(!preferences.defaultResumeUseEmbeddedTerminal)\n                }\n\n                gridDivider\n\n                // Row: Cursor style only\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Cursor style\", systemImage: \"cursorarrow\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Choose the caret shape shown inside the terminal.\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Picker(\n                    \"\",\n                    selection: Binding(\n                      get: { preferences.terminalCursorStyleOption },\n                      set: { preferences.terminalCursorStyleOption = $0 }\n                    )\n                  ) {\n                    ForEach(TerminalCursorStyleOption.allCases) { option in\n                      Text(option.title).tag(option)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.menu)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n                  .disabled(!preferences.defaultResumeUseEmbeddedTerminal)\n                }\n\n                gridDivider\n\n                // Row: Dark mode theme\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Dark Mode Theme\", systemImage: \"moon.fill\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Terminal color scheme for dark mode\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Picker(\"\", selection: Binding(\n                    get: { preferences.terminalThemeName },\n                    set: { preferences.terminalThemeName = $0 }\n                  )) {\n                    ForEach(availableThemes, id: \\.self) { theme in\n                      Text(theme).tag(theme)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.menu)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n                  .disabled(!preferences.defaultResumeUseEmbeddedTerminal || availableThemes.isEmpty)\n                }\n\n                gridDivider\n\n                // Row: Light mode theme\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Light Mode Theme\", systemImage: \"sun.max.fill\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Terminal color scheme for light mode\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Picker(\"\", selection: Binding(\n                    get: { preferences.terminalThemeNameLight },\n                    set: { preferences.terminalThemeNameLight = $0 }\n                  )) {\n                    ForEach(availableThemes, id: \\.self) { theme in\n                      Text(theme).tag(theme)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.menu)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n                  .disabled(!preferences.defaultResumeUseEmbeddedTerminal || availableThemes.isEmpty)\n                }\n              }\n            }\n            .task {\n              if availableThemes.isEmpty {\n                availableThemes = GhosttyThemeLoader.loadAvailableThemes()\n              }\n            }\n          }\n\n          VStack(alignment: .leading, spacing: 10) {\n            Text(\"External Terminal\").font(.headline).fontWeight(.semibold)\n            settingsCard {\n              Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 12) {\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Auto open external terminal\", systemImage: \"arrow.up.right.square\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"CodMate helps open the terminal app for external sessions\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  let terminals = externalTerminalOrderedProfiles(includeNone: true)\n                  Picker(\"\", selection: $preferences.defaultResumeExternalAppId) {\n                    ForEach(terminals) { profile in\n                      Text(profile.displayTitle).tag(profile.id)\n                    }\n                  }\n                  .labelsHidden()\n                  .pickerStyle(.menu)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n                  .gridCellAnchor(.trailing)\n                }\n\n                gridDivider\n\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Copy new or resume commands to clipboard\", systemImage: \"doc.on.clipboard\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Automatically copy new or resume commands when starting sessions\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Toggle(\"\", isOn: $preferences.defaultResumeCopyToClipboard)\n                    .labelsHidden()\n                    .toggleStyle(.switch)\n                    .controlSize(.small)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                    .gridColumnAlignment(.trailing)\n                }\n\n                gridDivider\n\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Prompt for Warp tab title\", systemImage: \"text.bubble\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(\"Show an input dialog before copying Warp commands\")\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                  Toggle(\"\", isOn: $preferences.promptForWarpTitle)\n                    .labelsHidden()\n                    .toggleStyle(.switch)\n                    .controlSize(.small)\n                    .frame(maxWidth: .infinity, alignment: .trailing)\n                    .gridColumnAlignment(.trailing)\n                }\n              }\n            }\n          }\n        }\n        .padding(.bottom, 16)\n      }\n    }\n  }\n\n  private var notificationsSettings: some View {\n    settingsScroll {\n      VStack(alignment: .leading, spacing: 20) {\n        VStack(alignment: .leading, spacing: 6) {\n          Text(\"Notifications Settings\")\n            .font(.title2)\n            .fontWeight(.bold)\n          Text(\"Configure notification delivery and hooks\")\n            .font(.subheadline)\n            .foregroundColor(.secondary)\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Common\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Commit message notifications\", systemImage: \"square.and.pencil\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Notify when commit message generation completes or fails.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $preferences.commitMessageNotificationsEnabled)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Title & comment notifications\", systemImage: \"text.bubble\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Notify when title and comment generation completes or fails.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $preferences.titleCommentNotificationsEnabled)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Copy command notifications\", systemImage: \"doc.on.doc\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Notify after copying New/Resume commands to the clipboard.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $preferences.commandCopyNotificationsEnabled)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n            }\n          }\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Codex\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"TUI Notifications\", systemImage: \"terminal\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\n                    \"Show in-terminal notifications during TUI sessions (supported terminals only).\"\n                  )\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $codexVM.tuiNotifications)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .onChange(of: codexVM.tuiNotifications) { _ in\n                    codexVM.scheduleApplyTuiNotificationsDebounced()\n                  }\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"System Notifications\", systemImage: \"bell\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\n                    \"Forward Codex turn-complete events to macOS notifications via notify.\"\n                  )\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $codexVM.systemNotifications)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .onChange(of: codexVM.systemNotifications) { _ in\n                    codexVM.scheduleApplySystemNotificationsDebounced()\n                  }\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n\n              if let path = codexVM.notifyBridgePath {\n                gridDivider\n                GridRow {\n                  VStack(alignment: .leading, spacing: 2) {\n                    Label(\"Notify bridge\", systemImage: \"link\")\n                      .font(.subheadline).fontWeight(.medium)\n                    Text(path)\n                      .font(.caption)\n                      .foregroundStyle(.secondary)\n                      .fixedSize(horizontal: false, vertical: true)\n                  }\n                }\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Self-test\", systemImage: \"checkmark.seal\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Send a sample event through the notify bridge.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                HStack(spacing: 8) {\n                  if codexVM.notifyBridgeHealthy {\n                    Image(systemName: \"checkmark.seal.fill\").foregroundStyle(.green)\n                  } else {\n                    Image(systemName: \"exclamationmark.triangle.fill\")\n                      .foregroundStyle(.orange)\n                  }\n                  Button(\"Run Self-test\") { Task { await codexVM.runNotifySelfTest() } }\n                    .controlSize(.small)\n                  if let r = codexVM.notifySelfTestResult {\n                    Text(r).font(.caption).foregroundStyle(.secondary)\n                  }\n                }\n                .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n            }\n          }\n          .disabled(!preferences.cliCodexEnabled)\n          .opacity(preferences.cliCodexEnabled ? 1.0 : 0.6)\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Claude Code\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"System Notifications\", systemImage: \"bell\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Forward Claude Code permission and completion hooks to macOS via codmate://notify.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $claudeVM.notificationsEnabled)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .onChange(of: claudeVM.notificationsEnabled) { _ in\n                    claudeVM.scheduleApplyNotificationSettingsDebounced()\n                  }\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Self-test\", systemImage: \"checkmark.seal\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Send a sample event through the notify bridge.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                HStack(spacing: 8) {\n                  if claudeVM.notificationBridgeHealthy {\n                    Image(systemName: \"checkmark.seal.fill\").foregroundStyle(.green)\n                  } else {\n                    Image(systemName: \"exclamationmark.triangle.fill\")\n                      .foregroundStyle(.orange)\n                  }\n                  Button(\"Run Self-test\") { Task { await claudeVM.runNotificationSelfTest() } }\n                    .controlSize(.small)\n                  if let result = claudeVM.notificationSelfTestResult {\n                    Text(result).font(.caption).foregroundStyle(.secondary)\n                  }\n                }\n                .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n            }\n          }\n          .disabled(!preferences.cliClaudeEnabled)\n          .opacity(preferences.cliClaudeEnabled ? 1.0 : 0.6)\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Gemini CLI\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"System Notifications\", systemImage: \"bell\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Forward Gemini permission prompts to macOS via codmate://notify.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                Toggle(\"\", isOn: $geminiVM.notificationsEnabled)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .controlSize(.small)\n                  .onChange(of: geminiVM.notificationsEnabled) { _ in\n                    geminiVM.scheduleApplyNotificationSettingsDebounced()\n                  }\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 2) {\n                  Label(\"Self-test\", systemImage: \"checkmark.seal\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Send a sample event through the notify bridge.\")\n                    .font(.caption)\n                    .foregroundStyle(.secondary)\n                    .fixedSize(horizontal: false, vertical: true)\n                }\n                HStack(spacing: 8) {\n                  if geminiVM.notificationBridgeHealthy {\n                    Image(systemName: \"checkmark.seal.fill\").foregroundStyle(.green)\n                  } else {\n                    Image(systemName: \"exclamationmark.triangle.fill\")\n                      .foregroundStyle(.orange)\n                  }\n                  Button(\"Run Self-test\") { Task { await geminiVM.runNotificationSelfTest() } }\n                    .controlSize(.small)\n                  if let result = geminiVM.notificationSelfTestResult {\n                    Text(result).font(.caption).foregroundStyle(.secondary)\n                  }\n                }\n                .frame(maxWidth: .infinity, alignment: .trailing)\n              }\n            }\n          }\n          .disabled(!preferences.cliGeminiEnabled)\n          .opacity(preferences.cliGeminiEnabled ? 1.0 : 0.6)\n        }\n      }\n      .padding(.bottom, 16)\n    }\n    .task {\n      await claudeVM.loadAll()\n      await geminiVM.loadIfNeeded()\n    }\n  }\n\n  private var commandSettings: some View {\n    settingsScroll {\n      VStack(alignment: .leading, spacing: 20) {\n        VStack(alignment: .leading, spacing: 6) {\n          Text(\"Command Options\")\n            .font(.title2)\n            .fontWeight(.bold)\n          Text(\"Default sandbox and approval policies for Codex commands\")\n            .font(.subheadline)\n            .foregroundColor(.secondary)\n        }\n\n        VStack(alignment: .leading, spacing: 10) {\n          Text(\"Codex CLI Defaults\").font(.headline).fontWeight(.semibold)\n          settingsCard {\n            Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 18) {\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Sandbox policy (-s, --sandbox)\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Filesystem access level for generated commands\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Picker(\"\", selection: $preferences.defaultResumeSandboxMode) {\n                  ForEach(SandboxMode.allCases) { Text($0.title).tag($0) }\n                }\n                .labelsHidden()\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Approval policy (-a, --ask-for-approval)\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"When human confirmation is required\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Picker(\"\", selection: $preferences.defaultResumeApprovalPolicy) {\n                  ForEach(ApprovalPolicy.allCases) { Text($0.title).tag($0) }\n                }\n                .labelsHidden()\n                .frame(maxWidth: .infinity, alignment: .trailing)\n                .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Enable full-auto (--full-auto)\")\n                    .font(.subheadline).fontWeight(.medium)\n                  Text(\"Alias for on-failure approvals with workspace-write sandbox\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Toggle(\"\", isOn: $preferences.defaultResumeFullAuto)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n\n              gridDivider\n\n              GridRow {\n                VStack(alignment: .leading, spacing: 0) {\n                  Text(\"Bypass approvals & sandbox\")\n                    .font(.subheadline).fontWeight(.medium)\n                    .foregroundColor(.red)\n                  Text(\"--dangerously-bypass-approvals-and-sandbox (use with care)\")\n                    .font(.caption).foregroundColor(.secondary)\n                }\n                Toggle(\"\", isOn: $preferences.defaultResumeDangerBypass)\n                  .labelsHidden()\n                  .toggleStyle(.switch)\n                  .tint(.red)\n                  .frame(maxWidth: .infinity, alignment: .trailing)\n                  .gridColumnAlignment(.trailing)\n              }\n            }\n          }\n        }\n      }\n      .padding(.bottom, 16)\n    }\n  }\n\n  private var mcpMateURL: URL { URL(string: \"https://mcpmate.io/\")! }\n  private let mcpMateTagline = \"Dedicated MCP orchestration for Codex workflows.\"\n\n  private var extensionsSettings: some View {\n    ExtensionsSettingsView(\n      selectedTab: $selectedExtensionsTab,\n      preferences: preferences,\n      openMCPMateDownload: openMCPMateDownload\n    )\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n    .padding(.top, 24)\n    .padding(.horizontal, 24)\n    .padding(.bottom, 24)\n  }\n\n  private var remoteHostsSettings: some View {\n    let enabled = preferences.isCLIEnabled(.codex) || preferences.isCLIEnabled(.claude)\n    return settingsScroll {\n      VStack(alignment: .leading, spacing: 20) {\n        VStack(alignment: .leading, spacing: 6) {\n          Text(\"Remote Hosts\")\n            .font(.title2)\n            .fontWeight(.bold)\n          Text(\"Choose which SSH hosts CodMate should mirror for remote Codex/Claude sessions.\")\n            .font(.subheadline)\n            .foregroundColor(.secondary)\n        }\n\n        let sshPermissionGranted = permissionsManager.hasPermission(for: .sshConfig)\n\n        HStack(alignment: .firstTextBaseline) {\n          Spacer(minLength: 8)\n          HStack(spacing: 10) {\n            Button(role: .none) {\n              DispatchQueue.main.async {\n                preferences.enabledRemoteHosts = []\n              }\n            } label: {\n              Text(\"Clear All\")\n            }\n            .buttonStyle(.bordered)\n            .disabled(preferences.enabledRemoteHosts.isEmpty)\n            Button {\n              Task { await viewModel.syncRemoteHosts(force: true, refreshAfter: true) }\n            } label: {\n              Label(\"Sync Hosts\", systemImage: \"arrow.triangle.2.circlepath\")\n            }\n            .buttonStyle(.bordered)\n            .disabled(preferences.enabledRemoteHosts.isEmpty)\n            Button {\n              reloadRemoteHosts()\n            } label: {\n              Label(\"Refresh\", systemImage: \"arrow.clockwise\")\n            }\n            .buttonStyle(.bordered)\n            .disabled(!sshPermissionGranted)\n          }\n        }\n\n        if !sshPermissionGranted {\n          VStack(alignment: .leading, spacing: 8) {\n            Label(\"Grant Access to ~/.ssh\", systemImage: \"lock.square\")\n              .font(.headline)\n            Text(\n              \"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.\"\n            )\n            .font(.caption)\n            .foregroundColor(.secondary)\n            Button {\n              guard !isRequestingSSHAccess else { return }\n              isRequestingSSHAccess = true\n              Task {\n                let granted = await permissionsManager.requestPermission(for: .sshConfig)\n                await MainActor.run {\n                  isRequestingSSHAccess = false\n                  if granted { reloadRemoteHosts() }\n                }\n              }\n            } label: {\n              HStack(spacing: 6) {\n                if isRequestingSSHAccess {\n                  ProgressView()\n                    .controlSize(.small)\n                }\n                Text(isRequestingSSHAccess ? \"Requesting…\" : \"Grant Access\")\n              }\n              .frame(maxWidth: .infinity)\n            }\n            .buttonStyle(.borderedProminent)\n          }\n          .padding()\n          .background(Color(nsColor: .separatorColor).opacity(0.2))\n          .cornerRadius(10)\n        }\n\n        let hosts = sshPermissionGranted ? availableRemoteHosts : []\n        if sshPermissionGranted {\n          if hosts.isEmpty {\n            VStack(alignment: .leading, spacing: 8) {\n              Text(\"No SSH hosts were found in ~/.ssh/config.\")\n                .font(.body)\n                .foregroundColor(.secondary)\n              Text(\n                \"Add host aliases to your SSH config, then refresh to enable remote session mirroring.\"\n              )\n              .font(.caption)\n              .foregroundStyle(.tertiary)\n            }\n            .padding(.vertical, 12)\n            .frame(maxWidth: .infinity, alignment: .leading)\n          } else {\n            VStack(alignment: .leading, spacing: 10) {\n              ForEach(hosts, id: \\.alias) { host in\n                VStack(alignment: .leading, spacing: 2) {\n                  Toggle(isOn: bindingForRemoteHost(alias: host.alias)) {\n                    Text(host.alias)\n                      .font(.body)\n                      .fontWeight(.medium)\n                  }\n                  .toggleStyle(.switch)\n                  let (statusText, statusColor) = syncStatusDescription(for: host.alias)\n                  Text(statusText)\n                    .font(.caption2)\n                    .foregroundColor(statusColor)\n                }\n              }\n            }\n            .padding(.vertical, 4)\n          }\n        } else {\n          VStack(alignment: .leading, spacing: 8) {\n            Text(\"Grant access above to inspect ~/.ssh/config.\")\n              .font(.body)\n              .foregroundColor(.secondary)\n            Text(\"CodMate cannot mirror remote sessions until it can read your SSH config.\")\n              .font(.caption)\n              .foregroundStyle(.tertiary)\n          }\n          .padding(.vertical, 12)\n          .frame(maxWidth: .infinity, alignment: .leading)\n        }\n\n        let hostAliases = Set(hosts.map { $0.alias })\n        let dangling = preferences.enabledRemoteHosts.subtracting(hostAliases)\n        if sshPermissionGranted && !dangling.isEmpty {\n          VStack(alignment: .leading, spacing: 6) {\n            Text(\"Unavailable Hosts\")\n              .font(.subheadline)\n              .fontWeight(.semibold)\n            Text(\n              \"The following host aliases are enabled but not present in your current SSH config:\"\n            )\n            .font(.caption)\n            .foregroundColor(.secondary)\n            ForEach(Array(dangling).sorted(), id: \\.self) { alias in\n              Text(\"• \\(alias)\")\n                .font(.caption)\n                .foregroundStyle(.tertiary)\n            }\n          }\n          .padding(.vertical, 6)\n        }\n\n        Text(\n          \"CodMate mirrors only the hosts you enable. Hosts that prompt for passwords will open interactively when needed.\"\n        )\n        .font(.caption)\n        .foregroundStyle(.secondary)\n      }\n      .onAppear {\n        if permissionsManager.hasPermission(for: .sshConfig) && availableRemoteHosts.isEmpty {\n          DispatchQueue.main.async { reloadRemoteHosts() }\n        }\n      }\n      .onChange(of: permissionsManager.hasPermission(for: .sshConfig)) { granted in\n        if granted {\n          reloadRemoteHosts()\n        } else {\n          availableRemoteHosts = []\n        }\n      }\n    }\n    .disabled(!enabled)\n    .opacity(enabled ? 1.0 : 0.6)\n  }\n\n  @MainActor\n  private func reloadRemoteHosts() {\n    guard permissionsManager.hasPermission(for: .sshConfig) else {\n      availableRemoteHosts = []\n      return\n    }\n    let resolver = SSHConfigResolver()\n    let hosts = resolver.resolvedHosts().sorted { $0.alias.lowercased() < $1.alias.lowercased() }\n    availableRemoteHosts = hosts\n    let hostAliases = Set(hosts.map { $0.alias })\n    let filtered = preferences.enabledRemoteHosts.filter { hostAliases.contains($0) }\n    if filtered.count != preferences.enabledRemoteHosts.count {\n      DispatchQueue.main.async {\n        preferences.enabledRemoteHosts = Set(filtered)\n      }\n    }\n  }\n\n  private func bindingForRemoteHost(alias: String) -> Binding<Bool> {\n    Binding(\n      get: { preferences.enabledRemoteHosts.contains(alias) },\n      set: { isOn in\n        DispatchQueue.main.async {\n          var hosts = preferences.enabledRemoteHosts\n          if isOn {\n            hosts.insert(alias)\n          } else {\n            hosts.remove(alias)\n          }\n          preferences.enabledRemoteHosts = hosts\n        }\n      }\n    )\n  }\n\n  private static let relativeFormatter: RelativeDateTimeFormatter = {\n    let f = RelativeDateTimeFormatter()\n    f.unitsStyle = .full\n    return f\n  }()\n\n  private func syncStatusDescription(for alias: String) -> (String, Color) {\n    guard let state = viewModel.remoteSyncStates[alias] else {\n      return (\"Not synced yet\", .secondary)\n    }\n    switch state {\n    case .idle:\n      return (\"Not synced yet\", .secondary)\n    case .syncing:\n      return (\"Syncing…\", .secondary)\n    case .succeeded(let date):\n      let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date())\n      return (\"Last synced \\(relative)\", .secondary)\n    case .failed(let date, let message):\n      let relative = Self.relativeFormatter.localizedString(for: date, relativeTo: Date())\n      let detail = Self.syncFailureDetail(from: message)\n      if detail.isEmpty {\n        return (\"Sync failed \\(relative)\", .red)\n      }\n      return (\"Sync failed \\(relative): \\(detail)\", .red)\n    }\n  }\n\n  private static func syncFailureDetail(from rawMessage: String) -> String {\n    let firstLine =\n      rawMessage\n      .split(whereSeparator: \\.isNewline)\n      .first\n      .map { String($0).trimmingCharacters(in: .whitespacesAndNewlines) } ?? \"\"\n    guard !firstLine.isEmpty else { return \"\" }\n\n    let prefix = \"sync failed\"\n    if firstLine.lowercased().hasPrefix(prefix) {\n      var separators = CharacterSet.whitespacesAndNewlines\n      separators.insert(charactersIn: \":-–—\")\n      let remainder = firstLine.dropFirst(prefix.count)\n      let sanitized = String(remainder).trimmingCharacters(in: separators)\n      return sanitized\n    }\n    return firstLine\n  }\n\n  private func resetToDefaults() {\n    preferences.projectsRoot = SessionPreferencesStore.defaultProjectsRoot(\n      for: FileManager.default.homeDirectoryForCurrentUser)\n    preferences.notesRoot = SessionPreferencesStore.defaultNotesRoot(\n      for: preferences.sessionsRoot)\n    preferences.codexCommandPath = \"\"\n    preferences.claudeCommandPath = \"\"\n    preferences.geminiCommandPath = \"\"\n    preferences.defaultResumeUseEmbeddedTerminal = true\n    preferences.defaultResumeCopyToClipboard = true\n    preferences.defaultResumeExternalAppId = \"terminal\"\n    preferences.defaultResumeSandboxMode = .workspaceWrite\n    preferences.defaultResumeApprovalPolicy = .onRequest\n    preferences.defaultResumeFullAuto = false\n    preferences.defaultResumeDangerBypass = false\n  }\n\n  private func openMCPMateDownload() {\n    NSWorkspace.shared.open(mcpMateURL)\n  }\n\n  // MARK: - Helper Views\n\n  private func settingsScroll<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n    ScrollView {\n      content()\n        .frame(maxWidth: .infinity, alignment: .topLeading)\n        .padding(.top, 24)\n        .padding(.horizontal, 24)\n        .padding(.bottom, 32)\n    }\n    // Allow the scroll view to clip to its bounds so the system\n    // titlebar bottom separator (hairline) remains visible consistently.\n  }\n\n  @ViewBuilder\n  private func settingsCard<Content: View>(@ViewBuilder _ content: () -> Content) -> some View {\n    VStack(alignment: .leading, spacing: 8) {\n      content()\n    }\n    .padding(10)\n    .background(Color(nsColor: .separatorColor).opacity(0.35))\n    .cornerRadius(10)\n  }\n\n  @ViewBuilder\n  private var gridDivider: some View {\n    Divider()\n  }\n}\n\nstruct SettingsView_Previews: PreviewProvider {\n  static var previews: some View {\n    let prefs = SessionPreferencesStore()\n    let vm = SessionListViewModel(preferences: prefs)\n    return SettingsView(\n      preferences: prefs, selection: .constant(.general), extensionsTab: .constant(.mcp)\n    )\n    .environmentObject(vm)\n  }\n}\n"
  },
  {
    "path": "views/SimpleProviderPicker.swift",
    "content": "import SwiftUI\n#if os(macOS)\nimport AppKit\n#endif\n\n/// Simplified provider picker for CLI settings with only two options:\n/// - Default (Built-in): Use CLI's built-in provider\n/// - Auto-Proxy (CliProxyAPI): Route through CLI Proxy API\nstruct SimpleProviderPicker: View {\n  let builtInTitle: String\n  let autoProxyTitle: String\n  let builtInTooltip: String\n  let autoProxyTooltip: String\n\n  @Binding var providerId: String?\n\n  private enum ProviderOption: String, CaseIterable {\n    case builtIn = \"builtIn\"\n    case autoProxy = \"autoProxy\"\n  }\n\n  private var selection: Binding<ProviderOption> {\n    Binding(\n      get: {\n        providerId == UnifiedProviderID.autoProxyId ? .autoProxy : .builtIn\n      },\n      set: { newValue in\n        providerId = newValue == .autoProxy ? UnifiedProviderID.autoProxyId : nil\n      }\n    )\n  }\n\n  init(\n    builtInTitle: String = \"Default (Built-in)\",\n    autoProxyTitle: String = \"Auto-Proxy (CliProxyAPI)\",\n    builtInTooltip: String = \"Use CLI's built-in provider configuration\",\n    autoProxyTooltip: String = \"Route all requests through CliProxyAPI for unified provider management\",\n    providerId: Binding<String?>\n  ) {\n    self.builtInTitle = builtInTitle\n    self.autoProxyTitle = autoProxyTitle\n    self.builtInTooltip = builtInTooltip\n    self.autoProxyTooltip = autoProxyTooltip\n    self._providerId = providerId\n  }\n\n  var body: some View {\n    Picker(\"\", selection: selection) {\n      Text(builtInTitle).tag(ProviderOption.builtIn)\n      Text(autoProxyTitle).tag(ProviderOption.autoProxy)\n    }\n    .labelsHidden()\n    .pickerStyle(.segmented)\n    .tint(selection.wrappedValue == .autoProxy ? .red : nil)\n    .padding(2)\n    .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))\n  }\n}\n\n/// Simplified model picker for displaying sanitized model names\n/// Supports provider icon/prefix display and searchable menu for long lists\nstruct SimpleModelPicker: View {\n  let models: [String]\n  let includeDefault: Bool\n  let defaultTitle: String\n  let isDisabled: Bool\n  let sanitizeNames: Bool\n  let onEditModels: (() -> Void)?\n  let editModelsHelp: String?\n  let providerId: String?\n  let providerCatalog: UnifiedProviderCatalogModel?\n\n  @Binding var modelId: String?\n  @State private var searchText: String = \"\"\n  @State private var isMenuOpen: Bool = false\n\n  init(\n    models: [String],\n    includeDefault: Bool = true,\n    defaultTitle: String = \"(default)\",\n    isDisabled: Bool = false,\n    sanitizeNames: Bool = true,\n    onEditModels: (() -> Void)? = nil,\n    editModelsHelp: String? = nil,\n    providerId: String? = nil,\n    providerCatalog: UnifiedProviderCatalogModel? = nil,\n    modelId: Binding<String?>\n  ) {\n    self.models = models\n    self.includeDefault = includeDefault\n    self.defaultTitle = defaultTitle\n    self.isDisabled = isDisabled\n    self.sanitizeNames = sanitizeNames\n    self.onEditModels = onEditModels\n    self.editModelsHelp = editModelsHelp\n    self.providerId = providerId\n    self.providerCatalog = providerCatalog\n    self._modelId = modelId\n  }\n\n  private var filteredModels: [String] {\n    if searchText.isEmpty {\n      return models\n    }\n    let query = searchText.lowercased()\n    return models.filter { model in\n      let display = displayName(for: model).lowercased()\n      return display.contains(query) || model.lowercased().contains(query)\n    }\n  }\n\n  private var shouldUseSearchableMenu: Bool {\n    models.count > 10  // Use searchable menu for lists with more than 10 items\n  }\n\n  var body: some View {\n    HStack(spacing: 8) {\n      if shouldUseSearchableMenu {\n        searchableMenuPicker\n      } else {\n        standardPicker\n      }\n\n      if let onEditModels {\n        Button {\n          onEditModels()\n        } label: {\n          Image(systemName: \"slider.horizontal.3\")\n        }\n        .buttonStyle(.borderless)\n        .help(editModelsHelp ?? \"Edit models\")\n      }\n    }\n  }\n\n  private var standardPicker: some View {\n    Picker(\"\", selection: $modelId) {\n      if includeDefault {\n        Text(defaultTitle).tag(String?.none)\n      }\n      if models.isEmpty {\n        // Show a placeholder when models are empty and includeDefault is false\n        if !includeDefault {\n          Text(\"(no models available)\").tag(String?.none).disabled(true)\n        }\n      } else {\n        ForEach(models, id: \\.self) { model in\n          modelMenuItem(model: model)\n        }\n      }\n    }\n    .labelsHidden()\n    .disabled(isDisabled)\n  }\n\n  @State private var isSearchPopoverPresented = false\n\n  private var searchableMenuPicker: some View {\n    HStack(spacing: 4) {\n      Button {\n        isSearchPopoverPresented = true\n      } label: {\n        HStack {\n          if let modelId = modelId {\n            modelLabel(model: modelId)\n          } else {\n            Text(defaultTitle)\n          }\n          Image(systemName: \"chevron.down\")\n            .font(.system(size: 10))\n            .foregroundStyle(.secondary)\n        }\n      }\n      .disabled(isDisabled)\n      .popover(isPresented: $isSearchPopoverPresented, arrowEdge: .bottom) {\n        searchableModelListPopover\n      }\n    }\n  }\n\n  private var searchableModelListPopover: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      // Search field\n      TextField(\"Search models\", text: $searchText)\n        .textFieldStyle(.roundedBorder)\n        .padding(.top, 16)\n\n      Divider()\n\n      // Model list\n      ScrollView {\n        LazyVStack(alignment: .leading, spacing: 0) {\n          if includeDefault {\n            modelRowButton(\n              isSelected: modelId == nil,\n              action: {\n                modelId = nil\n                isSearchPopoverPresented = false\n              },\n              content: {\n                HStack {\n                  Text(defaultTitle)\n                  Spacer()\n                  if modelId == nil {\n                    Image(systemName: \"checkmark\")\n                  }\n                }\n              },\n              index: 0\n            )\n          }\n\n          if filteredModels.isEmpty {\n            Text(\"No models found\")\n              .foregroundStyle(.secondary)\n              .padding(.vertical, 8)\n          } else {\n            ForEach(Array(filteredModels.enumerated()), id: \\.element) { index, model in\n              modelRowButton(\n                isSelected: modelId == model,\n                action: {\n                  modelId = model\n                  isSearchPopoverPresented = false\n                },\n                content: {\n                  HStack {\n                    modelLabel(model: model)\n                    Spacer()\n                    if modelId == model {\n                      Image(systemName: \"checkmark\")\n                    }\n                  }\n                },\n                index: includeDefault ? index + 1 : index\n              )\n            }\n          }\n        }\n      }\n      .frame(width: 400, height: 300)\n    }\n    .padding(.bottom, 16)\n    .padding(.horizontal, 16)\n  }\n\n  @ViewBuilder\n  private func modelRowButton<Content: View>(\n    isSelected: Bool,\n    action: @escaping () -> Void,\n    @ViewBuilder content: () -> Content,\n    index: Int\n  ) -> some View {\n    Button(action: action) {\n      content()\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .padding(.vertical, 8)\n        .padding(.horizontal, 8)\n                .background(\n                  Group {\n                    if isSelected {\n                      Color.accentColor.opacity(0.1)\n                    } else if index % 2 == 1 {\n                      Color(nsColor: .separatorColor).opacity(0.08)\n                    } else {\n                      Color.clear\n                    }\n                  }\n                )\n        .contentShape(Rectangle())\n    }\n    .buttonStyle(ModelRowButtonStyle())\n    .onHover { hovering in\n      if hovering {\n        NSCursor.pointingHand.push()\n      } else {\n        NSCursor.pop()\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func modelMenuItem(model: String) -> some View {\n    modelLabel(model: model)\n      .tag(String?(model))\n  }\n\n  @ViewBuilder\n  private func modelLabel(model: String) -> some View {\n    HStack(spacing: 6) {\n      if let providerId = providerId, let catalog = providerCatalog {\n        modelLabelProviderInfo(model: model, providerId: providerId, catalog: catalog)\n      }\n      Text(displayName(for: model))\n    }\n  }\n\n  @ViewBuilder\n  private func modelLabelProviderInfo(model: String, providerId: String, catalog: UnifiedProviderCatalogModel) -> some View {\n    // When providerId is autoProxy, infer provider from model ID\n    if providerId == UnifiedProviderID.autoProxyId {\n      // Infer provider from model ID\n      if let title = catalog.inferProviderFromModel(model) {\n        if let icon = providerIcon(for: nil, title: title, modelId: model) {\n          icon\n            .resizable()\n            .interpolation(.high)\n            .aspectRatio(contentMode: .fit)\n            .frame(width: 14, height: 14)\n        } else {\n          Text(title)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(Color(nsColor: .separatorColor).opacity(0.5))\n            .cornerRadius(3)\n        }\n      }\n    } else {\n      // Use provider title from catalog\n      if let title = catalog.providerTitle(for: providerId) {\n        if let icon = providerIcon(for: providerId, title: title, modelId: model) {\n          icon\n            .resizable()\n            .interpolation(.high)\n            .aspectRatio(contentMode: .fit)\n            .frame(width: 14, height: 14)\n        } else {\n          Text(title)\n            .font(.caption2)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 4)\n            .padding(.vertical, 1)\n            .background(Color(nsColor: .separatorColor).opacity(0.5))\n            .cornerRadius(3)\n        }\n      }\n    }\n  }\n\n  private func providerIcon(for providerId: String?, title: String, modelId: String? = nil) -> Image? {\n    // If providerId is nil (autoProxy mode), infer icon from title (service provider name)\n    if providerId == nil || providerId == UnifiedProviderID.autoProxyId {\n      // Priority 1: Try OAuth provider icon by title\n      if let authProvider = LocalAuthProvider.allCases.first(where: { $0.displayName == title }) {\n        let iconName = iconNameForOAuthProvider(authProvider)\n        if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) {\n          return Image(nsImage: nsImage)\n        }\n      }\n      // Priority 2: Try API key provider icon by title (check customIcon first)\n      // Try to find provider by title to check for customIcon\n      if let provider = findProviderByTitle(title), let customIcon = provider.customIcon {\n        return Image(systemName: customIcon)\n      }\n      // Priority 3: Try preset PNG icon\n      if let iconName = ProviderIconResource.iconName(for: title),\n         let nsImage = ProviderIconResource.processedImage(\n           named: iconName,\n           size: NSSize(width: 14, height: 14),\n           isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n         ) {\n        return Image(nsImage: nsImage)\n      }\n      // No fallback - if title doesn't match any known provider, return nil (shows default circle)\n      return nil\n    }\n\n    let parsed = UnifiedProviderID.parse(providerId ?? \"\")\n    switch parsed {\n    case .oauth(let authProvider, _):\n      // For OAuth providers, we can use LocalAuthProviderIconView but need to return Image\n      // Since we're in a Menu context, we'll use the icon name directly\n      let iconName = iconNameForOAuthProvider(authProvider)\n      if let nsImage = ProviderIconThemeHelper.menuImage(named: iconName, size: NSSize(width: 14, height: 14)) {\n        return Image(nsImage: nsImage)\n      }\n      return nil\n    case .api(let apiId):\n      // Priority 1: Check for custom SF Symbol icon\n      if let provider = findProviderById(apiId), let customIcon = provider.customIcon {\n        return Image(systemName: customIcon)\n      }\n      // Priority 2: Try preset PNG icon\n      if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: title),\n         let nsImage = ProviderIconResource.processedImage(\n           named: iconName,\n           size: NSSize(width: 14, height: 14),\n           isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n         ) {\n        return Image(nsImage: nsImage)\n      }\n      return nil\n    default:\n      return nil\n    }\n  }\n\n  // Helper to find provider by ID from registry\n  private func findProviderById(_ id: String) -> ProvidersRegistryService.Provider? {\n    let registry = ProvidersRegistryService()\n    // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings\n    let loadedRegistry = registry.load()\n    return loadedRegistry.providers.first(where: { $0.id == id })\n  }\n\n  // Helper to find provider by title/name from registry\n  private func findProviderByTitle(_ title: String) -> ProvidersRegistryService.Provider? {\n    let registry = ProvidersRegistryService()\n    // Use synchronous load() instead of async listProviders() to avoid actor isolation warnings\n    let loadedRegistry = registry.load()\n    return loadedRegistry.providers.first(where: { provider in\n      let displayName = UnifiedProviderID.providerDisplayName(provider)\n      return displayName == title || provider.name == title || provider.id == title\n    })\n  }\n\n  private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String {\n    switch provider {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    case .antigravity: return \"AntigravityIcon\"\n    case .qwen: return \"QwenIcon\"\n    }\n  }\n\n  private func providerTitle(for providerId: String?) -> String? {\n    guard let providerId = providerId else { return nil }\n    return providerCatalog?.providerTitle(for: providerId)\n  }\n\n  private func displayName(for model: String) -> String {\n    if sanitizeNames {\n      return ModelNameSanitizer.sanitizeSingle(model)\n    }\n    return model\n  }\n}\n\n// MARK: - Model Row Button Style\nprivate struct ModelRowButtonStyle: ButtonStyle {\n  func makeBody(configuration: Configuration) -> some View {\n    configuration.label\n      .background(\n        configuration.isPressed || configuration.role == .destructive\n          ? Color(nsColor: .controlAccentColor).opacity(0.2)\n          : Color.clear\n      )\n      .contentShape(Rectangle())\n  }\n}\n"
  },
  {
    "path": "views/Skills/SkillPackageExplorerView.swift",
    "content": "import SwiftUI\n\n#if canImport(AppKit)\n  import AppKit\n#endif\n\nstruct SkillPackageExplorerView: View {\n  let skill: SkillSummary\n  var onReveal: () -> Void\n  var onUninstall: () -> Void\n  var showsHeader: Bool = true\n  var showsActions: Bool = true\n\n  @State private var treeQuery: String = \"\"\n  @State private var expandedDirs: Set<String> = []\n  @State private var nodes: [GitReviewNode] = []\n  @State private var displayedRows: [BrowserRow] = []\n  @State private var isLoading: Bool = false\n  @State private var treeError: String? = nil\n  @State private var treeTruncated: Bool = false\n  @State private var totalEntries: Int = 0\n  @State private var selectedPath: String? = nil\n  @State private var previewText: String = \"\"\n  @State private var previewError: String? = nil\n  #if canImport(AppKit)\n    @State private var previewImage: NSImage? = nil\n  #endif\n  @State private var previewTask: Task<Void, Never>? = nil\n  @State private var reloadToken: UUID = UUID()\n\n  private let indentStep: CGFloat = 16\n  private let chevronWidth: CGFloat = 16\n  private let rowHeight: CGFloat = 22\n  private let browserEntryLimit: Int = 4000\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      if showsHeader {\n        header\n      }\n      HStack(alignment: .top, spacing: 12) {\n        fileTree\n          .frame(minWidth: 240, maxWidth: 280, maxHeight: .infinity)\n        previewPane\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      }\n    }\n    .onAppear { reloadTree(force: true) }\n    .onChange(of: skill.id) { _ in\n      treeQuery = \"\"\n      expandedDirs = []\n      nodes = []\n      displayedRows = []\n      treeTruncated = false\n      totalEntries = 0\n      treeError = nil\n      selectedPath = nil\n      previewText = \"\"\n      previewError = nil\n      previewTask?.cancel()\n      #if canImport(AppKit)\n        previewImage = nil\n      #endif\n      reloadToken = UUID()\n      reloadTree(force: true)\n    }\n    .onChange(of: treeQuery) { _ in rebuildDisplayed() }\n    .onChange(of: selectedPath) { _ in loadPreview() }\n  }\n\n  private var header: some View {\n    HStack(alignment: .top, spacing: 12) {\n      VStack(alignment: .leading, spacing: 6) {\n        Text(skill.displayName)\n          .font(.title3.weight(.semibold))\n        Text(skill.description.isEmpty ? skill.summary : skill.description)\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n      }\n      Spacer()\n      if showsActions {\n        HStack(spacing: 8) {\n          Button {\n            onReveal()\n          } label: {\n            Image(systemName: \"finder\")\n          }\n          .buttonStyle(.borderless)\n          .help(\"Reveal in Finder\")\n          Button(role: .destructive) {\n            onUninstall()\n          } label: {\n            Image(systemName: \"trash\")\n          }\n          .buttonStyle(.borderless)\n          .help(\"Move to Trash\")\n        }\n      }\n    }\n  }\n\n  private var fileTree: some View {\n    VStack(alignment: .leading, spacing: 8) {\n      HStack {\n        ToolbarSearchField(\n          placeholder: \"Search files\",\n          text: $treeQuery,\n          onFocusChange: { _ in },\n          onSubmit: {}\n        )\n        .frame(maxWidth: .infinity)\n        Button {\n          collapseAll()\n        } label: {\n          Image(systemName: \"arrow.up.right.and.arrow.down.left\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Expand all\")\n        Button {\n          expandAll()\n        } label: {\n          Image(systemName: \"arrow.down.left.and.arrow.up.right\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"Collapse all\")\n      }\n\n      ScrollView {\n        VStack(alignment: .leading, spacing: 0) {\n          if isLoading {\n            HStack(spacing: 8) {\n              ProgressView()\n              Text(\"Loading files…\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n            }\n            .padding(.vertical, 6)\n          } else if let error = treeError {\n            Text(error)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .padding(.vertical, 6)\n          } else if displayedRows.isEmpty {\n            Text(\n              treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n                ? \"No files.\" : \"No matches.\"\n            )\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .padding(.vertical, 6)\n          } else {\n            LazyVStack(alignment: .leading, spacing: 0) {\n              ForEach(displayedRows) { row in\n                browserRow(row)\n              }\n            }\n          }\n        }\n      }\n\n      if treeTruncated {\n        Text(\"Showing first \\(browserEntryLimit) files. Narrow search to see more.\")\n          .font(.caption2)\n          .foregroundStyle(.secondary)\n      }\n      if !isLoading, treeError == nil, totalEntries > 0 {\n        Text(\"\\(totalEntries)\\(treeTruncated ? \"+\" : \"\") items\")\n          .font(.caption2)\n          .foregroundStyle(.tertiary)\n      }\n    }\n    .padding(10)\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private var previewPane: some View {\n    Group {\n      #if canImport(AppKit)\n        if let img = previewImage {\n          ScrollView([.horizontal, .vertical]) {\n            Image(nsImage: img)\n              .resizable()\n              .scaledToFit()\n              .frame(maxWidth: .infinity, maxHeight: .infinity)\n              .padding(12)\n          }\n        } else {\n          previewTextView\n        }\n      #else\n        previewTextView\n      #endif\n    }\n    .padding(10)\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private var previewTextView: some View {\n    let emptyText: String = {\n      if let error = previewError, !error.isEmpty { return error }\n      return selectedPath == nil ? \"Select a file to preview.\" : \"(Empty preview)\"\n    }()\n    return AttributedTextView(\n      text: previewText.isEmpty ? emptyText : previewText,\n      isDiff: false,\n      wrap: false,\n      showLineNumbers: true,\n      fontSize: 12,\n      searchQuery: \"\"\n    )\n  }\n\n  private func browserRow(_ row: BrowserRow) -> some View {\n    if row.node.isDirectory {\n      return AnyView(directoryRow(row))\n    }\n    return AnyView(fileRow(row))\n  }\n\n  private func directoryRow(_ row: BrowserRow) -> some View {\n    let key = row.directoryKey ?? row.node.name\n    let indent = CGFloat(max(row.depth, 0)) * indentStep\n    let isExpanded =\n      !treeQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n      || expandedDirs.contains(key)\n    return HStack(spacing: 0) {\n      ZStack(alignment: .leading) {\n        Color.clear.frame(width: indent + chevronWidth)\n        if row.depth > 0 {\n          let guideColor = Color.secondary.opacity(0.15)\n          ForEach(0..<row.depth, id: \\.self) { idx in\n            Rectangle()\n              .fill(guideColor)\n              .frame(width: 1)\n              .offset(x: CGFloat(idx) * indentStep + chevronWidth / 2)\n          }\n        }\n        HStack(spacing: 0) {\n          Spacer().frame(width: indent)\n          Image(systemName: isExpanded ? \"chevron.down\" : \"chevron.right\")\n            .font(.system(size: 11))\n            .foregroundStyle(.secondary)\n            .frame(width: chevronWidth, height: rowHeight)\n        }\n      }\n      HStack(spacing: 6) {\n        Image(systemName: \"folder\")\n          .font(.system(size: 13))\n          .foregroundStyle(.secondary)\n        Text(row.node.name)\n          .font(.system(size: 13))\n          .lineLimit(1)\n        Spacer(minLength: 0)\n      }\n      .padding(.trailing, 8)\n    }\n    .frame(height: rowHeight)\n    .contentShape(Rectangle())\n    .onTapGesture { toggleDirectory(key) }\n    .contextMenu {\n      #if canImport(AppKit)\n        Button(\"Reveal in Finder\") { revealPath(path: key, isDirectory: true) }\n      #endif\n    }\n  }\n\n  private func fileRow(_ row: BrowserRow) -> some View {\n    guard let path = row.filePath else { return AnyView(EmptyView()) }\n    let indent = CGFloat(max(row.depth, 0)) * indentStep\n    let isSelected = selectedPath == path\n    let icon = GitFileIcon.icon(for: path)\n    let bg = isSelected ? Color.accentColor.opacity(0.12) : Color.clear\n    return AnyView(\n      HStack(spacing: 0) {\n        ZStack(alignment: .leading) {\n          Color.clear.frame(width: indent)\n          if row.depth > 0 {\n            let guideColor = Color.secondary.opacity(0.15)\n            ForEach(0..<row.depth, id: \\.self) { idx in\n              Rectangle()\n                .fill(guideColor)\n                .frame(width: 1)\n                .offset(x: CGFloat(idx) * indentStep - indentStep / 2)\n            }\n          }\n        }\n        .frame(width: indent)\n        HStack(spacing: 6) {\n          Image(systemName: icon.name)\n            .font(.system(size: 12))\n            .foregroundStyle(icon.color)\n          Text(row.node.name)\n            .font(.system(size: 13))\n            .lineLimit(1)\n          Spacer(minLength: 0)\n        }\n        .padding(.trailing, 8)\n      }\n      .frame(height: rowHeight)\n      .contentShape(Rectangle())\n      .background(RoundedRectangle(cornerRadius: 4).fill(bg))\n      .onTapGesture { selectedPath = path }\n      .contextMenu {\n        #if canImport(AppKit)\n          Button(\"Reveal in Finder\") { revealPath(path: path, isDirectory: false) }\n        #endif\n      }\n    )\n  }\n\n  private func reloadTree(force: Bool = false) {\n    guard let rootURL = skill.path.map({ URL(fileURLWithPath: $0, isDirectory: true) }) else {\n      nodes = []\n      displayedRows = []\n      treeError = \"Skill folder not found.\"\n      isLoading = false\n      return\n    }\n    if !force, !nodes.isEmpty { return }\n    if !FileManager.default.fileExists(atPath: rootURL.path) {\n      nodes = []\n      displayedRows = []\n      treeError = \"Skill folder not found.\"\n      isLoading = false\n      return\n    }\n    isLoading = true\n    treeError = nil\n    let limit = browserEntryLimit\n    let token = reloadToken\n    let skillPath = rootURL.path\n    Task {\n      let result = buildBrowserTreeFromFileSystem(root: rootURL, limit: limit)\n      await MainActor.run {\n        guard token == reloadToken,\n          skill.path == skillPath\n        else { return }\n        isLoading = false\n        if let error = result.error, result.nodes.isEmpty {\n          treeError = error\n          nodes = []\n          displayedRows = []\n          treeTruncated = false\n          totalEntries = 0\n        } else {\n          treeError = nil\n          nodes = GitReviewTreeBuilder.explorerSort(result.nodes)\n          treeTruncated = result.truncated\n          totalEntries = result.total\n          rebuildDisplayed()\n          if selectedPath == nil {\n            let skillFile = rootURL.appendingPathComponent(\"SKILL.md\")\n            if FileManager.default.fileExists(atPath: skillFile.path) {\n              selectedPath = \"SKILL.md\"\n            }\n          }\n        }\n      }\n    }\n  }\n\n  private func rebuildDisplayed() {\n    let query = treeQuery.trimmingCharacters(in: .whitespacesAndNewlines)\n    let filtered = query.isEmpty ? nodes : filteredNodes(nodes, query: query)\n    displayedRows = flattenBrowserNodes(filtered, depth: 0, forceExpand: !query.isEmpty)\n  }\n\n  private func expandAll() {\n    expandedDirs = Set(allDirectoryKeys(nodes))\n    rebuildDisplayed()\n  }\n\n  private func collapseAll() {\n    expandedDirs.removeAll()\n    rebuildDisplayed()\n  }\n\n  private func allDirectoryKeys(_ nodes: [GitReviewNode]) -> [String] {\n    var keys: [String] = []\n    func walk(_ ns: [GitReviewNode]) {\n      for node in ns {\n        if let dir = node.dirPath {\n          keys.append(dir)\n          if let children = node.children { walk(children) }\n        }\n      }\n    }\n    walk(nodes)\n    return keys\n  }\n\n  private func filteredNodes(_ nodes: [GitReviewNode], query: String) -> [GitReviewNode] {\n    let q = query.trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !q.isEmpty else { return nodes }\n    func filter(_ ns: [GitReviewNode]) -> [GitReviewNode] {\n      var out: [GitReviewNode] = []\n      for n in ns {\n        if n.isDirectory {\n          let kids = n.children.map(filter) ?? []\n          if n.name.localizedCaseInsensitiveContains(q) || !kids.isEmpty {\n            var dir = n\n            dir.children = kids\n            out.append(dir)\n          }\n        } else if let p = n.fullPath {\n          if n.name.localizedCaseInsensitiveContains(q) || p.localizedCaseInsensitiveContains(q) {\n            out.append(n)\n          }\n        }\n      }\n      return out\n    }\n    return filter(nodes)\n  }\n\n  private func flattenBrowserNodes(_ nodes: [GitReviewNode], depth: Int, forceExpand: Bool)\n    -> [BrowserRow]\n  {\n    var rows: [BrowserRow] = []\n    for node in nodes {\n      rows.append(BrowserRow(node: node, depth: depth))\n      if node.isDirectory, let key = node.dirPath ?? (depth == 0 ? node.name : nil) {\n        if forceExpand || expandedDirs.contains(key) {\n          let children = GitReviewTreeBuilder.explorerSort(node.children ?? [])\n          rows.append(\n            contentsOf: flattenBrowserNodes(children, depth: depth + 1, forceExpand: forceExpand))\n        }\n      }\n    }\n    return rows\n  }\n\n  private func toggleDirectory(_ key: String) {\n    if expandedDirs.contains(key) {\n      expandedDirs.remove(key)\n    } else {\n      expandedDirs.insert(key)\n    }\n    rebuildDisplayed()\n  }\n\n  private func buildBrowserTreeFromFileSystem(root: URL, limit: Int) -> (\n    nodes: [GitReviewNode], truncated: Bool, total: Int, error: String?\n  ) {\n    let (paths, truncated, error) = collectFileSystemPaths(root: root, limit: limit)\n    if paths.isEmpty {\n      return ([], truncated, 0, error ?? \"Unable to enumerate skill files.\")\n    }\n    let nodes = buildBrowserTreeFromPaths(paths)\n    return (nodes, truncated, paths.count, error)\n  }\n\n  private func collectFileSystemPaths(root: URL, limit: Int) -> ([String], Bool, String?) {\n    let fm = FileManager.default\n    let keys: [URLResourceKey] = [.isDirectoryKey, .isPackageKey]\n    var encounteredError: String?\n    let options: FileManager.DirectoryEnumerationOptions = [.skipsPackageDescendants]\n    guard\n      let enumerator = fm.enumerator(\n        at: root, includingPropertiesForKeys: keys, options: options,\n        errorHandler: { _, error in\n          encounteredError = error.localizedDescription\n          return true\n        })\n    else {\n      return ([], false, \"Unable to enumerate skill files.\")\n    }\n\n    let rootResolved = root.resolvingSymlinksInPath()\n    let base = rootResolved.path.hasSuffix(\"/\") ? rootResolved.path : rootResolved.path + \"/\"\n    var collected: [String] = []\n    var truncated = false\n\n    while let item = enumerator.nextObject() as? URL {\n      let itemPath = item.resolvingSymlinksInPath().path\n      guard itemPath.hasPrefix(base) else { continue }\n      let relative = String(itemPath.dropFirst(base.count))\n      if relative.isEmpty { continue }\n      if relative == \".codmate.json\" || relative.hasSuffix(\"/.codmate.json\") { continue }\n      if relative == \".git\" || relative.hasPrefix(\".git/\") {\n        enumerator.skipDescendants()\n        continue\n      }\n      if let values = try? item.resourceValues(forKeys: Set(keys)), values.isDirectory == true {\n        continue\n      }\n      collected.append(relative)\n      if collected.count >= limit {\n        truncated = true\n        break\n      }\n    }\n    return (collected, truncated, encounteredError)\n  }\n\n  private func buildBrowserTreeFromPaths(_ paths: [String]) -> [GitReviewNode] {\n    struct Builder {\n      var children: [String: Builder] = [:]\n      var filePath: String? = nil\n    }\n    var root = Builder()\n    for path in paths {\n      let components = path.split(separator: \"/\").map(String.init)\n      guard !components.isEmpty else { continue }\n      func insert(_ index: Int, current: inout Builder) {\n        let key = components[index]\n        if index == components.count - 1 {\n          var child = current.children[key, default: Builder()]\n          child.filePath = path\n          current.children[key] = child\n        } else {\n          var child = current.children[key, default: Builder()]\n          insert(index + 1, current: &child)\n          current.children[key] = child\n        }\n      }\n      insert(0, current: &root)\n    }\n    func convert(_ builder: Builder, prefix: String?) -> [GitReviewNode] {\n      var nodes: [GitReviewNode] = []\n      for (name, child) in builder.children {\n        let fullPath = prefix.map { \"\\($0)/\\(name)\" } ?? name\n        if let filePath = child.filePath, child.children.isEmpty {\n          nodes.append(GitReviewNode(name: name, fullPath: filePath, dirPath: nil, children: nil))\n        } else {\n          let childrenNodes = convert(child, prefix: fullPath)\n          nodes.append(\n            GitReviewNode(\n              name: name,\n              fullPath: nil,\n              dirPath: fullPath,\n              children: GitReviewTreeBuilder.explorerSort(childrenNodes)\n            )\n          )\n        }\n      }\n      return GitReviewTreeBuilder.explorerSort(nodes)\n    }\n    return convert(root, prefix: nil)\n  }\n\n  private func loadPreview() {\n    previewTask?.cancel()\n    let token = reloadToken\n    previewTask = Task {\n      guard let root = skill.path.map({ URL(fileURLWithPath: $0, isDirectory: true) }),\n        let path = selectedPath\n      else {\n        await MainActor.run {\n          previewText = \"\"\n          previewError = nil\n          #if canImport(AppKit)\n            previewImage = nil\n          #endif\n        }\n        return\n      }\n      let fileURL = root.appendingPathComponent(path)\n      if isImagePath(path) {\n        #if canImport(AppKit)\n          let img = NSImage(contentsOf: fileURL)\n          await MainActor.run {\n            guard token == reloadToken else { return }\n            previewImage = img\n            previewText = \"\"\n            previewError = img == nil ? \"Unable to load image.\" : nil\n          }\n        #else\n          await MainActor.run {\n            guard token == reloadToken else { return }\n            previewText = \"Image preview not supported.\"\n            previewError = nil\n          }\n        #endif\n        return\n      }\n\n      do {\n        let handle = try FileHandle(forReadingFrom: fileURL)\n        let data = try handle.read(upToCount: 256_000) ?? Data()\n        try? handle.close()\n        if let text = String(data: data, encoding: .utf8) {\n          await MainActor.run {\n            #if canImport(AppKit)\n              guard token == reloadToken else { return }\n              previewImage = nil\n            #endif\n            previewText = text\n            previewError = text.isEmpty ? \"(Empty file)\" : nil\n          }\n        } else {\n          await MainActor.run {\n            #if canImport(AppKit)\n              guard token == reloadToken else { return }\n              previewImage = nil\n            #endif\n            previewText = \"\"\n            previewError = \"Binary or unsupported file.\"\n          }\n        }\n      } catch {\n        await MainActor.run {\n          #if canImport(AppKit)\n            guard token == reloadToken else { return }\n            previewImage = nil\n          #endif\n          previewText = \"\"\n          previewError = \"Unable to read file.\"\n        }\n      }\n    }\n  }\n\n  private func isImagePath(_ path: String) -> Bool {\n    let ext = URL(fileURLWithPath: path).pathExtension.lowercased()\n    return [\"png\", \"jpg\", \"jpeg\", \"gif\", \"bmp\", \"tiff\", \"tif\", \"heic\", \"heif\", \"webp\"].contains(ext)\n  }\n\n  #if canImport(AppKit)\n    private func revealPath(path: String, isDirectory: Bool) {\n      guard let root = skill.path.map({ URL(fileURLWithPath: $0, isDirectory: true) }) else {\n        return\n      }\n      let target = root.appendingPathComponent(path)\n      if isDirectory {\n        NSWorkspace.shared.open(target)\n      } else {\n        NSWorkspace.shared.activateFileViewerSelecting([target])\n      }\n    }\n  #endif\n}\n\nprivate struct BrowserRow: Identifiable {\n  let node: GitReviewNode\n  let depth: Int\n\n  var id: String { node.id + \"-\\(depth)\" }\n  var directoryKey: String? { node.dirPath }\n  var filePath: String? { node.fullPath }\n}\n"
  },
  {
    "path": "views/SkillsSettingsView.swift",
    "content": "import SwiftUI\nimport UniformTypeIdentifiers\n#if canImport(AppKit)\nimport AppKit\n#endif\n\nstruct SkillsSettingsView: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  @StateObject private var vm = SkillsLibraryViewModel()\n  @State private var searchFocused = false\n  @State private var pendingAction: PendingSkillAction?\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      headerRow\n      contentRow\n    }\n    .onDrop(of: [UTType.fileURL, UTType.url, UTType.plainText], isTargeted: nil) { providers in\n      vm.handleDrop(providers)\n    }\n    .sheet(isPresented: $vm.showInstallSheet) {\n      SkillsInstallSheet(vm: vm)\n        .frame(minWidth: 520, minHeight: 340)\n    }\n    .sheet(isPresented: $vm.showCreateSheet) {\n      SkillCreateSheet(preferences: preferences, vm: vm, startInWizard: vm.createStartsWithWizard)\n        .frame(minWidth: 760, minHeight: 520, maxHeight: 720)\n    }\n    .sheet(isPresented: $vm.showImportSheet) {\n      SkillsImportSheet(\n        candidates: $vm.importCandidates,\n        isImporting: vm.isImporting,\n        statusMessage: vm.importStatusMessage,\n        title: \"Import Skills\",\n        subtitle: \"Scan Home for existing Codex/Claude/Gemini skills and import into CodMate.\",\n        onCancel: { vm.cancelImport() },\n        onImport: { Task { await vm.importSelectedSkills() } }\n      )\n      .frame(minWidth: 760, minHeight: 480)\n    }\n    .sheet(item: $vm.installConflict) { conflict in\n      SkillConflictResolutionSheet(conflict: conflict, onResolve: { resolution in\n        vm.resolveInstallConflict(resolution)\n        vm.installConflict = nil\n      }, onCancel: {\n        vm.installConflict = nil\n      })\n      .frame(minWidth: 420, minHeight: 240)\n    }\n    .alert(item: $pendingAction) { action in\n      Alert(\n        title: Text(\"Move to Trash?\"),\n        message: Text(\"Move \\(action.skill.displayName) to Trash?\"),\n        primaryButton: .destructive(Text(\"Move to Trash\"), action: { vm.uninstall(id: action.skill.id) }),\n        secondaryButton: .cancel()\n      )\n    }\n    .task { await vm.load() }\n  }\n\n  private var headerRow: some View {\n    HStack(spacing: 8) {\n      Spacer(minLength: 0)\n      ToolbarSearchField(\n        placeholder: \"Search skills\",\n        text: $vm.searchText,\n        onFocusChange: { focused in searchFocused = focused },\n        onSubmit: {}\n      )\n      .frame(width: 240)\n\n      Button {\n        vm.prepareInstall(mode: vm.installMode)\n      } label: {\n        Label(\"Add\", systemImage: \"plus\")\n      }\n      Button {\n        vm.beginImportFromHome()\n      } label: {\n        Label(\"Import\", systemImage: \"tray.and.arrow.down\")\n      }\n    }\n  }\n\n  private var contentRow: some View {\n    HStack(alignment: .top, spacing: 12) {\n      skillsList\n        .frame(minWidth: 260, maxWidth: 320)\n      detailPanel\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n  }\n\n  private var skillsList: some View {\n    Group {\n      if vm.isLoading {\n        VStack(spacing: 8) {\n          ProgressView()\n          Text(\"Loading skills…\")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else if vm.filteredSkills.isEmpty {\n        VStack(spacing: 10) {\n          Image(systemName: \"sparkles\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"No Skills\")\n            .font(.title3)\n            .fontWeight(.medium)\n          Text(\"Install skills from folder, zip, or URL to get started.\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n            .multilineTextAlignment(.center)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        List(selection: $vm.selectedSkillId) {\n          ForEach(vm.filteredSkills) { skill in\n            HStack(alignment: .center, spacing: 8) {\n              Toggle(\n                \"\",\n                isOn: Binding(\n                  get: { skill.isSelected },\n                  set: { value in\n                    vm.updateSkillSelection(id: skill.id, value: value)\n                  }\n                )\n              )\n              .labelsHidden()\n              .controlSize(.small)\n\n              VStack(alignment: .leading, spacing: 4) {\n                Text(skill.displayName)\n                  .font(.body.weight(.medium))\n                Text(skill.summary)\n                  .font(.caption)\n                  .foregroundStyle(.secondary)\n                  .lineLimit(2)\n                if !skill.tags.isEmpty {\n                  Text(skill.tags.joined(separator: \" · \"))\n                    .font(.caption2)\n                    .foregroundStyle(.secondary)\n                }\n              }\n              Spacer(minLength: 8)\n              HStack(spacing: 6) {\n                MCPServerTargetToggle(\n                  provider: .codex,\n                  isOn: Binding(\n                    get: { skill.targets.codex },\n                    set: { value in\n                      vm.updateSkillTarget(id: skill.id, target: .codex, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.codex)\n                )\n                MCPServerTargetToggle(\n                  provider: .claude,\n                  isOn: Binding(\n                    get: { skill.targets.claude },\n                    set: { value in\n                      vm.updateSkillTarget(id: skill.id, target: .claude, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.claude)\n                )\n                MCPServerTargetToggle(\n                  provider: .gemini,\n                  isOn: Binding(\n                    get: { skill.targets.gemini },\n                    set: { value in\n                      vm.updateSkillTarget(id: skill.id, target: .gemini, value: value)\n                    }\n                  ),\n                  disabled: !preferences.isCLIEnabled(.gemini)\n                )\n              }\n            }\n            .padding(.vertical, 4)\n            .contentShape(Rectangle())\n            .onTapGesture { vm.selectedSkillId = skill.id }\n            .tag(skill.id as String?)\n            .contextMenu {\n#if canImport(AppKit)\n\n              let editors = EditorApp.installedEditors\n              openInEditorMenu(editors: editors) { editor in\n                vm.openInEditor(skill, using: editor)\n              }\n#endif\n              Button(\"Reveal in Finder\") { revealInFinder(skill) }\n              Button(\"Move to Trash\", role: .destructive) { confirmUninstall(skill) }\n            }\n          }\n        }\n        .listStyle(.inset)\n        .scrollContentBackground(.hidden)\n      }\n    }\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private var detailPanel: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      if let skill = vm.selectedSkill {\n        SkillPackageExplorerView(\n          skill: skill,\n          onReveal: { revealInFinder(skill) },\n          onUninstall: { confirmUninstall(skill) }\n        )\n        .id(skill.id)\n      } else {\n        VStack(spacing: 12) {\n          Image(systemName: \"doc.text\")\n            .font(.system(size: 32))\n            .foregroundStyle(.secondary)\n          Text(\"Select a skill to view details\")\n            .font(.subheadline)\n            .foregroundStyle(.secondary)\n        }\n        .frame(maxWidth: .infinity, maxHeight: .infinity)\n      }\n    }\n    .padding(12)\n    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)\n    .background(\n      RoundedRectangle(cornerRadius: 8, style: .continuous)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private func revealInFinder(_ skill: SkillSummary) {\n    guard let path = skill.path, !path.isEmpty else { return }\n    let url = URL(fileURLWithPath: path, isDirectory: true)\n#if canImport(AppKit)\n    NSWorkspace.shared.activateFileViewerSelecting([url])\n#endif\n  }\n\n  private func confirmUninstall(_ skill: SkillSummary) {\n    pendingAction = PendingSkillAction(skill: skill)\n  }\n}\n\nprivate struct PendingSkillAction: Identifiable {\n  let id = UUID()\n  let skill: SkillSummary\n}\n\nprivate struct SkillsInstallSheet: View {\n  @ObservedObject var vm: SkillsLibraryViewModel\n  @State private var importerPresented = false\n  @State private var isDropTargeted = false\n  @FocusState private var urlFieldFocused: Bool\n  private let rowWidth: CGFloat = 420\n  private let fieldWidth: CGFloat = 320\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 16) {\n      HStack(alignment: .firstTextBaseline) {\n        Text(\"Install Skill\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        Spacer()\n        Button {\n          vm.cancelInstall()\n          vm.prepareCreateSkill(startWithWizard: true)\n        } label: {\n          Image(systemName: \"sparkles\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"AI Wizard\")\n      }\n\n      dropArea\n\n      HStack {\n        Spacer(minLength: 0)\n        Picker(\"\", selection: $vm.installMode) {\n          ForEach(SkillInstallMode.allCases, id: \\.self) { mode in\n            Text(mode.title).tag(mode)\n          }\n        }\n        .labelsHidden()\n        .pickerStyle(.segmented)\n        .frame(width: 240)\n        Spacer(minLength: 0)\n      }\n\n      Group {\n        switch vm.installMode {\n        case .folder:\n          sourceRow(value: vm.pendingInstallURL?.path ?? \"Choose a folder…\") {\n            importerPresented = true\n          }\n        case .zip:\n          sourceRow(value: vm.pendingInstallURL?.path ?? \"Choose a zip file…\") {\n            importerPresented = true\n          }\n        case .url:\n          HStack {\n            Spacer(minLength: 0)\n            TextField(\"https://example.com/skill.zip\", text: $vm.pendingInstallText)\n              .focused($urlFieldFocused)\n              .textFieldStyle(.roundedBorder)\n              .frame(width: rowWidth)\n            Spacer(minLength: 0)\n          }\n        }\n      }\n      .frame(maxWidth: .infinity, alignment: .leading)\n\n      Spacer(minLength: 0)\n\n      VStack(alignment: .leading, spacing: 6) {\n        if let status = vm.installStatusMessage, !status.isEmpty {\n          Text(status)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        } else {\n          Text(\" \")\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n      }\n      .frame(height: 64)\n\n      HStack {\n        Spacer()\n        Button(\"Cancel\") { vm.cancelInstall() }\n        Button(\"Install\") { vm.finishInstall() }\n          .buttonStyle(.borderedProminent)\n          .disabled(!canInstall)\n      }\n    }\n    .padding(16)\n    .onAppear {\n      urlFieldFocused = false\n    }\n    .onChange(of: vm.installMode) { _ in\n      urlFieldFocused = false\n    }\n    .onDrop(of: [UTType.fileURL, UTType.url, UTType.plainText], isTargeted: $isDropTargeted) {\n      providers in\n      handleDrop(providers)\n    }\n    .fileImporter(\n      isPresented: $importerPresented,\n      allowedContentTypes: allowedTypes,\n      allowsMultipleSelection: false\n    ) { result in\n      if case .success(let urls) = result {\n        vm.pendingInstallURL = urls.first\n      }\n    }\n  }\n\n  private var dropArea: some View {\n    ZStack {\n      RoundedRectangle(cornerRadius: 10, style: .continuous)\n        .strokeBorder(\n          isDropTargeted ? Color.accentColor : Color.secondary.opacity(0.3),\n          style: StrokeStyle(lineWidth: 1, dash: [6, 4])\n        )\n        .frame(height: 120)\n        .background(\n          RoundedRectangle(cornerRadius: 10, style: .continuous)\n            .fill(isDropTargeted ? Color.accentColor.opacity(0.08) : Color.clear)\n        )\n      VStack(spacing: 6) {\n        Image(systemName: \"tray.and.arrow.down\")\n          .font(.system(size: 28))\n          .foregroundStyle(isDropTargeted ? Color.accentColor : Color.secondary)\n        Text(\"Drop a skill folder, zip file, or URL\")\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n        if let url = vm.pendingInstallURL {\n          Text(url.path)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n            .truncationMode(.middle)\n        } else if !vm.pendingInstallText.isEmpty {\n          Text(vm.pendingInstallText)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n            .lineLimit(1)\n            .truncationMode(.middle)\n        }\n      }\n      .padding(.horizontal, 16)\n    }\n  }\n\n  private func handleDrop(_ providers: [NSItemProvider]) -> Bool {\n    for provider in providers {\n      if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) {\n        provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { item, _ in\n          guard let data = item as? Data,\n            let url = URL(dataRepresentation: data, relativeTo: nil)\n          else { return }\n          Task { @MainActor in\n            applyFileURL(url)\n          }\n        }\n        return true\n      }\n      if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {\n        provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { item, _ in\n          if let url = item as? URL {\n            Task { @MainActor in\n              vm.installMode = .url\n              vm.pendingInstallText = url.absoluteString\n            }\n          }\n        }\n        return true\n      }\n      if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {\n        provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { item, _ in\n          let text: String?\n          if let data = item as? Data {\n            text = String(data: data, encoding: .utf8)\n          } else {\n            text = item as? String\n          }\n          guard let text = text?.trimmingCharacters(in: .whitespacesAndNewlines),\n            !text.isEmpty\n          else { return }\n          Task { @MainActor in\n            vm.installMode = .url\n            vm.pendingInstallText = text\n          }\n        }\n        return true\n      }\n    }\n    return false\n  }\n\n  private func applyFileURL(_ url: URL) {\n    let isZip = url.pathExtension.lowercased() == \"zip\"\n    let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false\n    if isDirectory {\n      vm.installMode = .folder\n      vm.pendingInstallURL = url\n    } else if isZip {\n      vm.installMode = .zip\n      vm.pendingInstallURL = url\n    } else {\n      vm.installMode = .zip\n      vm.pendingInstallURL = url\n    }\n  }\n\n  private var canInstall: Bool {\n    switch vm.installMode {\n    case .folder, .zip:\n      return vm.pendingInstallURL != nil\n    case .url:\n      return !vm.pendingInstallText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n    }\n  }\n\n  private var allowedTypes: [UTType] {\n    switch vm.installMode {\n    case .folder:\n      return [.folder]\n    case .zip:\n      return [.zip]\n    case .url:\n      return [.data]\n    }\n  }\n\n  private func sourceRow(value: String, action: @escaping () -> Void) -> some View {\n    HStack(spacing: 8) {\n      Spacer(minLength: 0)\n      HStack(spacing: 8) {\n        Text(value)\n          .font(.body)\n          .foregroundStyle(.secondary)\n          .lineLimit(1)\n          .truncationMode(.middle)\n          .frame(width: fieldWidth, alignment: .leading)\n          .padding(.horizontal, 8)\n          .padding(.vertical, 6)\n          .background(\n            RoundedRectangle(cornerRadius: 6, style: .continuous)\n              .fill(Color(nsColor: .textBackgroundColor))\n              .overlay(\n                RoundedRectangle(cornerRadius: 6, style: .continuous)\n                  .stroke(Color.secondary.opacity(0.2))\n              )\n          )\n        Button(\"Choose…\") { action() }\n      }\n      .frame(width: rowWidth, alignment: .center)\n      Spacer(minLength: 0)\n    }\n  }\n}\n\nprivate struct SkillConflictResolutionSheet: View {\n  let conflict: SkillInstallConflict\n  var onResolve: (SkillConflictResolution) -> Void\n  var onCancel: () -> Void\n\n  @State private var selection: Int = 0\n  @State private var renameText: String\n\n  init(conflict: SkillInstallConflict, onResolve: @escaping (SkillConflictResolution) -> Void, onCancel: @escaping () -> Void) {\n    self.conflict = conflict\n    self.onResolve = onResolve\n    self.onCancel = onCancel\n    _renameText = State(initialValue: conflict.suggestedId)\n  }\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 16) {\n      Text(\"Skill Already Exists\")\n        .font(.title3)\n        .fontWeight(.semibold)\n      Text(\"A skill named \\\"\\(conflict.proposedId)\\\" already exists at this location.\")\n        .font(.subheadline)\n        .foregroundStyle(.secondary)\n\n      Picker(\"\", selection: $selection) {\n        Text(\"Overwrite\").tag(0)\n        Text(\"Skip\").tag(1)\n        Text(\"Rename\").tag(2)\n      }\n      .labelsHidden()\n      .pickerStyle(.segmented)\n\n      if selection == 2 {\n        TextField(\"New name\", text: $renameText)\n          .textFieldStyle(.roundedBorder)\n      }\n\n      Spacer()\n\n      HStack {\n        Button(\"Cancel\") { onCancel() }\n        Spacer()\n        Button(\"Continue\") {\n          switch selection {\n          case 0: onResolve(.overwrite)\n          case 1: onResolve(.skip)\n          default:\n            let trimmed = renameText.trimmingCharacters(in: .whitespacesAndNewlines)\n            let finalName = trimmed.isEmpty ? conflict.suggestedId : trimmed\n            onResolve(.rename(finalName))\n          }\n        }\n        .buttonStyle(.borderedProminent)\n      }\n    }\n    .padding(16)\n  }\n}\n\nprivate struct SkillCreateSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  @ObservedObject var vm: SkillsLibraryViewModel\n  private let startInWizard: Bool\n  @State private var wizardActive: Bool\n\n  init(\n    preferences: SessionPreferencesStore,\n    vm: SkillsLibraryViewModel,\n    startInWizard: Bool = false\n  ) {\n    self.preferences = preferences\n    self.vm = vm\n    self.startInWizard = startInWizard\n    _wizardActive = State(initialValue: startInWizard)\n  }\n\n  var body: some View {\n    if wizardActive {\n      SkillWizardSheet(preferences: preferences, onApply: { draft in\n        applyDraft(draft)\n        wizardActive = false\n      }, onCancel: {\n        wizardActive = false\n      })\n    } else {\n      formBody\n    }\n  }\n\n  private var formBody: some View {\n    VStack(alignment: .leading, spacing: 16) {\n      HStack(alignment: .firstTextBaseline) {\n        Text(\"Create Skill\")\n          .font(.title3)\n          .fontWeight(.semibold)\n        Spacer()\n        Button {\n          wizardActive = true\n        } label: {\n          Image(systemName: \"sparkles\")\n        }\n        .buttonStyle(.borderless)\n        .help(\"AI Wizard\")\n      }\n      if vm.pendingWizardDraft == nil {\n        Text(\"Run the AI wizard to generate a draft, then review before creating.\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      VStack(alignment: .leading, spacing: 8) {\n        Text(\"Skill Name\")\n          .font(.subheadline)\n          .fontWeight(.medium)\n        TextField(\"e.g., data-analysis or custom-formatter\", text: $vm.newSkillName)\n          .textFieldStyle(.roundedBorder)\n        Text(\"Name will be converted to lowercase with hyphens (kebab-case)\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n\n      VStack(alignment: .leading, spacing: 8) {\n        Text(\"Description\")\n          .font(.subheadline)\n          .fontWeight(.medium)\n        TextField(\"Describe what this skill does and when to use it\", text: $vm.newSkillDescription)\n          .textFieldStyle(.roundedBorder)\n      }\n      if let preview = vm.wizardPreviewSkill {\n        SkillPackageExplorerView(\n          skill: preview,\n          onReveal: {},\n          onUninstall: {},\n          showsHeader: false,\n          showsActions: false\n        )\n        .id(preview.id)\n        .frame(minHeight: 220, maxHeight: 320)\n      }\n\n      if let error = vm.createErrorMessage {\n        Text(error)\n          .font(.caption)\n          .foregroundStyle(.red)\n      }\n\n      Spacer(minLength: 0)\n\n      HStack {\n        Button(\"Cancel\") { vm.cancelCreateSkill() }\n        Spacer()\n        Button(\"Create\") { vm.createSkill() }\n          .buttonStyle(.borderedProminent)\n          .disabled(\n            vm.pendingWizardDraft == nil\n              || vm.newSkillName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n          )\n      }\n    }\n    .padding(16)\n    .frame(minWidth: 480, minHeight: 280)\n    .onChange(of: vm.newSkillName) { _ in\n      if vm.pendingWizardDraft != nil {\n        vm.refreshWizardPreview()\n      }\n    }\n    .onChange(of: vm.newSkillDescription) { _ in\n      if vm.pendingWizardDraft != nil {\n        vm.refreshWizardPreview()\n      }\n    }\n  }\n\n  private func applyDraft(_ draft: SkillWizardDraft) {\n    vm.applyWizardDraft(draft)\n  }\n}\n"
  },
  {
    "path": "views/SplitControls.swift",
    "content": "import AppKit\nimport SwiftUI\nimport CoreImage\n\nprivate let menuIconSize = NSSize(width: 14, height: 14)\n\nfunc menuAssetNSImage(named name: String, invertForDarkMode: Bool = false) -> NSImage? {\n  guard let image = NSImage(named: name) else { return nil }\n  let resized = resizedMenuImage(image)\n  if invertForDarkMode {\n    return invertedMenuImage(resized) ?? resized\n  }\n  return resized\n}\n\nfunc menuSystemNSImage(named name: String) -> NSImage? {\n  guard let image = NSImage(systemSymbolName: name, accessibilityDescription: nil) else { return nil }\n  let resized = resizedMenuImage(image)\n  resized.isTemplate = true\n  return resized\n}\n\nprivate func resizedMenuImage(_ image: NSImage) -> NSImage {\n  let newImage = NSImage(size: menuIconSize)\n  newImage.lockFocus()\n  image.draw(\n    in: NSRect(origin: .zero, size: menuIconSize),\n    from: NSRect(origin: .zero, size: image.size),\n    operation: .copy,\n    fraction: 1.0\n  )\n  newImage.unlockFocus()\n  return newImage\n}\n\nfunc invertedMenuImage(_ image: NSImage) -> NSImage? {\n  guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {\n    return nil\n  }\n  let ciImage = CIImage(cgImage: cgImage)\n  guard let filter = CIFilter(name: \"CIColorInvert\") else { return nil }\n  filter.setValue(ciImage, forKey: kCIInputImageKey)\n  guard let outputImage = filter.outputImage else { return nil }\n  let rep = NSCIImageRep(ciImage: outputImage)\n  let newImage = NSImage(size: image.size)\n  newImage.addRepresentation(rep)\n  return newImage\n}\n\n// Shared split primary button used across detail toolbar and list empty state\nstruct SplitPrimaryMenuButton: View {\n  let title: String\n  let systemImage: String\n  let primary: () -> Void\n  let items: [SplitMenuItem]\n\n  var body: some View {\n    let h: CGFloat = 24\n    HStack(spacing: 0) {\n      Button(action: primary) {\n        Label(title, systemImage: systemImage)\n          .font(.system(size: 12, weight: .semibold))\n          .foregroundStyle(.primary)\n          .padding(.horizontal, 12)\n          .frame(height: h)\n          .contentShape(Rectangle())\n      }\n      .buttonStyle(.plain)\n\n      Rectangle()\n        .fill(Color.secondary.opacity(0.25))\n        .frame(width: 1, height: h - 8)\n        .padding(.vertical, 4)\n\n      ChevronMenuButton(items: items)\n        .frame(width: h, height: h)\n    }\n    .background(Color(nsColor: .controlBackgroundColor))\n    .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))\n    .overlay(\n      RoundedRectangle(cornerRadius: 6, style: .continuous)\n        .stroke(Color.secondary.opacity(0.25), lineWidth: 1)\n    )\n  }\n}\n\nstruct SplitMenuItem: Identifiable {\n  enum Kind {\n    case action(title: String, systemImage: String? = nil, assetImage: String? = nil, disabled: Bool = false, run: () -> Void)\n    case separator\n    case submenu(title: String, systemImage: String? = nil, assetImage: String? = nil, items: [SplitMenuItem])\n  }\n  let id: String\n  let kind: Kind\n\n  init(id: String = UUID().uuidString, kind: Kind) {\n    self.id = id\n    self.kind = kind\n  }\n}\n\nstruct SplitMenuItemsView: View {\n  let items: [SplitMenuItem]\n  @Environment(\\.colorScheme) private var colorScheme\n\n  var body: some View {\n    ForEach(items) { item in\n      switch item.kind {\n      case .separator:\n        Divider()\n      case .action(let title, let systemImage, let assetImage, let disabled, let run):\n        Button(action: run) {\n          if let asset = assetImage,\n             let icon = menuAssetNSImage(\n              named: asset,\n              invertForDarkMode: asset == \"ChatGPTIcon\" && colorScheme == .dark\n             )\n          {\n            Label {\n              Text(title)\n            } icon: {\n              Image(nsImage: icon)\n                .frame(width: 14, height: 14)\n            }\n          } else if let systemImage {\n            Label(title, systemImage: systemImage)\n          } else {\n            Text(title)\n          }\n        }\n        .disabled(disabled)\n      case .submenu(let title, let systemImage, let assetImage, let children):\n        Menu {\n          SplitMenuItemsView(items: children)\n        } label: {\n          if let asset = assetImage,\n             let icon = menuAssetNSImage(\n              named: asset,\n              invertForDarkMode: asset == \"ChatGPTIcon\" && colorScheme == .dark\n             )\n          {\n            Label {\n              Text(title)\n            } icon: {\n              Image(nsImage: icon)\n                .frame(width: 14, height: 14)\n            }\n          } else if let systemImage {\n            Label(title, systemImage: systemImage)\n          } else {\n            Text(title)\n          }\n        }\n      }\n    }\n  }\n}\n\nstruct ChevronMenuButton: NSViewRepresentable {\n  let items: [SplitMenuItem]\n\n  func makeCoordinator() -> Coordinator { Coordinator(items: items) }\n\n  func makeNSView(context: Context) -> NSButton {\n    let btn = NSButton(\n      title: \"\", target: context.coordinator, action: #selector(Coordinator.openMenu(_:)))\n    btn.isBordered = false\n    btn.bezelStyle = .regularSquare\n    if let img = NSImage(systemSymbolName: \"chevron.down\", accessibilityDescription: nil) {\n      btn.image = img\n    }\n    btn.translatesAutoresizingMaskIntoConstraints = false\n    return btn\n  }\n\n  func updateNSView(_ nsView: NSButton, context: Context) {\n    context.coordinator.items = items\n  }\n\n  final class Coordinator: NSObject {\n    var items: [SplitMenuItem]\n    private var runs: [() -> Void] = []\n    init(items: [SplitMenuItem]) { self.items = items }\n\n    @objc func openMenu(_ sender: NSButton) {\n      let menu = NSMenu()\n      runs.removeAll(keepingCapacity: true)\n      func build(_ items: [SplitMenuItem], into menu: NSMenu) {\n        for item in items {\n          switch item.kind {\n          case .separator:\n            menu.addItem(.separator())\n          case .action(let title, let systemImage, let assetImage, let disabled, let run):\n            let mi = NSMenuItem(\n              title: title, action: #selector(Coordinator.fire(_:)), keyEquivalent: \"\")\n            if let asset = assetImage,\n               let img = menuAssetNSImage(\n                named: asset,\n                invertForDarkMode: asset == \"ChatGPTIcon\" && isDarkMode()\n               )\n            {\n              mi.image = img\n            } else if let systemImage, let img = menuSystemNSImage(named: systemImage) {\n              mi.image = img\n            }\n            mi.tag = runs.count\n            mi.target = self\n            mi.isEnabled = !disabled\n            menu.addItem(mi)\n            runs.append(run)\n          case .submenu(let title, let systemImage, let assetImage, let children):\n            let mi = NSMenuItem(title: title, action: nil, keyEquivalent: \"\")\n            if let asset = assetImage,\n               let img = menuAssetNSImage(\n                named: asset,\n                invertForDarkMode: asset == \"ChatGPTIcon\" && isDarkMode()\n               )\n            {\n              mi.image = img\n            } else if let systemImage, let img = menuSystemNSImage(named: systemImage) {\n              mi.image = img\n            }\n            let sub = NSMenu(title: title)\n            build(children, into: sub)\n            mi.submenu = sub\n            menu.addItem(mi)\n          }\n        }\n      }\n      build(items, into: menu)\n      let location = NSPoint(x: sender.bounds.midX, y: sender.bounds.maxY - 3)\n      menu.popUp(positioning: nil, at: location, in: sender)\n    }\n\n    @objc func fire(_ sender: NSMenuItem) {\n      let idx = sender.tag\n      guard idx >= 0 && idx < runs.count else { return }\n      runs[idx]()\n    }\n\n    private func isDarkMode() -> Bool {\n      if let appearance = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) {\n        return appearance == .darkAqua\n      }\n      return false\n    }\n\n  }\n}\n"
  },
  {
    "path": "views/TaskListView.swift",
    "content": "import SwiftUI\n\n#if os(macOS)\n  import AppKit\n#endif\n\n/// TaskListView: Displays tasks and sessions in Tasks mode, maintaining the original session list appearance\nstruct TaskListView: View {\n  @EnvironmentObject private var viewModel: SessionListViewModel\n  @ObservedObject var workspaceVM: ProjectWorkspaceViewModel\n  @Binding var selection: Set<SessionSummary.ID>\n\n  let onResume: (SessionSummary) -> Void\n  let onReveal: (SessionSummary) -> Void\n  let onDeleteRequest: (SessionSummary) -> Void\n  let onExportMarkdown: (SessionSummary) -> Void\n  var isRunning: ((SessionSummary) -> Bool)? = nil\n  var isUpdating: ((SessionSummary) -> Bool)? = nil\n  var isAwaitingFollowup: ((SessionSummary) -> Bool)? = nil\n  var onPrimarySelect: ((SessionSummary) -> Void)? = nil\n  var onNewSessionWithTaskContext: ((CodMateTask, SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil\n  @State private var editingTask: CodMateTask? = nil\n  @Environment(\\.colorScheme) private var colorScheme\n  @State private var draggedSession: SessionSummary? = nil\n  @State private var taskToDelete: CodMateTask? = nil\n  @State private var showDeleteConfirmation = false\n  @State private var lastClickedID: SessionSummary.ID? = nil\n  @State private var pendingMove: PendingSessionMove? = nil\n  @State private var editingMode: EditTaskSheet.Mode = .edit\n  @State private var collapsedTaskIDs: Set<UUID> = []\n  @State private var sessionAssigningTask: SessionSummary? = nil\n\n  private var currentProjectId: String? {\n    viewModel.selectedProjectIDs.first\n  }\n\n  private struct PendingSessionMove: Identifiable {\n    let id = UUID()\n    let session: SessionSummary\n    let fromTask: CodMateTask\n    let toTask: CodMateTask\n  }\n\n  var body: some View {\n    VStack(spacing: 0) {\n      let enrichedTasks = workspaceVM.enrichTasksWithSessions()\n      let assignedSessionIds = Set(enrichedTasks.flatMap { $0.task.sessionIds })\n\n      // Check if sections are empty and show placeholder\n      if viewModel.sections.isEmpty {\n        if viewModel.isLoading {\n          VStack {\n            Spacer()\n            ProgressView(\"Scanning…\")\n            Spacer()\n          }\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n        } else {\n          emptyStateView\n        }\n      } else {\n        // Use the same sections from viewModel, but render tasks inline\n        List(selection: $selection) {\n          ForEach(viewModel.sections) { section in\n            Section {\n              ForEach(\n                enrichedSessionsForSection(\n                  section,\n                  enrichedTasks: enrichedTasks,\n                  assignedSessionIds: assignedSessionIds),\n                id: \\.id\n              ) { item in\n                switch item {\n                case .taskHeader(let taskWithSessions):\n                  taskRow(taskWithSessions)\n                case .taskSession(let taskWithSessions, let session):\n                  sessionRow(session, parentTask: taskWithSessions.task)\n                case .session(let session):\n                  sessionRow(session)\n                }\n              }\n            } header: {\n              sectionHeader(for: section)\n            }\n          }\n        }\n        .padding(.horizontal, -2)\n        .listStyle(.inset)\n        .contextMenu { taskListBackgroundContextMenu() }\n      }\n    }\n    .sheet(item: $editingTask) { task in\n      EditTaskSheet(\n        task: task,\n        mode: editingMode,\n        workspaceVM: workspaceVM,\n        onSave: { updatedTask in\n          Task {\n            await workspaceVM.updateTask(updatedTask)\n            editingTask = nil\n          }\n        },\n        onCancel: {\n          editingTask = nil\n        }\n      )\n    }\n    .sheet(item: $sessionAssigningTask) { session in\n      if let projectId = currentProjectId {\n        TaskSelectionSheet(\n          tasks: workspaceVM.tasks.filter { $0.projectId == projectId },\n          onSelect: { task in\n            Task {\n              var updatedTask = task\n              if !updatedTask.sessionIds.contains(session.id) {\n                updatedTask.sessionIds.append(session.id)\n                await workspaceVM.updateTask(updatedTask)\n              }\n              sessionAssigningTask = nil\n            }\n          },\n          onCreate: { title in\n            let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)\n            guard !trimmed.isEmpty else { return }\n            Task {\n              await workspaceVM.createTask(\n                title: trimmed,\n                description: nil,\n                projectId: projectId\n              )\n              if let newTask = workspaceVM.tasks.first(\n                where: {\n                  $0.projectId == projectId\n                    && $0.effectiveTitle.localizedCaseInsensitiveCompare(trimmed) == .orderedSame\n                }\n              ) {\n                var updatedTask = newTask\n                if !updatedTask.sessionIds.contains(session.id) {\n                  updatedTask.sessionIds.append(session.id)\n                  await workspaceVM.updateTask(updatedTask)\n                }\n              }\n              sessionAssigningTask = nil\n            }\n          },\n          onCancel: {\n            sessionAssigningTask = nil\n          }\n        )\n      }\n    }\n    .task(id: currentProjectId) {\n      if let projectId = currentProjectId {\n        await workspaceVM.loadTasks(for: projectId)\n      }\n    }\n    .onReceive(NotificationCenter.default.publisher(for: .codMateCollapseAllTasks)) { note in\n      guard shouldHandleTaskNotification(note) else { return }\n      collapsedTaskIDs = taskIDsForCurrentProject()\n    }\n    .onReceive(NotificationCenter.default.publisher(for: .codMateExpandAllTasks)) { note in\n      guard shouldHandleTaskNotification(note) else { return }\n      collapsedTaskIDs.removeAll()\n    }\n    .confirmationDialog(\n      \"Delete Task\",\n      isPresented: $showDeleteConfirmation,\n      presenting: taskToDelete\n    ) { task in\n      Button(\"Delete\", role: .destructive) {\n        Task {\n          if let projectId = currentProjectId {\n            await workspaceVM.deleteTask(task.id, projectId: projectId)\n          }\n          taskToDelete = nil\n        }\n      }\n      Button(\"Cancel\", role: .cancel) {\n        taskToDelete = nil\n      }\n    } message: { task in\n      Text(\n        \"Delete \\\"\\(task.effectiveTitle)\\\"? This will not delete the associated sessions, only remove the task container.\"\n      )\n    }\n    .confirmationDialog(\n      \"Move Session to Another Task?\",\n      isPresented: Binding(\n        get: { pendingMove != nil },\n        set: { if !$0 { pendingMove = nil } }\n      ),\n      presenting: pendingMove\n    ) { move in\n      Button(\"Move\") {\n        guard let projectId = currentProjectId else {\n          pendingMove = nil\n          return\n        }\n        Task {\n          // Move session by updating target task; ProjectWorkspaceViewModel\n          // will enforce 0/1 membership across tasks.\n          var updatedTarget = move.toTask\n          if !updatedTarget.sessionIds.contains(move.session.id) {\n            updatedTarget.sessionIds.append(move.session.id)\n            await workspaceVM.updateTask(updatedTarget)\n          }\n          pendingMove = nil\n          // Reload tasks for current project to reflect latest state\n          await workspaceVM.loadTasks(for: projectId)\n        }\n      }\n      Button(\"Cancel\", role: .cancel) {\n        pendingMove = nil\n      }\n    } message: { move in\n      Text(\n        \"Move \\\"\\(move.session.effectiveTitle)\\\" from \\\"\\(move.fromTask.effectiveTitle)\\\" to \\\"\\(move.toTask.effectiveTitle)\\\"?\"\n      )\n    }\n  }\n\n  // MARK: - Data Enrichment\n\n  enum SessionOrTask: Identifiable {\n    case taskHeader(TaskWithSessions)\n    case taskSession(TaskWithSessions, SessionSummary)\n    case session(SessionSummary)\n\n    var id: String {\n      switch self {\n      case .taskHeader(let t): return \"task-header-\\(t.id.uuidString)\"\n      case .taskSession(let t, let s): return \"task-session-\\(t.id.uuidString)-\\(s.id)\"\n      case .session(let s): return \"session-\\(s.id)\"\n      }\n    }\n  }\n\n  private func enrichedSessionsForSection(\n    _ section: SessionDaySection,\n    enrichedTasks: [TaskWithSessions],\n    assignedSessionIds: Set<String>\n  ) -> [SessionOrTask] {\n    guard !enrichedTasks.isEmpty else {\n      return section.sessions.map { .session($0) }\n    }\n\n    let sectionSessionIDs = Set(section.sessions.map(\\.id))\n    let calendar = Calendar.current\n\n    // Build per-task sessions limited to this section, and also include\n    // tasks that currently have no sessions but were updated on this day.\n    var taskSectionSessions: [UUID: [SessionSummary]] = [:]\n    var tasksInSection: [TaskWithSessions] = []\n    for task in enrichedTasks {\n      let inSection = task.sessions.filter { sectionSessionIDs.contains($0.id) }\n      if !inSection.isEmpty {\n        tasksInSection.append(task)\n        taskSectionSessions[task.task.id] = inSection\n      } else if task.sessions.isEmpty,\n        calendar.isDate(task.task.updatedAt, inSameDayAs: section.id)\n      {\n        // New or empty tasks should still appear in the Tasks view\n        // on the day they were last updated, even before any sessions\n        // are assigned to them.\n        tasksInSection.append(task)\n        taskSectionSessions[task.task.id] = []\n      }\n    }\n    guard !tasksInSection.isEmpty else {\n      // No tasks relevant for this section; fall back to standalone sessions only.\n      return section.sessions.map { .session($0) }\n    }\n\n    // Sort tasks according to current sort order, using aggregated metrics\n    let sortedTasks: [TaskWithSessions] = {\n      switch viewModel.sortOrder {\n      case .mostRecent:\n        // Use the latest timestamp among this section's sessions, respecting date dimension\n        let dim = viewModel.dateDimension\n        return tasksInSection.sorted { lhs, rhs in\n          let ls = taskSectionSessions[lhs.task.id] ?? []\n          let rs = taskSectionSessions[rhs.task.id] ?? []\n          func key(_ s: SessionSummary) -> Date {\n            switch dim {\n            case .created: return s.startedAt\n            case .updated: return s.lastUpdatedAt ?? s.startedAt\n            }\n          }\n          let lDate = ls.map(key).max() ?? .distantPast\n          let rDate = rs.map(key).max() ?? .distantPast\n          return lDate > rDate\n        }\n\n      case .longestDuration:\n        // Aggregate total duration for this section's sessions\n        return tasksInSection.sorted { lhs, rhs in\n          let lDur = (taskSectionSessions[lhs.task.id] ?? []).reduce(0) { $0 + $1.duration }\n          let rDur = (taskSectionSessions[rhs.task.id] ?? []).reduce(0) { $0 + $1.duration }\n          if lDur != rDur { return lDur > rDur }\n          // Tie-breaker: most recent activity\n          let lDate =\n            (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          let rDate =\n            (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          return lDate > rDate\n        }\n\n      case .mostActivity:\n        // Aggregate total visible event count\n        let visibleKinds = viewModel.preferences.timelineVisibleKinds\n        return tasksInSection.sorted { lhs, rhs in\n          let lEvents = (taskSectionSessions[lhs.task.id] ?? [])\n            .reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) }\n          let rEvents = (taskSectionSessions[rhs.task.id] ?? [])\n            .reduce(0) { $0 + $1.visibleEventCount(using: visibleKinds) }\n          if lEvents != rEvents { return lEvents > rEvents }\n          let lDate =\n            (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          let rDate =\n            (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          if lDate != rDate { return lDate > rDate }\n          return lhs.task.effectiveTitle.localizedCaseInsensitiveCompare(rhs.task.effectiveTitle)\n            == .orderedAscending\n        }\n\n      case .alphabetical:\n        return tasksInSection.sorted { lhs, rhs in\n          let cmp = lhs.task.effectiveTitle.localizedStandardCompare(rhs.task.effectiveTitle)\n          if cmp == .orderedSame {\n            let lDate =\n              (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }\n              .max() ?? .distantPast\n            let rDate =\n              (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }\n              .max() ?? .distantPast\n            if lDate != rDate { return lDate > rDate }\n            return lhs.task.id.uuidString < rhs.task.id.uuidString\n          }\n          return cmp == .orderedAscending\n        }\n\n      case .largestSize:\n        // Approximate size by total file size across this section's sessions\n        return tasksInSection.sorted { lhs, rhs in\n          func totalSize(for task: TaskWithSessions) -> UInt64 {\n            (taskSectionSessions[task.task.id] ?? []).reduce(0) { acc, s in\n              acc + (s.fileSizeBytes ?? 0)\n            }\n          }\n          let lSize = totalSize(for: lhs)\n          let rSize = totalSize(for: rhs)\n          if lSize != rSize { return lSize > rSize }\n          let lDate =\n            (taskSectionSessions[lhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          let rDate =\n            (taskSectionSessions[rhs.task.id] ?? []).map { $0.lastUpdatedAt ?? $0.startedAt }.max()\n            ?? .distantPast\n          return lDate > rDate\n        }\n      }\n    }()\n\n    var result: [SessionOrTask] = []\n\n    // For each task (in sorted order), add a header row and then its sessions that belong to this section\n    for task in sortedTasks {\n      result.append(.taskHeader(task))\n      if !collapsedTaskIDs.contains(task.task.id),\n        let sectionSessions = taskSectionSessions[task.task.id]\n      {\n        for session in sectionSessions {\n          result.append(.taskSession(task, session))\n        }\n      }\n    }\n\n    // Add standalone sessions (not assigned to any task), preserving existing section order\n    for session in section.sessions where !assignedSessionIds.contains(session.id) {\n      result.append(.session(session))\n    }\n\n    return result\n  }\n\n  // MARK: - Section Header\n\n  @ViewBuilder\n  private func sectionHeader(for section: SessionDaySection) -> some View {\n    HStack {\n      Text(section.title)\n      Spacer()\n      Label(readableFormattedDuration(section.totalDuration), systemImage: \"clock\")\n      Label(\"\\(section.totalEvents)\", systemImage: \"chart.bar\")\n    }\n    .font(.subheadline)\n    .foregroundStyle(.secondary)\n  }\n\n  // MARK: - Task Row\n\n  @ViewBuilder\n  private func taskRow(_ taskWithSessions: TaskWithSessions) -> some View {\n    VStack(alignment: .leading, spacing: 0) {\n      // Task header - using same visual style as SessionListRowView\n      HStack(alignment: .top, spacing: 12) {\n        // Left icon — purely visual\n        let container = RoundedRectangle(cornerRadius: 9, style: .continuous)\n        ZStack {\n          container\n            .fill(Color.white)\n            .shadow(color: Color.black.opacity(0.08), radius: 1.5, x: 0, y: 1)\n          container\n            .stroke(Color.black.opacity(0.06), lineWidth: 1)\n\n          Image(systemName: \"checklist\")\n            .font(.system(size: 14, weight: .semibold))\n            .foregroundStyle(Color.accentColor)\n        }\n        .frame(width: 32, height: 32)\n        .help(\"Task\")\n\n        // Content area\n        VStack(alignment: .leading, spacing: 4) {\n          // Title only - status and collapse indicator removed\n          Text(taskWithSessions.task.effectiveTitle)\n            .font(.headline)\n            .lineLimit(1)\n            .truncationMode(.tail)\n\n          // Metadata row\n          HStack(spacing: 8) {\n            Text(taskWithSessions.task.updatedAt.formatted(date: .numeric, time: .shortened))\n              .layoutPriority(1)\n            Text(formatDuration(taskWithSessions.totalDuration))\n              .layoutPriority(1)\n          }\n          .font(.caption)\n          .foregroundStyle(.secondary)\n          .lineLimit(1)\n\n          // Description if available\n          if let description = taskWithSessions.task.effectiveDescription {\n            Text(description)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .lineLimit(2)\n          }\n\n          // Metrics\n          HStack(spacing: 8) {\n            metric(icon: \"doc.text\", value: taskWithSessions.sessions.count)\n            metric(icon: \"clock\", value: Int(taskWithSessions.totalDuration / 60))\n            if taskWithSessions.totalTokens > 0 {\n              metric(icon: \"circle.grid.cross\", value: taskWithSessions.totalTokens)\n            }\n          }\n          .font(.caption2.monospacedDigit())\n          .foregroundStyle(.secondary)\n        }\n        .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)\n        .padding(.trailing, 32)\n\n        Spacer(minLength: 0)\n      }\n      .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))\n      .padding(.vertical, 8)\n      .overlay(alignment: .topTrailing) {\n        // Top-right collapse/expand button\n        Button {\n          if collapsedTaskIDs.contains(taskWithSessions.task.id) {\n            collapsedTaskIDs.remove(taskWithSessions.task.id)\n          } else {\n            collapsedTaskIDs.insert(taskWithSessions.task.id)\n          }\n        } label: {\n          Image(systemName: collapsedTaskIDs.contains(taskWithSessions.task.id) ? \"chevron.down\" : \"chevron.up\")\n            .foregroundStyle(Color.secondary)\n            .font(.system(size: 14, weight: .semibold))\n        }\n        .buttonStyle(.borderless)\n        .padding(.leading, 8)\n        .padding(.trailing, 8)\n        .padding(.top, 8)\n      }\n      .onDrop(\n        of: [.text],\n        delegate: TaskDropDelegate(\n          task: taskWithSessions.task,\n          draggedSession: $draggedSession,\n          workspaceVM: workspaceVM,\n          onRequestMove: handleMoveRequest\n        )\n      )\n      .contentShape(Rectangle())\n      .onTapGesture(count: 2) {\n        editingMode = .edit\n        editingTask = taskWithSessions.task\n      }\n      .contextMenu {\n        if let project = projectForTask(taskWithSessions.task) {\n          let anchor = latestLocalSession(for: taskWithSessions)\n          let items = buildNewMenuItems(anchor: anchor, project: project) { anchor, source, profile in\n            if let handler = onNewSessionWithTaskContext {\n              handler(taskWithSessions.task, anchor, source, profile)\n            }\n          }\n          if items.isEmpty {\n            Button {\n              if let handler = onNewSessionWithTaskContext, let anchor {\n                // Fallback to default behavior if menu generation failed but anchor exists\n                // We'll use the anchor's source and default profile\n                let defaultProfile = ExternalTerminalProfileStore.shared.resolvePreferredProfile(\n                  id: viewModel.preferences.defaultResumeExternalAppId\n                ) ?? ExternalTerminalProfileStore.shared.availableProfiles().first!\n                handler(taskWithSessions.task, anchor, anchor.source, defaultProfile)\n              } else {\n                viewModel.newSession(project: project)\n              }\n            } label: {\n              Label(\"Collaborate with\", systemImage: \"person.2\")\n            }\n          } else {\n            Menu { SplitMenuItemsView(items: items) } label: { Label(\"Collaborate with…\", systemImage: \"person.2\") }\n          }\n          Button {\n            let draft = CodMateTask(\n              title: \"\",\n              description: nil,\n              projectId: project.id\n            )\n            editingMode = .new\n            editingTask = draft\n          } label: {\n            Label(\"New Task…\", systemImage: \"checklist\")\n          }\n        }\n        Button {\n          editingMode = .edit\n          editingTask = taskWithSessions.task\n        } label: {\n          Label(\"Edit Task\", systemImage: \"pencil\")\n        }\n        Button(role: .destructive) {\n          taskToDelete = taskWithSessions.task\n          showDeleteConfirmation = true\n        } label: {\n          Label(\"Delete Task\", systemImage: \"trash\")\n        }\n        taskCollapseContextMenuItems()\n      }\n\n    }\n  }\n\n  // MARK: - Session Row\n\n  @ViewBuilder\n  private func sessionRow(_ session: SessionSummary, parentTask: CodMateTask? = nil) -> some View {\n    EquatableSessionListRow(\n      summary: session,\n      isRunning: isRunning?(session) ?? false,\n      isSelected: selection.contains(session.id),\n      isUpdating: isUpdating?(session) ?? false,\n      awaitingFollowup: isAwaitingFollowup?(session) ?? false,\n      inProject: viewModel.projectIdForSession(session.id) != nil,\n      projectTip: projectTip(for: session),\n      inTaskContainer: parentTask != nil\n    )\n    .tag(session.id)\n    .contentShape(Rectangle())\n    .padding(.leading, parentTask != nil ? 44 : 0)\n    .onTapGesture(count: 2) {\n      selection = [session.id]\n      onPrimarySelect?(session)\n      Task {\n        await viewModel.beginEditing(session: session)\n      }\n    }\n    .onTapGesture {\n      handleClick(on: session)\n    }\n    .contextMenu {\n      let resumeItems = buildResumeMenuItems(for: session)\n      if !resumeItems.isEmpty {\n        Menu { SplitMenuItemsView(items: resumeItems) } label: {\n          let icon = assetIconForSessionSource(session.source)\n          Label {\n            Text(\"Resume session\")\n          } icon: {\n            if let menuIcon = menuAssetNSImage(\n              named: icon,\n              invertForDarkMode: icon == \"ChatGPTIcon\" && colorScheme == .dark\n            ) {\n              Image(nsImage: menuIcon)\n                .frame(width: 14, height: 14)\n            } else {\n              Image(icon)\n                .resizable()\n                .scaledToFit()\n                .frame(width: 14, height: 14)\n                .clipped()\n                .modifier(DarkModeInvertModifier(active: icon == \"ChatGPTIcon\" && colorScheme == .dark))\n            }\n          }\n        }\n      }\n      if let project = projectForSession(session, parentTask: parentTask) {\n        let items = buildNewMenuItems(anchor: session)\n        if items.isEmpty {\n          Button { viewModel.newSession(project: project) } label: { Label(\"New Session\", systemImage: \"plus\") }\n        } else {\n          Menu { SplitMenuItemsView(items: items) } label: { Label(\"New Session…\", systemImage: \"plus\") }\n        }\n        Divider()\n        Button {\n          let draft = CodMateTask(\n            title: \"\",\n            description: nil,\n            projectId: project.id\n          )\n          editingMode = .new\n          editingTask = draft\n        } label: {\n          Label(\"New Task…\", systemImage: \"checklist\")\n        }\n        if parentTask == nil {\n          Button { sessionAssigningTask = session } label: { Label(\"Add to Task…\", systemImage: \"plus.circle\") }\n        }\n      }\n      if parentTask != nil {\n        Button {\n          Task {\n            guard let task = parentTask else { return }\n            var updatedTask = task\n            updatedTask.sessionIds.removeAll { $0 == session.id }\n            await viewModel.workspaceVM?.updateTask(updatedTask)\n          }\n        } label: {\n          Label(\"Remove from Task\", systemImage: \"minus.circle\")\n        }\n      }\n      Divider()\n      Button {\n        Task { await viewModel.beginEditing(session: session) }\n      } label: {\n        Label(\"Edit Title & Comment\", systemImage: \"pencil\")\n      }\n      Button {\n        Task { @MainActor in\n          await viewModel.generateTitleAndComment(for: session, force: false)\n        }\n      } label: {\n        Label(\"Generate Title & Comment\", systemImage: \"sparkles\")\n      }\n      Divider()\n      Button { copyAbsolutePath(session) } label: { Label(\"Copy Absolute Path\", systemImage: \"doc.on.doc\") }\n      Button { onExportMarkdown(session) } label: { Label(\"Export as Markdown\", systemImage: \"square.and.arrow.up\") }\n      Divider()\n      Button { onReveal(session) } label: { Label(\"Reveal in Finder\", systemImage: \"finder\") }\n      Button(role: .destructive) { onDeleteRequest(session) } label: { Label(\"Move to Trash\", systemImage: \"trash\") }\n      taskCollapseContextMenuItems()\n    }\n    .onDrag {\n      self.draggedSession = session\n      return NSItemProvider(object: session.id as NSString)\n    }\n    .onDrop(\n      of: [.text],\n      delegate: SessionDropDelegate(\n        session: session,\n        draggedSession: $draggedSession,\n        workspaceVM: workspaceVM,\n        currentProjectId: currentProjectId\n      )\n    )\n    .listRowInsets(EdgeInsets())\n  }\n\n  // MARK: - Helpers\n\n  @ViewBuilder\n  private func metric(icon: String, value: Int) -> some View {\n    HStack(spacing: 2) {\n      Image(systemName: icon)\n      Text(\"\\(value)\")\n    }\n  }\n\n  private func statusColor(_ status: TaskStatus) -> Color {\n    switch status {\n    case .pending: return .gray\n    case .inProgress: return .blue\n    case .completed: return .green\n    case .canceled: return .red\n    case .archived: return .orange\n    }\n  }\n\n  private func formatDuration(_ duration: TimeInterval) -> String {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = [.hour, .minute]\n    formatter.unitsStyle = .abbreviated\n    return formatter.string(from: duration) ?? \"—\"\n  }\n\n  private func readableFormattedDuration(_ interval: TimeInterval) -> String {\n    let formatter = DateComponentsFormatter()\n    formatter.unitsStyle = .abbreviated\n    if interval >= 3600 {\n      formatter.allowedUnits = [.hour, .minute]\n    } else if interval >= 60 {\n      formatter.allowedUnits = [.minute, .second]\n    } else {\n      formatter.allowedUnits = [.second]\n    }\n    return formatter.string(from: interval) ?? \"—\"\n  }\n\n  private func projectTip(for session: SessionSummary) -> String? {\n    guard let pid = viewModel.projectIdForSession(session.id),\n      let p = viewModel.projects.first(where: { $0.id == pid })\n    else { return nil }\n    let name = p.name.trimmingCharacters(in: .whitespacesAndNewlines)\n    let display = name.isEmpty ? p.id : name\n    let raw = (p.overview ?? \"\").trimmingCharacters(in: .whitespacesAndNewlines)\n    guard !raw.isEmpty else { return display }\n    let snippet = raw.count > 20 ? String(raw.prefix(20)) + \"…\" : raw\n    return display + \"\\n\" + snippet\n  }\n\n  private func handleClick(on session: SessionSummary) {\n    #if os(macOS)\n      let mods = NSApp.currentEvent?.modifierFlags ?? []\n      let isToggle = mods.contains(.command) || mods.contains(.control)\n      let isRange = mods.contains(.shift)\n    #else\n      let isToggle = false\n      let isRange = false\n    #endif\n    let id = session.id\n    if isRange, let anchor = lastClickedID {\n      let flat = viewModel.sections.flatMap { $0.sessions.map(\\.id) }\n      if let a = flat.firstIndex(of: anchor), let b = flat.firstIndex(of: id) {\n        let lo = min(a, b)\n        let hi = max(a, b)\n        let rangeIDs = Set(flat[lo...hi])\n        selection = rangeIDs\n      } else {\n        selection = [id]\n      }\n      onPrimarySelect?(session)\n    } else if isToggle {\n      if selection.contains(id) {\n        selection.remove(id)\n      } else {\n        selection.insert(id)\n      }\n      lastClickedID = id\n      onPrimarySelect?(session)\n    } else {\n      selection = [id]\n      lastClickedID = id\n      onPrimarySelect?(session)\n    }\n  }\n\n  private func handleMoveRequest(\n    session: SessionSummary, fromTask: CodMateTask, toTask: CodMateTask\n  ) {\n    pendingMove = PendingSessionMove(session: session, fromTask: fromTask, toTask: toTask)\n  }\n\n  private func projectForSession(_ session: SessionSummary, parentTask: CodMateTask?) -> Project? {\n    if let parentTask {\n      return projectForTask(parentTask)\n    }\n    guard let pid = viewModel.projectIdForSession(session.id) else { return nil }\n    if pid == SessionListViewModel.otherProjectId { return nil }\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  private func projectForTask(_ task: CodMateTask) -> Project? {\n    let pid = task.projectId\n    if pid == SessionListViewModel.otherProjectId { return nil }\n    return viewModel.projects.first(where: { $0.id == pid })\n  }\n\n  // MARK: - Task Context Helpers\n\n  /// Returns the most recent local (non-remote) session for a given task, if any.\n  private func latestLocalSession(for taskWithSessions: TaskWithSessions) -> SessionSummary? {\n    let candidates = taskWithSessions.sessions.filter { !$0.isRemote }\n    return candidates.max(by: { (lhs, rhs) in\n      let lDate = lhs.lastUpdatedAt ?? lhs.startedAt\n      let rDate = rhs.lastUpdatedAt ?? rhs.startedAt\n      return lDate < rDate\n    })\n  }\n\n  @ViewBuilder\n  private func projectContextMenu(for project: Project) -> some View {\n    let anchor = latestAnchor(for: project)\n    let items = buildNewMenuItems(\n      anchor: anchor,\n      project: project,\n      customAction: anchor == nil ? { _, source, profile in\n        viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile)\n      } : nil\n    )\n    Menu(\"New Session…\") {\n      SplitMenuItemsView(items: items)\n    }\n  }\n\n  @ViewBuilder\n  private func taskListBackgroundContextMenu() -> some View {\n    if let projectId = currentProjectId,\n      let project = viewModel.projects.first(where: { $0.id == projectId })\n    {\n      let anchor = latestAnchor(for: project)\n      let items = buildNewMenuItems(\n        anchor: anchor,\n        project: project,\n        customAction: anchor == nil ? { _, source, profile in\n          viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile)\n        } : nil\n      )\n      Menu {\n        SplitMenuItemsView(items: items)\n      } label: {\n        Label(\"New Session…\", systemImage: \"plus\")\n      }\n      Button {\n        editingMode = .new\n        editingTask = CodMateTask(title: \"\", description: nil, projectId: currentProjectId ?? \"\")\n      } label: {\n        Label(\"New Task…\", systemImage: \"checklist\")\n      }\n    }\n    Divider()\n    Button {\n      NotificationCenter.default.post(\n        name: .codMateCollapseAllTasks, object: nil,\n        userInfo: [\"projectId\": currentProjectId as Any])\n    } label: {\n      Label(\"Collapse all Tasks\", systemImage: \"arrow.down.right.and.arrow.up.left\")\n    }\n    Button {\n      NotificationCenter.default.post(\n        name: .codMateExpandAllTasks, object: nil,\n        userInfo: [\"projectId\": currentProjectId as Any])\n    } label: {\n      Label(\"Expand all Tasks\", systemImage: \"arrow.up.left.and.arrow.down.right\")\n    }\n  }\n\n  private func copyAbsolutePath(_ session: SessionSummary) {\n    let pb = NSPasteboard.general\n    pb.clearContents()\n    pb.setString(session.fileURL.path, forType: .string)\n  }\n\n  @ViewBuilder\n  private func taskCollapseContextMenuItems() -> some View {\n    Divider()\n    Button {\n      NotificationCenter.default.post(\n        name: .codMateCollapseAllTasks, object: nil,\n        userInfo: [\"projectId\": currentProjectId as Any])\n    } label: {\n      Label(\"Collapse all Tasks\", systemImage: \"arrow.down.right.and.arrow.up.left\")\n    }\n    Button {\n      NotificationCenter.default.post(\n        name: .codMateExpandAllTasks, object: nil,\n        userInfo: [\"projectId\": currentProjectId as Any])\n    } label: {\n      Label(\"Expand all Tasks\", systemImage: \"arrow.up.left.and.arrow.down.right\")\n    }\n  }\n\n  private func buildResumeMenuItems(for session: SessionSummary) -> [SplitMenuItem] {\n    var items: [SplitMenuItem] = []\n\n    if viewModel.preferences.isEmbeddedTerminalEnabled {\n      items.append(\n        SplitMenuItem(\n          id: \"resume-embedded-\\(session.id)\",\n          kind: .action(\n            title: \"CodMate\",\n            systemImage: \"macwindow\",\n            run: {\n              NotificationCenter.default.post(\n                name: .codMateResumeSession,\n                object: nil,\n                userInfo: [\"sessionId\": session.id, \"forceEmbedded\": true]\n              )\n            }\n          )\n        )\n      )\n    }\n\n    for profile in externalTerminalOrderedProfiles(includeNone: false) {\n      items.append(\n        SplitMenuItem(\n          id: \"resume-\\(profile.id)-\\(session.id)\",\n          kind: .action(\n            title: profile.displayTitle,\n            systemImage: \"terminal\",\n            run: {\n              NotificationCenter.default.post(\n                name: .codMateResumeSession,\n                object: nil,\n                userInfo: [\"sessionId\": session.id, \"profileId\": profile.id]\n              )\n            }\n          )\n        )\n      )\n    }\n\n    return items\n  }\n\n  private func assetIconForSessionSource(_ source: SessionSource) -> String {\n    switch source.baseKind {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    }\n  }\n\n  private func buildNewMenuItems(\n    anchor: SessionSummary?,\n    project: Project? = nil,\n    customAction: ((SessionSummary?, SessionSource, ExternalTerminalProfile) -> Void)? = nil\n  ) -> [SplitMenuItem] {\n    guard anchor != nil || project != nil else { return [] }\n    let allowed: Set<ProjectSessionSource>\n    if let anchor {\n      allowed = Set(viewModel.allowedSources(for: anchor))\n    } else if let project {\n      let sources = project.sources.isEmpty ? ProjectSessionSource.allSet : project.sources\n      allowed = Set(sources.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n    } else {\n      allowed = Set(ProjectSessionSource.allCases.filter { viewModel.preferences.isCLIEnabled($0.baseKind) })\n    }\n    let requestedOrder: [ProjectSessionSource] = [.claude, .codex, .gemini]\n    let enabledRemoteHosts = viewModel.preferences.enabledRemoteHosts.sorted()\n\n    func sourceKey(_ source: SessionSource) -> String {\n      switch source {\n      case .codexLocal: return \"codex-local\"\n      case .codexRemote(let host): return \"codex-\\(host)\"\n      case .claudeLocal: return \"claude-local\"\n      case .claudeRemote(let host): return \"claude-\\(host)\"\n      case .geminiLocal: return \"gemini-local\"\n      case .geminiRemote(let host): return \"gemini-\\(host)\"\n      }\n    }\n\n    func launchItems(for source: SessionSource) -> [SplitMenuItem] {\n      let key = sourceKey(source)\n      var items = externalTerminalMenuItems(idPrefix: key) { profile in\n        if let customAction {\n          customAction(anchor, source, profile)\n        } else if let anchor {\n          onNewSession(with: anchor, using: source, profile: profile)\n        } else if let project {\n          // No anchor but we have a project - use project-based new session\n          viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile)\n        }\n      }\n      if viewModel.preferences.isEmbeddedTerminalEnabled {\n        let embedded = embeddedTerminalProfile()\n        items.insert(\n          SplitMenuItem(\n            id: \"\\(key)-\\(embedded.id)\",\n            kind: .action(\n              title: embedded.displayTitle,\n              systemImage: \"macwindow\",\n              run: {\n                if let customAction {\n                  customAction(anchor, source, embedded)\n                } else if let anchor {\n                  onNewSession(with: anchor, using: source, profile: embedded)\n                } else if let project {\n                  // No anchor but we have a project - use project-based new session\n                  viewModel.launchNewSessionFromProject(project: project, using: source, profile: embedded)\n                }\n              })\n          ), at: 0)\n      }\n      return items\n    }\n\n    func remoteSource(for base: ProjectSessionSource, host: String) -> SessionSource {\n      switch base {\n      case .codex: return .codexRemote(host: host)\n      case .claude: return .claudeRemote(host: host)\n      case .gemini: return .geminiRemote(host: host)\n      }\n    }\n\n    func providerAssetIcon(_ source: ProjectSessionSource) -> String {\n      switch source {\n      case .codex: return \"ChatGPTIcon\"\n      case .claude: return \"ClaudeIcon\"\n      case .gemini: return \"GeminiIcon\"\n      }\n    }\n\n    var menuItems: [SplitMenuItem] = []\n    for base in requestedOrder where allowed.contains(base) {\n      var providerItems = launchItems(for: base.sessionSource)\n      if !enabledRemoteHosts.isEmpty {\n        providerItems.append(.init(kind: .separator))\n        for host in enabledRemoteHosts {\n          let remote = remoteSource(for: base, host: host)\n          providerItems.append(\n            .init(\n              id: \"remote-\\(base.rawValue)-\\(host)\",\n              kind: .submenu(title: host, systemImage: \"network\", items: launchItems(for: remote))\n            ))\n        }\n      }\n      menuItems.append(\n        .init(\n          id: \"provider-\\(base.rawValue)\",\n          kind: .submenu(title: base.displayName, assetImage: providerAssetIcon(base), items: providerItems)\n        ))\n    }\n\n    if menuItems.isEmpty, let anchor {\n      let fallback = anchor.source\n      menuItems.append(\n        .init(\n          id: \"fallback-\\(sourceKey(fallback))\",\n          kind: .submenu(title: fallback.branding.displayName, systemImage: \"terminal\", items: launchItems(for: fallback))\n        ))\n    }\n    return menuItems\n  }\n\n  private func onNewSession(\n    with anchor: SessionSummary,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    viewModel.launchNewSessionWithProfile(\n      session: anchor,\n      using: source,\n      profile: profile,\n      workingDirectory: anchor.cwd\n    )\n  }\n\n  private func latestAnchor(for project: Project) -> SessionSummary? {\n    if let visible = viewModel.sections.flatMap({ $0.sessions }).first(\n      where: { viewModel.projectIdForSession($0.id) == project.id })\n    {\n      return visible\n    }\n    return viewModel.allSessions.first { viewModel.projectIdForSession($0.id) == project.id }\n  }\n\n  private func onNewSessionFromProject(\n    project: Project,\n    using source: SessionSource,\n    profile: ExternalTerminalProfile\n  ) {\n    viewModel.launchNewSessionFromProject(project: project, using: source, profile: profile)\n  }\n\n  // MARK: - Empty State View\n\n  @ViewBuilder\n  private var emptyStateView: some View {\n    let project = currentProjectId.flatMap { pid in viewModel.projects.first(where: { $0.id == pid }) }\n    let isOtherProject = project?.id == SessionListViewModel.otherProjectId\n\n    ZStack {\n      Color.clear\n      \n      VStack(spacing: 12) {\n        Spacer(minLength: 12)\n\n        // Different message for Other project bucket\n        if isOtherProject {\n          unavailableViewWrapper(\n            title: \"No Unassigned Sessions\",\n            systemImage: \"tray\",\n            description: \"Sessions can only be created within a project. Select a project from the sidebar to start a new session.\"\n          )\n        } else {\n          unavailableViewWrapper(\n            title: \"No Sessions\",\n            systemImage: \"tray\",\n            description: \"Right-click in this area or use the \\\"+ New\\\" button to start a new session.\"\n          )\n        }\n\n        // Primary action: New (hidden for Other project, shown for regular projects)\n        if let project, !isOtherProject {\n          let anchor = latestAnchor(for: project)\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {\n              viewModel.newSession(project: project)\n            },\n            items: buildNewMenuItems(anchor: anchor, project: project)\n          )\n          .help(\"Start a new session in \\(project.name.isEmpty ? project.id : project.name)\")\n        } else if !isOtherProject {\n          SplitPrimaryMenuButton(\n            title: \"New\",\n            systemImage: \"plus\",\n            primary: {},\n            items: []\n          )\n          .opacity(0.6)\n          .help(\"Select a project in the sidebar to start a new session\")\n        }\n\n        Spacer()\n      }\n    }\n    .frame(maxWidth: .infinity, maxHeight: .infinity)\n    .contentShape(Rectangle())\n    .contextMenu { taskListBackgroundContextMenu() }\n  }\n\n  @ViewBuilder\n  private func unavailableViewWrapper(title: String, systemImage: String, description: String) -> some View {\n    Group {\n      if #available(macOS 14.0, *) {\n        ContentUnavailableView(title, systemImage: systemImage, description: Text(description))\n      } else {\n        UnavailableStateView(\n          title,\n          systemImage: systemImage,\n          description: description,\n          titleColor: .primary\n        )\n      }\n    }\n    .frame(maxWidth: .infinity)\n  }\n}\n\nextension TaskListView {\n  fileprivate func shouldHandleTaskNotification(_ note: Notification) -> Bool {\n    guard let target = note.userInfo?[\"projectId\"] as? String else { return true }\n    return target == currentProjectId\n  }\n\n  fileprivate func taskIDsForCurrentProject() -> Set<UUID> {\n    guard let projectId = currentProjectId else { return [] }\n    let ids = workspaceVM.tasks.filter { $0.projectId == projectId }.map { $0.id }\n    return Set(ids)\n  }\n}\n\n// MARK: - New Task Sheet (Removed - now using EditTaskSheet with mode: .new)\n\n// MARK: - Edit Task Sheet\nstruct EditTaskSheet: View {\n  enum Mode {\n    case new\n    case edit\n  }\n\n  let task: CodMateTask\n  let mode: Mode\n  @State private var title: String\n  @State private var description: String\n  @State private var status: TaskStatus\n  let onSave: (CodMateTask) -> Void\n  let onCancel: () -> Void\n  @FocusState private var focusedField: Field?\n  @ObservedObject var workspaceVM: ProjectWorkspaceViewModel\n\n  enum Field {\n    case title\n    case description\n  }\n\n  init(\n    task: CodMateTask,\n    mode: Mode = .edit,\n    workspaceVM: ProjectWorkspaceViewModel,\n    onSave: @escaping (CodMateTask) -> Void,\n    onCancel: @escaping () -> Void\n  ) {\n    self.task = task\n    self.mode = mode\n    self.workspaceVM = workspaceVM\n    self._title = State(initialValue: task.title)\n    self._description = State(initialValue: task.description ?? \"\")\n    self._status = State(initialValue: task.status)\n    self.onSave = onSave\n    self.onCancel = onCancel\n  }\n\n  var body: some View {\n    let hasAnyContent = !workspaceVM.getSessionsForTask(task.id).isEmpty\n      || !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n      || !description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty\n\n    VStack(alignment: .leading, spacing: 16) {\n      HStack {\n        Text(mode == .new ? \"New Task\" : \"Edit Task\")\n          .font(.title3).bold()\n        Spacer()\n\n        // Generate button (icon only, transparent background)\n        // Show if there are sessions OR any content (title/description)\n        if hasAnyContent {\n          Button(action: {\n            Task { @MainActor in\n              await workspaceVM.generateTitleAndDescription(for: task, currentTitle: title, currentDescription: description, force: false)\n              // After generation, update local state\n              if let generatedTitle = workspaceVM.generatedTaskTitle {\n                title = generatedTitle\n              }\n              if let generatedDescription = workspaceVM.generatedTaskDescription {\n                description = generatedDescription\n              }\n              // Clear generated content\n              workspaceVM.generatedTaskTitle = nil\n              workspaceVM.generatedTaskDescription = nil\n            }\n          }) {\n            if workspaceVM.isGeneratingTitleDescription && workspaceVM.generatingTaskId == task.id {\n              ProgressView()\n                .controlSize(.small)\n                .frame(width: 16, height: 16)\n            } else {\n              Image(systemName: \"sparkles\")\n                .font(.system(size: 16))\n                .foregroundStyle(.secondary)\n            }\n          }\n          .buttonStyle(.plain)\n          .help(\"Generate title and description using AI\")\n          .disabled(workspaceVM.isGeneratingTitleDescription && workspaceVM.generatingTaskId == task.id)\n        }\n      }\n\n      TextField(\"Task Title\", text: $title)\n        .textFieldStyle(.roundedBorder)\n        .focused($focusedField, equals: .title)\n\n      VStack(alignment: .leading, spacing: 8) {\n        Text(\"Description (optional)\").font(.subheadline)\n        TextEditor(text: $description)\n          .font(.body)\n          .codmatePlainTextEditorStyleIfAvailable()\n          .scrollContentBackground(.hidden)\n          .frame(minHeight: 120)\n          .padding(8)\n          .background(\n            RoundedRectangle(cornerRadius: 6)\n              .stroke(Color.gray.opacity(0.2), lineWidth: 1)\n          )\n          .focused($focusedField, equals: .description)\n      }\n\n      Picker(\"Status\", selection: $status) {\n        ForEach(TaskStatus.allCases) { s in\n          Text(s.displayName).tag(s)\n        }\n      }\n\n      HStack {\n        Button(\"Cancel\", action: onCancel)\n          .keyboardShortcut(.cancelAction)\n\n        Spacer()\n\n        Button(\"Save\") {\n          var updated = task\n          updated.title = title\n          updated.description = description.isEmpty ? nil : description\n          updated.status = status\n          onSave(updated)\n        }\n        .keyboardShortcut(.defaultAction)\n        .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)\n      }\n    }\n    .padding(20)\n    .frame(minWidth: 520)\n    .onAppear {\n      // Set focus to title field when view appears\n      focusedField = .title\n    }\n  }\n}\n\n// MARK: - Drop Delegates\n\n/// Drop delegate for dropping a session onto another session (creates a new task)\nstruct SessionDropDelegate: DropDelegate {\n  let session: SessionSummary\n  @Binding var draggedSession: SessionSummary?\n  let workspaceVM: ProjectWorkspaceViewModel\n  let currentProjectId: String?\n\n  func performDrop(info: DropInfo) -> Bool {\n    guard let draggedSession = draggedSession,\n      draggedSession.id != session.id,\n      let projectId = currentProjectId\n    else {\n      return false\n    }\n\n    // Only allow creating a new task when both sessions are currently unassigned\n    let draggedTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) }\n    )?.id\n    let targetTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(session.id) })?.id\n    guard draggedTaskId == nil, targetTaskId == nil else {\n      self.draggedSession = nil\n      return false\n    }\n\n    // Create a new task with both unassigned sessions\n    Task {\n      let taskTitle = \"Task: \\(draggedSession.displayName) + \\(session.displayName)\"\n      await workspaceVM.createTask(\n        title: taskTitle,\n        description: nil,\n        projectId: projectId\n      )\n\n      // Find the newly created task (it will be the first one)\n      if let newTask = workspaceVM.tasks.first {\n        // Add both sessions to the task\n        var updatedTask = newTask\n        updatedTask.sessionIds = [draggedSession.id, session.id]\n        await workspaceVM.updateTask(updatedTask)\n      }\n    }\n\n    self.draggedSession = nil\n    return true\n  }\n\n  func validateDrop(info: DropInfo) -> Bool {\n    guard let dragged = draggedSession,\n      dragged.id != session.id\n    else { return false }\n\n    let draggedTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(dragged.id) })?.id\n    let targetTaskId = workspaceVM.tasks.first(where: { $0.sessionIds.contains(session.id) })?.id\n\n    // Only allow drop when both sessions are not yet assigned to any task\n    return draggedTaskId == nil && targetTaskId == nil\n  }\n}\n\n/// Drop delegate for dropping a session onto a task (adds session to task)\nstruct TaskDropDelegate: DropDelegate {\n  let task: CodMateTask\n  @Binding var draggedSession: SessionSummary?\n  let workspaceVM: ProjectWorkspaceViewModel\n  let onRequestMove: (SessionSummary, CodMateTask, CodMateTask) -> Void\n\n  func performDrop(info: DropInfo) -> Bool {\n    guard let draggedSession = draggedSession else {\n      return false\n    }\n\n    let draggedTask = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) })\n\n    if let fromTask = draggedTask {\n      // If already in this task, do nothing\n      guard fromTask.id != task.id else {\n        self.draggedSession = nil\n        return false\n      }\n      // Request a confirmed move from fromTask → task\n      DispatchQueue.main.async {\n        onRequestMove(draggedSession, fromTask, task)\n      }\n      self.draggedSession = nil\n      return true\n    } else {\n      // Add unassigned session to this task\n      Task {\n        var updatedTask = task\n        if !updatedTask.sessionIds.contains(draggedSession.id) {\n          updatedTask.sessionIds.append(draggedSession.id)\n          await workspaceVM.updateTask(updatedTask)\n        }\n      }\n\n      self.draggedSession = nil\n      return true\n    }\n  }\n\n  func validateDrop(info: DropInfo) -> Bool {\n    guard let draggedSession = draggedSession else { return false }\n    let draggedTask = workspaceVM.tasks.first(where: { $0.sessionIds.contains(draggedSession.id) })\n    // Allow drop if session is unassigned or belongs to a different task\n    return draggedTask == nil || draggedTask?.id != task.id\n  }\n}\n\n// MARK: - Task Selection Sheet\nstruct TaskSelectionSheet: View {\n  let tasks: [CodMateTask]\n  let onSelect: (CodMateTask) -> Void\n  let onCreate: (String) -> Void\n  let onCancel: () -> Void\n  @State private var searchText = \"\"\n\n  var filteredTasks: [CodMateTask] {\n    if searchText.isEmpty {\n      return tasks\n    }\n    return tasks.filter { $0.effectiveTitle.localizedCaseInsensitiveContains(searchText) }\n  }\n\n  private var trimmedSearch: String {\n    searchText.trimmingCharacters(in: .whitespacesAndNewlines)\n  }\n\n  private var canCreateNewTask: Bool {\n    guard !trimmedSearch.isEmpty else { return false }\n    return !tasks.contains {\n      $0.effectiveTitle.localizedCaseInsensitiveCompare(trimmedSearch) == .orderedSame\n    }\n  }\n\n  var body: some View {\n    VStack(spacing: 16) {\n      Text(\"Add to Task\")\n        .font(.title2)\n        .fontWeight(.bold)\n\n      TextField(\"Search tasks\", text: $searchText)\n        .textFieldStyle(.roundedBorder)\n        .onSubmit {\n          if canCreateNewTask {\n            onCreate(trimmedSearch)\n          }\n        }\n        .overlay(alignment: .trailing) {\n          if canCreateNewTask {\n            Button {\n              onCreate(trimmedSearch)\n            } label: {\n              Image(systemName: \"plus.circle.fill\")\n                .foregroundStyle(.secondary)\n            }\n            .buttonStyle(.plain)\n            .padding(.trailing, 6)\n          }\n        }\n\n      if filteredTasks.isEmpty {\n        Text(\"No tasks found.\")\n          .foregroundColor(.secondary)\n          .frame(maxWidth: .infinity, maxHeight: .infinity)\n      } else {\n        List(filteredTasks) { task in\n          HStack {\n            Image(systemName: task.status.icon)\n              .foregroundColor(statusColor(task.status))\n            Text(task.effectiveTitle)\n              .lineLimit(1)\n            Spacer()\n          }\n          .padding(.vertical, 4)\n          .contentShape(Rectangle())\n          .onTapGesture {\n            onSelect(task)\n          }\n        }\n        .listStyle(.plain)\n        .overlay(\n          RoundedRectangle(cornerRadius: 6)\n            .stroke(Color.gray.opacity(0.2), lineWidth: 1)\n        )\n      }\n\n      HStack {\n        Spacer()\n        Button(\"Cancel\", action: onCancel)\n          .keyboardShortcut(.cancelAction)\n      }\n    }\n    .padding()\n    .frame(width: 400, height: 400)\n  }\n\n  private func statusColor(_ status: TaskStatus) -> Color {\n    switch status {\n    case .pending: return .gray\n    case .inProgress: return .blue\n    case .completed: return .green\n    case .canceled: return .red\n    case .archived: return .orange\n    }\n  }\n}\n"
  },
  {
    "path": "views/TripleUsageDonutView.swift",
    "content": "import SwiftUI\n\npublic struct UsageRingState {\n  public var progress: Double?\n  public var baseColor: Color\n  public var healthState: UsageMetricSnapshot.HealthState?\n  public var disabled: Bool\n\n  public init(\n    progress: Double? = nil,\n    baseColor: Color,\n    healthState: UsageMetricSnapshot.HealthState? = nil,\n    disabled: Bool\n  ) {\n    self.progress = progress\n    self.baseColor = baseColor\n    self.healthState = healthState\n    self.disabled = disabled\n  }\n\n  public var effectiveColor: Color {\n    if disabled {\n      return Color(nsColor: .quaternaryLabelColor)\n    }\n\n    // Apply health state color if available\n    if let state = healthState {\n      switch state {\n      case .healthy:\n        return baseColor  // Use provider color\n      case .warning:\n        return .orange    // Warning color\n      case .unknown:\n        return baseColor  // Default to provider color\n      }\n    }\n\n    return baseColor\n  }\n}\n\npublic struct TripleUsageDonutView: View {\n  public var states: [UsageRingState]\n  public var trackColor: Color\n\n  public init(\n    states: [UsageRingState],\n    trackColor: Color = .secondary\n  ) {\n    self.states = states\n    self.trackColor = trackColor\n  }\n\n  public var body: some View {\n    let layout = ringLayout(for: states.count)\n    return ZStack {\n      ForEach(Array(states.enumerated()), id: \\.offset) { index, state in\n        let size = layout.sizes[index]\n        let lineWidth = layout.lineWidth\n        let opacity = layout.trackOpacities[index]\n        Circle()\n          .stroke(trackColor.opacity(opacity), lineWidth: lineWidth)\n          .frame(width: size, height: size)\n        ring(for: state, lineWidth: lineWidth, size: size)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func ring(for state: UsageRingState, lineWidth: CGFloat, size: CGFloat) -> some View {\n    if state.disabled {\n      Circle()\n        .stroke(Color(nsColor: .quaternaryLabelColor), lineWidth: lineWidth)\n        .frame(width: size, height: size)\n    } else if let progress = state.progress {\n      Circle()\n        .trim(from: 0, to: CGFloat(max(0, min(progress, 1))))\n        .stroke(style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))\n        .foregroundStyle(state.effectiveColor)\n        .rotationEffect(.degrees(-90))\n        .frame(width: size, height: size)\n    }\n  }\n\n  private func ringLayout(for count: Int) -> (sizes: [CGFloat], lineWidth: CGFloat, trackOpacities: [Double]) {\n    let outerDiameter: CGFloat = 22\n    let outerRadius = outerDiameter / 2\n    let innerClearRadius: CGFloat = 4.25\n    let availableSpan = max(outerRadius - innerClearRadius, 1)\n    let ringCount = max(count, 1)\n    let units = CGFloat(ringCount * 2 - 1)\n    let unit = availableSpan / units\n    let minLineWidth: CGFloat = 1.2\n    let maxLineWidth: CGFloat = 2.8\n    let lineWidth = min(maxLineWidth, max(minLineWidth, unit))\n    let gap = lineWidth\n\n    var sizes: [CGFloat] = []\n    var opacities: [Double] = []\n    let startRadius = outerRadius - lineWidth / 2\n    for index in 0..<ringCount {\n      let radius = startRadius - CGFloat(index) * (lineWidth + gap)\n      sizes.append(max(0, radius * 2))\n      opacities.append(max(0.12, 0.25 - (Double(index) * 0.03)))\n    }\n\n    return (sizes: sizes, lineWidth: lineWidth, trackOpacities: opacities)\n  }\n}\n"
  },
  {
    "path": "views/UnavailableStateView.swift",
    "content": "import SwiftUI\n\nstruct UnavailableStateView: View {\n    let title: String\n    let systemImage: String\n    let description: String?\n    let imageFont: Font\n    let titleFont: Font\n    let descriptionFont: Font\n    let titleColor: Color\n\n    init(\n        _ title: String,\n        systemImage: String,\n        description: String? = nil,\n        imageFont: Font = .title2,\n        titleFont: Font = .headline,\n        descriptionFont: Font = .caption,\n        titleColor: Color = .secondary\n    ) {\n        self.title = title\n        self.systemImage = systemImage\n        self.description = description\n        self.imageFont = imageFont\n        self.titleFont = titleFont\n        self.descriptionFont = descriptionFont\n        self.titleColor = titleColor\n    }\n\n    var body: some View {\n        VStack(spacing: 8) {\n            Image(systemName: systemImage)\n                .font(imageFont)\n                .foregroundColor(.secondary)\n\n            Text(title)\n                .font(titleFont)\n                .foregroundColor(titleColor)\n\n            if let description, !description.isEmpty {\n                Text(description)\n                    .font(descriptionFont)\n                    .foregroundColor(.secondary)\n            }\n        }\n    }\n}\n"
  },
  {
    "path": "views/UnifiedProviderPickerView.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct UnifiedProviderPickerView: View {\n  let sections: [UnifiedProviderSection]\n  let models: [String]\n  let modelSectionTitle: String?\n  let includeAuto: Bool\n  let autoTitle: String\n  let includeDefaultModel: Bool\n  let defaultModelTitle: String\n  let providerUnavailableHint: String?\n  let disableModels: Bool\n  let showProviderPicker: Bool\n  let showModelPicker: Bool\n  let simpleMode: Bool\n  let autoProxyTitle: String\n  let sanitizeModelNames: Bool\n  let onEditModels: (() -> Void)?\n  let editModelsHelp: String?\n\n  @Binding var providerId: String?\n  @Binding var modelId: String?\n\n  init(\n    sections: [UnifiedProviderSection],\n    models: [String],\n    modelSectionTitle: String?,\n    includeAuto: Bool,\n    autoTitle: String,\n    includeDefaultModel: Bool,\n    defaultModelTitle: String,\n    providerUnavailableHint: String?,\n    disableModels: Bool,\n    showProviderPicker: Bool = true,\n    showModelPicker: Bool = true,\n    simpleMode: Bool = false,\n    autoProxyTitle: String = \"Auto-Proxy (CliProxyAPI)\",\n    sanitizeModelNames: Bool = false,\n    onEditModels: (() -> Void)? = nil,\n    editModelsHelp: String? = nil,\n    providerId: Binding<String?>,\n    modelId: Binding<String?>\n  ) {\n    self.sections = sections\n    self.models = models\n    self.modelSectionTitle = modelSectionTitle\n    self.includeAuto = includeAuto\n    self.autoTitle = autoTitle\n    self.includeDefaultModel = includeDefaultModel\n    self.defaultModelTitle = defaultModelTitle\n    self.providerUnavailableHint = providerUnavailableHint\n    self.disableModels = disableModels\n    self.showProviderPicker = showProviderPicker\n    self.showModelPicker = showModelPicker\n    self.simpleMode = simpleMode\n    self.autoProxyTitle = autoProxyTitle\n    self.sanitizeModelNames = sanitizeModelNames\n    self.onEditModels = onEditModels\n    self.editModelsHelp = editModelsHelp\n    self._providerId = providerId\n    self._modelId = modelId\n  }\n\n  var body: some View {\n    VStack(alignment: .trailing, spacing: 4) {\n      HStack(spacing: 8) {\n        if showProviderPicker {\n          providerPicker\n        }\n        if showModelPicker {\n          modelPicker\n        }\n        if showModelPicker, let onEditModels {\n          Button {\n            onEditModels()\n          } label: {\n            Image(systemName: \"slider.horizontal.3\")\n          }\n          .buttonStyle(.borderless)\n          .help(editModelsHelp ?? \"Edit models\")\n        }\n      }\n      if showProviderPicker, let hint = providerUnavailableHint, !hint.isEmpty {\n        Text(hint)\n          .font(.caption)\n          .foregroundStyle(.secondary)\n          .frame(maxWidth: .infinity, alignment: .trailing)\n      }\n    }\n  }\n\n  private var providerPicker: some View {\n    Group {\n      if simpleMode {\n        // Simple mode: custom segmented control with individual tooltips\n        HStack(spacing: 0) {\n          Button {\n            providerId = nil\n          } label: {\n            Text(autoTitle)\n              .frame(maxWidth: .infinity)\n              .padding(.vertical, 4)\n          }\n          .buttonStyle(SegmentButtonStyle(isSelected: providerId == nil))\n          .help(\"Use CLI's built-in provider configuration\")\n\n          Button {\n            providerId = UnifiedProviderID.autoProxyId\n          } label: {\n            Text(autoProxyTitle)\n              .frame(maxWidth: .infinity)\n              .padding(.vertical, 4)\n          }\n          .buttonStyle(SegmentButtonStyle(isSelected: providerId == UnifiedProviderID.autoProxyId))\n          .help(\"Route all requests through CLI Proxy API for unified provider management\")\n        }\n        .fixedSize(horizontal: false, vertical: true)\n      } else {\n        // Full mode: dropdown picker with all providers\n        Picker(\"\", selection: $providerId) {\n          if includeAuto {\n            Text(autoTitle).tag(String?.none)\n          }\n          ForEach(sections) { section in\n            Section(section.title) {\n              ForEach(section.providers) { provider in\n                providerMenuItem(provider)\n                  .tag(String?(provider.id))\n                  .disabled(!provider.isAvailable)\n              }\n            }\n          }\n        }\n        .labelsHidden()\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func providerMenuItem(_ provider: UnifiedProviderChoice) -> some View {\n    let parsed = UnifiedProviderID.parse(provider.id)\n    switch parsed {\n    case .oauth(let authProvider, _):\n      Label {\n        Text(provider.title)\n      } icon: {\n        // LocalAuthProviderIconView already applies theme handling internally\n        LocalAuthProviderIconView(provider: authProvider, size: 14, cornerRadius: 2)\n      }\n    case .api(let apiId):\n      // For API key providers, use unified icon resource library\n      if let iconName = ProviderIconResource.iconName(for: apiId) ?? ProviderIconResource.iconName(for: provider.title),\n         let processedImage = ProviderIconResource.processedImage(\n           named: iconName,\n           size: NSSize(width: 14, height: 14),\n           isDarkMode: NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua\n         ) {\n        Label {\n          Text(provider.title)\n        } icon: {\n          Image(nsImage: processedImage)\n            .resizable()\n            .interpolation(.high)\n            .aspectRatio(contentMode: .fit)\n            .frame(width: 14, height: 14)\n        }\n      } else {\n        Text(provider.title)\n      }\n    default:\n      Text(provider.title)\n    }\n  }\n\n  private func iconNameForOAuthProvider(_ provider: LocalAuthProvider) -> String {\n    switch provider {\n    case .codex: return \"ChatGPTIcon\"\n    case .claude: return \"ClaudeIcon\"\n    case .gemini: return \"GeminiIcon\"\n    case .antigravity: return \"AntigravityIcon\"\n    case .qwen: return \"QwenIcon\"\n    }\n  }\n\n\n  private var modelPicker: some View {\n    Picker(\"\", selection: $modelId) {\n      if includeDefaultModel {\n        Text(defaultModelTitle).tag(String?.none)\n      }\n      if let title = modelSectionTitle, !models.isEmpty {\n        Section(title) {\n          ForEach(models, id: \\.self) { model in\n            Text(displayName(for: model)).tag(String?(model))\n          }\n        }\n      } else {\n        ForEach(models, id: \\.self) { model in\n          Text(displayName(for: model)).tag(String?(model))\n        }\n      }\n    }\n    .labelsHidden()\n    .disabled(disableModels)\n  }\n\n  /// Returns the display name for a model (sanitized if enabled, otherwise raw)\n  private func displayName(for model: String) -> String {\n    if sanitizeModelNames {\n      return ModelNameSanitizer.sanitizeSingle(model)\n    }\n    return model\n  }\n}\n\n// MARK: - Segment Button Style\n\nprivate struct SegmentButtonStyle: ButtonStyle {\n  let isSelected: Bool\n\n  func makeBody(configuration: Configuration) -> some View {\n    configuration.label\n      .font(.system(size: 11))\n      .foregroundColor(isSelected ? .white : .primary)\n      .background(\n        RoundedRectangle(cornerRadius: 5)\n          .fill(isSelected ? Color.accentColor : Color.clear)\n      )\n      .overlay(\n        RoundedRectangle(cornerRadius: 5)\n          .strokeBorder(Color.gray.opacity(0.3), lineWidth: 0.5)\n      )\n      .opacity(configuration.isPressed ? 0.7 : 1.0)\n  }\n}\n"
  },
  {
    "path": "views/UsageStatusControl.swift",
    "content": "import SwiftUI\nimport AppKit\n\nstruct UsageStatusControl: View {\n  var snapshots: [UsageProviderKind: UsageProviderSnapshot]\n  var preferences: SessionPreferencesStore\n  @Binding var selectedProvider: UsageProviderKind\n  var onRequestRefresh: (UsageProviderKind) -> Void\n\n  @State private var showPopover = false\n  @State private var isHovering = false\n  @State private var hoverPhase: Double = 0\n  @State private var hoverLockoutActive = false\n  @State private var didAutoRefreshCodex = false\n\n  private static let hoverAnimation = Animation.easeInOut(duration: 0.2)\n\n  private static let countdownFormatter: DateComponentsFormatter = {\n    let formatter = DateComponentsFormatter()\n    formatter.allowedUnits = [.day, .hour, .minute]\n    formatter.unitsStyle = .abbreviated\n    formatter.maximumUnitCount = 2\n    formatter.includesTimeRemainingPhrase = false\n    return formatter\n  }()\n\n  private var countdownFormatter: DateComponentsFormatter { Self.countdownFormatter }\n\n  var body: some View {\n    let referenceDate = Date()\n    return Group {\n      if shouldHideAllProviders {\n        EmptyView()\n      } else {\n        content(referenceDate: referenceDate)\n      }\n    }\n  }\n\n  @ViewBuilder\n  private func content(referenceDate: Date) -> some View {\n    HStack(spacing: 8) {\n      let enabledProviders = orderedEnabledProviders()\n      let rows = providerRows(at: referenceDate, enabledProviders: enabledProviders)\n      let ringStates = enabledProviders.map { ringState(for: $0, relativeTo: referenceDate) }\n\n      Button {\n        showPopover.toggle()\n      } label: {\n        HStack(spacing: isHovering ? 8 : 0) {\n          TripleUsageDonutView(\n            states: ringStates\n          )\n          VStack(alignment: .leading, spacing: -1.5) {\n            if rows.isEmpty {\n              Text(\"Usage unavailable\")\n                .font(.system(size: 8))\n                .foregroundStyle(.secondary)\n            } else {\n              ForEach(rows, id: \\.provider) { row in\n                Text(row.text)\n                  .font(.system(size: 8))\n                  .lineLimit(1)\n              }\n            }\n          }\n          .opacity(isHovering ? 1 : 0)\n          .frame(maxWidth: isHovering ? .infinity : 0, alignment: .leading)\n          .clipped()\n        }\n        .animation(Self.hoverAnimation, value: isHovering)\n        .padding(.leading, 4)\n        .padding(.vertical, 4)\n        .padding(.trailing, isHovering ? 8 : 4)\n        .contentShape(Capsule(style: .continuous))\n      }\n      .buttonStyle(.plain)\n      .help(\"View usage snapshots for Codex, Claude, and Gemini\")\n      .focusable(false)\n      .onHover { hovering in\n        if hovering {\n          guard !hoverLockoutActive else { return }\n          withAnimation(Self.hoverAnimation) {\n            isHovering = true\n            hoverPhase = 1\n          }\n        } else {\n          if isHovering {\n            hoverLockoutActive = true\n          }\n          withAnimation(Self.hoverAnimation) {\n            isHovering = false\n            hoverPhase = 0\n          }\n        }\n      }\n      .onAppear { autoRefreshCodexIfNeeded() }\n      .onChange(of: snapshots[.codex]?.updatedAt ?? nil) { _ in\n        autoRefreshCodexIfNeeded()\n      }\n      .onChange(of: showPopover) { isPresented in\n        if isPresented {\n          refreshAllProviders()\n        }\n      }\n      .onAnimationCompleted(for: hoverPhase) {\n        guard hoverPhase == 0 else { return }\n        hoverLockoutActive = false\n      }\n      .onDisappear {\n        hoverLockoutActive = false\n        hoverPhase = 0\n      }\n      .popover(isPresented: $showPopover, arrowEdge: .top) {\n        let enabledProviders = orderedEnabledProviders()\n        UsageStatusPopover(\n          snapshots: snapshots,\n          enabledProviders: enabledProviders,\n          selectedProvider: $selectedProvider,\n          onRequestRefresh: onRequestRefresh\n        )\n      }\n    }\n  }\n\n  private var shouldHideAllProviders: Bool {\n    let enabledProviders = orderedEnabledProviders()\n    guard !enabledProviders.isEmpty else { return true }\n    return enabledProviders.allSatisfy { provider in\n      guard let snapshot = snapshots[provider] else { return true }\n      return snapshot.origin == .thirdParty\n    }\n  }\n\n  private func providerRows(\n    at date: Date,\n    enabledProviders: [UsageProviderKind]\n  ) -> [(provider: UsageProviderKind, text: String)] {\n    enabledProviders.compactMap { provider in\n      guard let snapshot = snapshots[provider] else { return nil }\n      if snapshot.origin == .thirdParty {\n        return (provider, \"\\(provider.displayName) · Custom provider (usage unavailable)\")\n      }\n      let urgent = snapshot.urgentMetric(relativeTo: date)\n      switch snapshot.availability {\n      case .ready:\n        let percent = urgent?.percentText ?? \"—\"\n        let info: String\n        if let urgent = urgent, let reset = urgent.resetDate {\n          info =\n            resetCountdown(from: reset, kind: urgent.kind) ?? resetFormatter.string(from: reset)\n        } else if let minutes = urgent?.fallbackWindowMinutes {\n          info = \"\\(minutes)m window\"\n        } else {\n          info = \"—\"\n        }\n        return (provider, \"\\(provider.displayName) · \\(percent) · \\(info)\")\n      case .empty:\n        return (provider, \"\\(provider.displayName) · Not available\")\n      case .comingSoon:\n        return nil\n      }\n    }\n  }\n\n  private func autoRefreshCodexIfNeeded() {\n    guard preferences.isCLIEnabled(.codex) else { return }\n    let shouldRefresh: Bool = {\n      guard let snapshot = snapshots[.codex] else { return true }\n      if snapshot.origin == .thirdParty { return false }\n      if snapshot.availability == .ready { return false }\n      return snapshot.updatedAt == nil\n    }()\n\n    if shouldRefresh {\n      // Only trigger refresh if we haven't already done so\n      guard !didAutoRefreshCodex else { return }\n      didAutoRefreshCodex = true\n      onRequestRefresh(.codex)\n    } else {\n      // Reset flag when data is available, allowing future auto-refresh if data becomes unavailable again\n      didAutoRefreshCodex = false\n    }\n  }\n\n  private func ringState(for provider: UsageProviderKind, relativeTo date: Date) -> UsageRingState {\n    let color = providerColor(provider)\n    guard let snapshot = snapshots[provider] else {\n      return UsageRingState(progress: nil, baseColor: color, disabled: false)\n    }\n    if snapshot.origin == .thirdParty {\n      return UsageRingState(progress: nil, baseColor: color, disabled: true)\n    }\n    guard snapshot.availability == .ready else {\n      return UsageRingState(progress: nil, baseColor: color, disabled: false)\n    }\n    let urgentMetric = snapshot.urgentMetric(relativeTo: date)\n    return UsageRingState(\n      progress: urgentMetric?.progress,\n      baseColor: color,\n      healthState: urgentMetric?.healthState(relativeTo: date),\n      disabled: false\n    )\n  }\n\n  private func refreshAllProviders() {\n    for provider in orderedEnabledProviders() {\n      onRequestRefresh(provider)\n    }\n  }\n\n  private func providerColor(_ provider: UsageProviderKind) -> Color {\n    switch provider {\n    case .codex:\n      return Color.accentColor\n    case .claude:\n      return Color(nsColor: .systemPurple)\n    case .gemini:\n      return Color(nsColor: .systemTeal)\n    }\n  }\n\n  private func orderedEnabledProviders() -> [UsageProviderKind] {\n    let ordered: [UsageProviderKind] = [.gemini, .claude, .codex]\n    return ordered.filter { preferences.isCLIEnabled($0.baseKind) }\n  }\n\n  private static let resetFormatter: DateFormatter = {\n    let formatter = DateFormatter()\n    formatter.setLocalizedDateFormatFromTemplate(\"MMM d HH:mm\")\n    return formatter\n  }()\n\n  private var resetFormatter: DateFormatter { Self.resetFormatter }\n\n  private func resetCountdown(from date: Date, kind: UsageMetricSnapshot.Kind) -> String? {\n    let interval = date.timeIntervalSinceNow\n    guard interval > 0 else {\n      return kind == .sessionExpiry ? \"expired\" : \"reset\"\n    }\n    if let formatted = countdownFormatter.string(from: interval) {\n      let verb = kind == .sessionExpiry ? \"expires in\" : \"resets in\"\n      return \"\\(verb) \\(formatted)\"\n    }\n    return nil\n  }\n}\n\nprivate struct AnimationCompletionObserverModifier<Value>: AnimatableModifier\nwhere Value: VectorArithmetic {\n  var animatableData: Value {\n    didSet { notifyIfFinished() }\n  }\n\n  private let targetValue: Value\n  private let completion: () -> Void\n\n  init(observedValue: Value, completion: @escaping () -> Void) {\n    self.animatableData = observedValue\n    self.targetValue = observedValue\n    self.completion = completion\n  }\n\n  func body(content: Content) -> some View {\n    content\n  }\n\n  private func notifyIfFinished() {\n    guard animatableData == targetValue else { return }\n    DispatchQueue.main.async { completion() }\n  }\n}\n\nextension View {\n  fileprivate func onAnimationCompleted<Value: VectorArithmetic>(\n    for value: Value,\n    completion: @escaping () -> Void\n  ) -> some View {\n    modifier(AnimationCompletionObserverModifier(observedValue: value, completion: completion))\n  }\n}\n\nprivate struct UsageStatusPopover: View {\n  var snapshots: [UsageProviderKind: UsageProviderSnapshot]\n  var enabledProviders: [UsageProviderKind]\n  @Binding var selectedProvider: UsageProviderKind\n  var onRequestRefresh: (UsageProviderKind) -> Void\n\n  @State private var didTriggerClaudeAutoRefresh = false\n\n  var body: some View {\n    TimelineView(.periodic(from: .now, by: 1)) { context in\n      content(referenceDate: context.date)\n    }\n    .padding(16)\n    .frame(width: 300)\n    .focusable(false)\n    .onAppear { maybeTriggerClaudeAutoRefresh(now: Date()) }\n    .onChange(of: snapshots[.claude]?.updatedAt ?? nil) { _ in\n      maybeTriggerClaudeAutoRefresh(now: Date())\n    }\n    .onDisappear { didTriggerClaudeAutoRefresh = false }\n  }\n\n  @ViewBuilder\n  private func content(referenceDate: Date) -> some View {\n    VStack(alignment: .leading, spacing: 12) {\n      ForEach(Array(enabledProviders.enumerated()), id: \\.element.id) { index, provider in\n        VStack(alignment: .leading, spacing: 8) {\n          HStack(spacing: 6) {\n            providerIcon(for: provider)\n            if let snapshot = snapshots[provider] {\n              UsageProviderTitleView(\n                title: snapshot.title,\n                badge: snapshot.titleBadge,\n                provider: provider\n              )\n            } else {\n              Text(provider.displayName)\n                .font(.subheadline.weight(.semibold))\n            }\n            Spacer()\n          }\n\n          if let snapshot = snapshots[provider] {\n            UsageSnapshotView(\n              referenceDate: referenceDate,\n              snapshot: snapshot,\n              onAction: { onRequestRefresh(provider) }\n            )\n          } else {\n            Text(\"No usage data available\")\n              .font(.footnote)\n              .foregroundStyle(.secondary)\n          }\n        }\n\n        if index < enabledProviders.count - 1 {\n          Divider()\n            .padding(.vertical, 6)\n        }\n      }\n    }\n  }\n\n  private func maybeTriggerClaudeAutoRefresh(now: Date) {\n    guard enabledProviders.contains(.claude) else { return }\n    guard !didTriggerClaudeAutoRefresh else { return }\n    guard let claude = snapshots[.claude],\n      claude.origin == .builtin,\n      claude.availability == .ready\n    else { return }\n\n    let threshold: TimeInterval = 5 * 60\n    let soonest = claude.metrics\n      .filter { $0.kind == .fiveHour || $0.kind == .weekly }\n      .compactMap { metric -> TimeInterval? in\n        guard let reset = metric.resetDate else { return nil }\n        let interval = reset.timeIntervalSince(now)\n        return interval > 0 ? interval : nil\n      }\n      .min()\n\n    guard let remaining = soonest, remaining <= threshold else { return }\n    didTriggerClaudeAutoRefresh = true\n    onRequestRefresh(.claude)\n  }\n\n  @ViewBuilder\n  private func providerIcon(for provider: UsageProviderKind) -> some View {\n    ProviderIconView(provider: provider, size: 12, cornerRadius: 2)\n  }\n}\n\nprivate struct UsageProviderTitleView: View {\n  var title: String\n  var badge: String?\n  var provider: UsageProviderKind\n\n  @Environment(\\.openURL) private var openURL\n\n  var body: some View {\n    ZStack(alignment: .topTrailing) {\n      Text(title)\n        .font(.subheadline.weight(.semibold))\n        .padding(.trailing, badge == nil ? 0 : badgeWidth)\n\n      if let badge, !badge.isEmpty {\n        Text(badge)\n          .font(.system(size: 9, weight: .semibold, design: .rounded))\n          .foregroundStyle(.secondary)\n          .baselineOffset(7)\n          .padding(.leading, 4)\n          .frame(width: badgeWidth, alignment: .leading)\n          .offset(y: -1)\n          .onTapGesture {\n            guard let url = usageURL else { return }\n            openURL(url)\n          }\n          .onHover { hovering in\n            guard usageURL != nil else { return }\n            if hovering {\n              NSCursor.pointingHand.set()\n            } else {\n              NSCursor.arrow.set()\n            }\n          }\n      }\n    }\n    .fixedSize(horizontal: true, vertical: false)\n  }\n\n  private var usageURL: URL? {\n    switch provider {\n    case .codex:\n      return URL(string: \"https://chatgpt.com/codex/settings/usage\")\n    case .claude:\n      return URL(string: \"https://claude.ai/settings/usage\")\n    case .gemini:\n      return nil\n    }\n  }\n\n  private var badgeWidth: CGFloat { 44 }\n}\n\nprivate struct UsageSnapshotView: View {\n  var referenceDate: Date\n  var snapshot: UsageProviderSnapshot\n  var onAction: (() -> Void)?\n\n  private static let relativeFormatter: RelativeDateTimeFormatter = {\n    let formatter = RelativeDateTimeFormatter()\n    formatter.unitsStyle = .abbreviated\n    return formatter\n  }()\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      if snapshot.origin == .thirdParty {\n        VStack(alignment: .leading, spacing: 8) {\n          Text(\n            \"Usage data isn't available while a custom provider is selected. Switch Active Provider back to (Built-in) to restore usage.\"\n          )\n          .font(.footnote)\n          .foregroundStyle(.secondary)\n        }\n        .opacity(0.75)\n      } else if snapshot.availability == .ready {\n        ForEach(snapshot.metrics.filter { $0.kind != .snapshot && $0.kind != .context }) { metric in\n          let state = MetricDisplayState(metric: metric, referenceDate: referenceDate)\n          UsageMetricRowView(metric: metric, state: state, now: referenceDate)\n        }\n\n        HStack {\n          Spacer(minLength: 0)\n          Label(updatedLabel(reference: referenceDate), systemImage: \"clock.arrow.circlepath\")\n            .labelStyle(.titleAndIcon)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n      } else {\n        VStack(alignment: .leading, spacing: 10) {\n          Text(snapshot.statusMessage ?? \"No usage data yet.\")\n            .font(.footnote)\n            .foregroundStyle(.secondary)\n\n          if let action = snapshot.action {\n            let label = actionLabel(for: action)\n            Button {\n              onAction?()\n            } label: {\n              Label(label.text, systemImage: label.icon)\n                .font(.subheadline)\n            }\n            .buttonStyle(.borderedProminent)\n            .controlSize(.small)\n          }\n        }\n      }\n    }\n    .focusable(false)\n  }\n\n  private func updatedLabel(reference: Date) -> String {\n    if let updated = snapshot.updatedAt {\n      let relative = Self.relativeFormatter.localizedString(for: updated, relativeTo: reference)\n      return \"Updated \" + relative\n    }\n    return \"Waiting for usage data\"\n  }\n\n  private func actionLabel(for action: UsageProviderSnapshot.Action) -> (text: String, icon: String)\n  {\n    switch action {\n    case .refresh:\n      return (\"Load usage\", \"arrow.clockwise\")\n    case .authorizeKeychain:\n      return (\"Grant access\", \"lock.open\")\n    }\n  }\n}\n\nprivate struct MetricDisplayState {\n  var progress: Double?\n  var usageText: String?\n  var percentText: String?\n  var resetText: String\n\n  init(metric: UsageMetricSnapshot, referenceDate: Date) {\n    let expired = metric.resetDate.map { $0 <= referenceDate } ?? false\n    if expired {\n      progress = metric.progress != nil ? 0 : nil\n      percentText = metric.percentText != nil ? \"0%\" : nil\n      if metric.kind == .fiveHour {\n        usageText = \"No usage since reset\"\n      } else {\n        usageText = metric.usageText\n      }\n      if metric.kind == .fiveHour {\n        resetText = \"Reset\"\n      } else {\n        resetText = \"\"\n      }\n    } else {\n      progress = metric.progress\n      percentText = metric.percentText\n      // Real-time calculation of remaining time using current referenceDate\n      usageText = Self.remainingText(for: metric, referenceDate: referenceDate)\n      resetText = Self.resetDescription(for: metric)\n    }\n  }\n\n  private static func remainingText(for metric: UsageMetricSnapshot, referenceDate: Date) -> String?\n  {\n    guard let resetDate = metric.resetDate else {\n      return metric.usageText  // Fallback to cached text if no reset date\n    }\n\n    let remaining = resetDate.timeIntervalSince(referenceDate)\n    if remaining <= 0 {\n      return metric.kind == .sessionExpiry ? \"Expired\" : \"Reset\"\n    }\n\n    let minutes = Int(remaining / 60)\n    let hours = minutes / 60\n    let days = hours / 24\n\n    switch metric.kind {\n    case .fiveHour:\n      let mins = minutes % 60\n      if hours > 0 {\n        return \"\\(hours)h \\(mins)m remaining\"\n      } else {\n        return \"\\(mins)m remaining\"\n      }\n\n    case .weekly:\n      let remainingHours = hours % 24\n      if days > 0 {\n        if remainingHours > 0 {\n          return \"\\(days)d \\(remainingHours)h remaining\"\n        } else {\n          return \"\\(days)d remaining\"\n        }\n      } else if hours > 0 {\n        let mins = minutes % 60\n        return \"\\(hours)h \\(mins)m remaining\"\n      } else {\n        return \"\\(minutes)m remaining\"\n      }\n\n    case .sessionExpiry, .quota:\n      let mins = minutes % 60\n      if hours > 0 {\n        return \"\\(hours)h \\(mins)m remaining\"\n      } else {\n        return \"\\(mins)m remaining\"\n      }\n\n    case .context, .snapshot:\n      return metric.usageText\n    }\n  }\n\n  private static func resetDescription(for metric: UsageMetricSnapshot) -> String {\n    if let date = metric.resetDate {\n      let prefix = metric.kind == .sessionExpiry ? \"Expires at \" : \"\"\n      return prefix + Self.resetFormatter.string(from: date)\n    }\n    if let minutes = metric.fallbackWindowMinutes {\n      if minutes >= 60 {\n        return String(format: \"%.1fh window\", Double(minutes) / 60.0)\n      }\n      return \"\\(minutes) min window\"\n    }\n    return \"\"\n  }\n\n  private static let resetFormatter: DateFormatter = {\n    let formatter = DateFormatter()\n    formatter.setLocalizedDateFormatFromTemplate(\"MMM d, HH:mm\")\n    return formatter\n  }()\n}\n\nprivate struct UsageMetricRowView: View {\n  var metric: UsageMetricSnapshot\n  var state: MetricDisplayState\n  var now: Date = Date()\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 4) {\n      HStack(alignment: .firstTextBaseline) {\n        Text(metric.label)\n          .font(.subheadline.weight(.semibold))\n        Spacer()\n        Text(state.resetText)\n          .font(.subheadline)\n          .foregroundStyle(.secondary)\n      }\n\n      if let progress = state.progress {\n        UsageProgressBar(\n          progress: progress,\n          healthState: metric.healthState(relativeTo: now)\n        )\n        .frame(height: 4)\n      }\n\n      HStack {\n        Text(state.usageText ?? \"\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n        Spacer()\n        Text(state.percentText ?? \"\")\n          .font(.caption)\n          .foregroundStyle(.secondary)\n      }\n    }\n  }\n}\n\nprivate struct UsageProgressBar: View {\n  var progress: Double\n  var healthState: UsageMetricSnapshot.HealthState\n\n  var body: some View {\n    GeometryReader { geo in\n      let clamped = max(0, min(progress, 1))\n      ZStack(alignment: .leading) {\n        Capsule(style: .continuous)\n          .fill(Color.secondary.opacity(0.2))\n        if clamped <= 0.002 {\n          Circle()\n            .fill(barColor)\n            .frame(width: 6, height: 6)\n        } else {\n          Capsule(style: .continuous)\n            .fill(barColor)\n            .frame(width: max(6, geo.size.width * CGFloat(clamped)))\n        }\n      }\n    }\n  }\n\n  private var barColor: Color {\n    switch healthState {\n    case .healthy:\n      return .accentColor  // Blue - usage is slower than time\n    case .warning:\n      return .orange       // Orange - usage is faster than time\n    case .unknown:\n      return .accentColor  // Default blue\n    }\n  }\n}\n\nstruct DarkModeInvertModifier: ViewModifier {\n  var active: Bool\n\n  func body(content: Content) -> some View {\n    if active {\n      content.colorInvert()\n    } else {\n      content\n    }\n  }\n}\n"
  },
  {
    "path": "views/Wizard/CommandWizardSheet.swift",
    "content": "import SwiftUI\n\nstruct CommandWizardSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  var onApply: (CommandWizardDraft) -> Void\n  var onCancel: () -> Void\n\n  @StateObject private var vm: WizardConversationViewModel<CommandWizardDraft>\n\n  init(\n    preferences: SessionPreferencesStore,\n    onApply: @escaping (CommandWizardDraft) -> Void,\n    onCancel: @escaping () -> Void\n  ) {\n    self.preferences = preferences\n    self.onApply = onApply\n    self.onCancel = onCancel\n    _vm = StateObject(\n      wrappedValue: WizardConversationViewModel<CommandWizardDraft>(\n        feature: .commands,\n        preferences: preferences,\n        summaryBuilder: CommandWizardSheet.summaryLines\n      )\n    )\n  }\n\n  var body: some View {\n    WizardConversationView(\n      title: \"Command Wizard\",\n      subtitle: \"Describe the slash command you want to create.\",\n      vm: vm,\n      onApply: { draft in\n        onApply(draft)\n      },\n      onCancel: onCancel\n    )\n  }\n\n  private static func summaryLines(_ draft: CommandWizardDraft) -> [String] {\n    var lines: [String] = []\n    lines.append(\"Name: \\(draft.name)\")\n    lines.append(\"Description: \\(draft.description)\")\n    lines.append(\"Prompt length: \\(draft.prompt.count) chars\")\n    if !draft.tags.isEmpty {\n      lines.append(\"Tags: \\(draft.tags.joined(separator: \", \"))\")\n    }\n    if let targets = draft.targets {\n      let codex = targets.codex ? \"on\" : \"off\"\n      let claude = targets.claude ? \"on\" : \"off\"\n      let gemini = targets.gemini ? \"on\" : \"off\"\n      lines.append(\"Targets: Codex \\(codex), Claude \\(claude), Gemini \\(gemini)\")\n    }\n    return lines\n  }\n}\n"
  },
  {
    "path": "views/Wizard/HookWizardSheet.swift",
    "content": "import SwiftUI\n\nstruct HookWizardSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  var onApply: (HookWizardDraft) -> Void\n  var onCancel: () -> Void\n\n  @StateObject private var vm: WizardConversationViewModel<HookWizardDraft>\n\n  init(\n    preferences: SessionPreferencesStore,\n    onApply: @escaping (HookWizardDraft) -> Void,\n    onCancel: @escaping () -> Void\n  ) {\n    self.preferences = preferences\n    self.onApply = onApply\n    self.onCancel = onCancel\n    _vm = StateObject(\n      wrappedValue: WizardConversationViewModel<HookWizardDraft>(\n        feature: .hooks,\n        preferences: preferences,\n        summaryBuilder: HookWizardSheet.summaryLines\n      )\n    )\n  }\n\n  var body: some View {\n    WizardConversationView(\n      title: \"Hook Wizard\",\n      subtitle: \"Describe the hook behavior you want to create.\",\n      vm: vm,\n      onApply: { draft in\n        onApply(draft)\n      },\n      onCancel: onCancel\n    )\n  }\n\n  private static func summaryLines(_ draft: HookWizardDraft) -> [String] {\n    var lines: [String] = []\n    lines.append(\"Event: \\(draft.event)\")\n    if let matcher = draft.matcher, !matcher.isEmpty {\n      lines.append(\"Matcher: \\(matcher)\")\n    }\n    let commandCount = draft.commands.count\n    lines.append(\"Commands: \\(commandCount)\")\n    if let targets = draft.targets {\n      let codex = targets.codex ? \"on\" : \"off\"\n      let claude = targets.claude ? \"on\" : \"off\"\n      let gemini = targets.gemini ? \"on\" : \"off\"\n      lines.append(\"Targets: Codex \\(codex), Claude \\(claude), Gemini \\(gemini)\")\n    }\n    return lines\n  }\n}\n"
  },
  {
    "path": "views/Wizard/MCPWizardSheet.swift",
    "content": "import SwiftUI\n\nstruct MCPWizardSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  var onApply: (MCPWizardDraft) -> Void\n  var onCancel: () -> Void\n\n  @StateObject private var vm: WizardConversationViewModel<MCPWizardDraft>\n\n  init(\n    preferences: SessionPreferencesStore,\n    onApply: @escaping (MCPWizardDraft) -> Void,\n    onCancel: @escaping () -> Void\n  ) {\n    self.preferences = preferences\n    self.onApply = onApply\n    self.onCancel = onCancel\n    _vm = StateObject(\n      wrappedValue: WizardConversationViewModel<MCPWizardDraft>(\n        feature: .mcp,\n        preferences: preferences,\n        summaryBuilder: MCPWizardSheet.summaryLines\n      )\n    )\n  }\n\n  var body: some View {\n    WizardConversationView(\n      title: \"MCP Server Wizard\",\n      subtitle: \"Describe the MCP server you want to add.\",\n      vm: vm,\n      onApply: { draft in\n        onApply(draft)\n      },\n      onCancel: onCancel\n    )\n  }\n\n  private static func summaryLines(_ draft: MCPWizardDraft) -> [String] {\n    var lines: [String] = []\n    lines.append(\"Name: \\(draft.name)\")\n    lines.append(\"Kind: \\(draft.kind.rawValue)\")\n    if let command = draft.command, !command.isEmpty {\n      lines.append(\"Command: \\(command)\")\n    }\n    if let url = draft.url, !url.isEmpty {\n      lines.append(\"URL: \\(url)\")\n    }\n    if let targets = draft.targets {\n      let codex = targets.codex ? \"on\" : \"off\"\n      let claude = targets.claude ? \"on\" : \"off\"\n      let gemini = targets.gemini ? \"on\" : \"off\"\n      lines.append(\"Targets: Codex \\(codex), Claude \\(claude), Gemini \\(gemini)\")\n    }\n    return lines\n  }\n}\n"
  },
  {
    "path": "views/Wizard/SkillWizardSheet.swift",
    "content": "import SwiftUI\n\nstruct SkillWizardSheet: View {\n  @ObservedObject var preferences: SessionPreferencesStore\n  var onApply: (SkillWizardDraft) -> Void\n  var onCancel: () -> Void\n\n  @StateObject private var vm: WizardConversationViewModel<SkillWizardDraft>\n\n  init(\n    preferences: SessionPreferencesStore,\n    onApply: @escaping (SkillWizardDraft) -> Void,\n    onCancel: @escaping () -> Void\n  ) {\n    self.preferences = preferences\n    self.onApply = onApply\n    self.onCancel = onCancel\n    _vm = StateObject(\n      wrappedValue: WizardConversationViewModel<SkillWizardDraft>(\n        feature: .skills,\n        preferences: preferences,\n        summaryBuilder: SkillWizardSheet.summaryLines\n      )\n    )\n  }\n\n  var body: some View {\n    WizardConversationView(\n      title: \"Skill Wizard\",\n      subtitle: \"Describe the skill you want to create.\",\n      vm: vm,\n      onApply: { draft in\n        onApply(draft)\n      },\n      onCancel: onCancel\n    )\n  }\n\n  private static func summaryLines(_ draft: SkillWizardDraft) -> [String] {\n    var lines: [String] = []\n    lines.append(\"Name: \\(draft.name)\")\n    lines.append(\"Description: \\(draft.description)\")\n    if let summary = draft.summary, !summary.isEmpty {\n      lines.append(\"Summary: \\(summary)\")\n    }\n    if !draft.tags.isEmpty {\n      lines.append(\"Tags: \\(draft.tags.joined(separator: \", \"))\")\n    }\n    lines.append(\"Instructions: \\(draft.instructions.count)\")\n    lines.append(\"Examples: \\(draft.examples.count)\")\n    return lines\n  }\n}\n"
  },
  {
    "path": "views/Wizard/WizardConversationView.swift",
    "content": "import SwiftUI\n\nstruct WizardConversationView<Draft: Codable>: View {\n  let title: String\n  let subtitle: String?\n  @ObservedObject var vm: WizardConversationViewModel<Draft>\n  var onApply: (Draft) -> Void\n  var onCancel: () -> Void\n\n  @FocusState private var inputFocused: Bool\n  @EnvironmentObject private var wizardGuard: WizardGuard\n\n  var body: some View {\n    VStack(alignment: .leading, spacing: 12) {\n      header\n      conversationPanel\n\n      if let error = vm.errorMessage, !error.isEmpty {\n        ScrollView {\n          Text(error)\n            .font(.system(size: 12, design: .monospaced))\n            .foregroundStyle(.red)\n            .textSelection(.enabled)\n            .frame(maxWidth: .infinity, alignment: .leading)\n        }\n        .frame(maxHeight: 160)\n      }\n      actionBar\n    }\n    .padding(16)\n    .frame(minWidth: 760, minHeight: 520, maxHeight: 720)\n    .onAppear { wizardGuard.isActive = true }\n    .onDisappear { wizardGuard.isActive = false }\n  }\n\n  private var header: some View {\n    HStack(alignment: .firstTextBaseline, spacing: 12) {\n      VStack(alignment: .leading, spacing: 4) {\n        Text(title)\n          .font(.title3)\n          .fontWeight(.semibold)\n        if let subtitle, !subtitle.isEmpty {\n          Text(subtitle)\n            .font(.caption)\n            .foregroundStyle(.secondary)\n        }\n      }\n      Spacer()\n      providerPicker\n    }\n  }\n\n  private var providerPicker: some View {\n    let providers =\n      vm.availableProviders.isEmpty ? SessionSource.Kind.allCases : vm.availableProviders\n    return Picker(\"Provider\", selection: $vm.selectedProvider) {\n      ForEach(providers, id: \\.self) { provider in\n        Text(provider.displayName).tag(provider)\n      }\n    }\n    .pickerStyle(.segmented)\n    .frame(width: 260)\n  }\n\n  private var conversationPanel: some View {\n    VStack(spacing: 0) {\n      ScrollView {\n        LazyVStack(alignment: .leading, spacing: 10) {\n          let items = timelineItems\n          if items.isEmpty {\n            Text(\"Describe what you want to create.\")\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .frame(maxWidth: .infinity, alignment: .center)\n              .padding(.vertical, 32)\n          }\n          ForEach(items) { item in\n            switch item.kind {\n            case .message(let msg):\n              messageRow(msg)\n            case .draft(let draft):\n              draftMessageRow(draft)\n            case .runEvent(let event):\n              runEventRow(event)\n            }\n          }\n        }\n        .frame(maxWidth: .infinity, alignment: .leading)\n        .padding(.vertical, 8)\n      }\n      inputBar\n    }\n    .frame(maxHeight: .infinity)\n    .padding(12)\n    .background(\n      RoundedRectangle(cornerRadius: 8)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 8).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private func messageRow(_ msg: WizardMessage) -> some View {\n    HStack(alignment: .top, spacing: 8) {\n      if msg.role == .user { Spacer(minLength: 0) }\n      VStack(alignment: .leading, spacing: 4) {\n        Text(msg.role == .user ? \"You\" : \"Assistant\")\n          .font(.caption2)\n          .foregroundStyle(.secondary)\n        Text(msg.text)\n          .font(.body)\n          .fixedSize(horizontal: false, vertical: true)\n      }\n      .padding(8)\n      .background(\n        RoundedRectangle(cornerRadius: 6)\n          .fill(msg.role == .user ? Color.accentColor.opacity(0.12) : Color.secondary.opacity(0.08))\n      )\n      if msg.role != .user { Spacer(minLength: 0) }\n    }\n  }\n\n  private func draftMessageRow(_ draft: Draft) -> some View {\n    let lines = vm.draftSummaryLines()\n    return HStack(alignment: .top, spacing: 8) {\n      VStack(alignment: .leading, spacing: 4) {\n        Text(\"Assistant\")\n          .font(.caption2)\n          .foregroundStyle(.secondary)\n        VStack(alignment: .leading, spacing: 6) {\n          Text(\"Draft preview\")\n            .font(.subheadline.weight(.semibold))\n          ForEach(lines, id: \\.self) { line in\n            Text(line)\n              .font(.caption)\n              .foregroundStyle(.secondary)\n              .fixedSize(horizontal: false, vertical: true)\n          }\n          if !vm.warnings.isEmpty {\n            ForEach(vm.warnings, id: \\.self) { warning in\n              Text(\"⚠︎ \\(warning)\")\n                .font(.caption)\n                .foregroundStyle(.secondary)\n            }\n          }\n        }\n      }\n      .padding(8)\n      .background(\n        RoundedRectangle(cornerRadius: 6)\n          .fill(Color.secondary.opacity(0.08))\n      )\n      Spacer(minLength: 0)\n    }\n  }\n\n  private var inputBar: some View {\n    let canSend =\n      !vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !vm.isRunning\n    return ZStack(alignment: .bottomTrailing) {\n      ZStack(alignment: .topLeading) {\n        if vm.inputText.isEmpty {\n          Text(\"Describe what you want to create…\")\n            .font(.body)\n            .foregroundStyle(.secondary)\n            .padding(.horizontal, 12)\n            .padding(.vertical, 10)\n            .allowsHitTesting(false)\n        }\n        TextEditor(text: $vm.inputText)\n          .focused($inputFocused)\n          .frame(minHeight: 72, maxHeight: 140)\n          .padding(.horizontal, 8)\n          .padding(.vertical, 6)\n          .padding(.trailing, 36)\n          .padding(.bottom, 24)\n          .scrollContentBackground(.hidden)\n          .background(Color.clear)\n          .disabled(vm.isRunning)\n      }\n      Button(action: {\n        vm.sendMessage()\n      }) {\n        Image(systemName: \"paperplane.fill\")\n          .font(.system(size: 12, weight: .semibold))\n          .foregroundStyle(.white)\n          .frame(width: 28, height: 28)\n          .background(Circle().fill(canSend ? Color.accentColor : Color.secondary.opacity(0.35)))\n      }\n      .buttonStyle(.plain)\n      .padding(8)\n      .disabled(!canSend)\n    }\n    .background(\n      RoundedRectangle(cornerRadius: 12)\n        .fill(Color(nsColor: .textBackgroundColor))\n        .overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.secondary.opacity(0.15)))\n    )\n  }\n\n  private func runEventRow(_ event: WizardRunEvent) -> some View {\n    let isOutput = event.kind != .status\n    let isErrorLine = event.kind == .stderr && isErrorMessage(event.message)\n    let tint: Color = isErrorLine ? .red : .secondary\n    let label: String = {\n      switch event.kind {\n      case .status: return \"Tool\"\n      case .stdout: return \"Tool Output\"\n      case .stderr: return isErrorLine ? \"Tool Error\" : \"Tool Log\"\n      }\n    }()\n    return HStack(alignment: .top, spacing: 8) {\n      VStack(alignment: .leading, spacing: 4) {\n        Text(label)\n          .font(.caption2)\n          .foregroundStyle(tint)\n        Text(event.message)\n          .font(isOutput ? .system(size: 11, design: .monospaced) : .caption)\n          .foregroundStyle(tint)\n          .fixedSize(horizontal: false, vertical: true)\n          .textSelection(.enabled)\n      }\n      .padding(8)\n      .background(\n        RoundedRectangle(cornerRadius: 6)\n          .fill(Color.secondary.opacity(0.08))\n      )\n      Spacer(minLength: 0)\n    }\n  }\n\n  private struct TimelineItem: Identifiable {\n    enum Kind {\n      case message(WizardMessage)\n      case runEvent(WizardRunEvent)\n      case draft(Draft)\n    }\n\n    let id: String\n    let timestamp: Date\n    let order: Int\n    let kind: Kind\n  }\n\n  private var timelineItems: [TimelineItem] {\n    var items: [TimelineItem] = []\n    var order = 0\n    for message in vm.messages {\n      items.append(\n        TimelineItem(\n          id: \"message-\\(message.id.uuidString)\",\n          timestamp: message.createdAt,\n          order: order,\n          kind: .message(message)\n        )\n      )\n      order += 1\n    }\n    for event in vm.runEvents {\n      items.append(\n        TimelineItem(\n          id: \"event-\\(event.id.uuidString)\",\n          timestamp: event.timestamp,\n          order: order,\n          kind: .runEvent(event)\n        )\n      )\n      order += 1\n    }\n    if let draft = vm.draft, let timestamp = vm.draftTimestamp, !vm.isRunning {\n      items.append(\n        TimelineItem(\n          id: \"draft-\\(timestamp.timeIntervalSinceReferenceDate)\",\n          timestamp: timestamp,\n          order: order,\n          kind: .draft(draft)\n        )\n      )\n      order += 1\n    }\n    return items.sorted { lhs, rhs in\n      if lhs.timestamp == rhs.timestamp {\n        return lhs.order < rhs.order\n      }\n      return lhs.timestamp < rhs.timestamp\n    }\n  }\n\n  private func isErrorMessage(_ message: String) -> Bool {\n    let lowercased = message.lowercased()\n    return lowercased.contains(\"error\")\n      || lowercased.contains(\"failed\")\n      || lowercased.contains(\"invalid\")\n      || lowercased.contains(\"exception\")\n      || lowercased.contains(\"panic\")\n  }\n\n  private var actionBar: some View {\n    HStack {\n      Spacer()\n      Button(\"Cancel\") { onCancel() }\n      Button(\"Apply\") {\n        if let draft = vm.draft {\n          onApply(draft)\n        }\n      }\n      .buttonStyle(.borderedProminent)\n      .disabled(vm.draft == nil || vm.isRunning)\n    }\n  }\n}\n"
  }
]