From c4c7e2834553ea8acb826d1ed869a760ac0a3092 Mon Sep 17 00:00:00 2001 From: Tom Ballard Date: Wed, 15 Jul 2026 07:55:17 +0100 Subject: [PATCH] feat(release): complete v0.1.1 attention controls [release:v0.1.1] Completes the v0.1.1 attention-product contract: - teaches the menu-bar state language in Settings - opens Settings for first-run integration setup - reports agent-hook, last-event, and GitHub health - records global shortcuts through a native AppKit control - renames dismiss to Hide Until It Changes - keeps a bounded local recent-signals history Also bumps the app bundle to 0.1.1 and makes the development run script relaunch the rebuilt app reliably. Verification: - swift test (40 tests) - ./Scripts/run-meanwhile.sh - codesign --verify --deep --strict --verbose=2 dist/Meanwhile.app - live shortcut capture, cancel, clear, and restore --- .github/workflows/ci.yml | 3 +- App/Info.plist | 4 +- README.md | 38 ++-- Scripts/run-meanwhile.sh | 1 + .../Meanwhile/ConnectionHealthSection.swift | 160 +++++++++++++++ .../Meanwhile/ControlsSettingsSection.swift | 82 ++++++++ Sources/Meanwhile/GlobalHotKey.swift | 156 +++++++++++++++ Sources/Meanwhile/MeanwhileApp.swift | 150 +++++++++----- Sources/Meanwhile/RecentSignalsSection.swift | 78 ++++++++ .../Meanwhile/RepositorySettingsModel.swift | 154 ++++++++++++++- .../Meanwhile/RepositorySettingsView.swift | 165 ++++++++-------- .../Meanwhile/RepositorySourcesSection.swift | 116 +++++++++++ Sources/Meanwhile/SettingsSupport.swift | 76 +++++++ .../Meanwhile/SettingsWindowController.swift | 3 +- Sources/Meanwhile/ShortcutRecorder.swift | 186 ++++++++++++++++++ .../Meanwhile/StatusItemSettingsSection.swift | 81 ++++++++ Sources/MeanwhileCore/AgentEventStore.swift | 10 + .../AgentIntegrationInstaller.swift | 71 ++++++- .../GitHubRepositoryCatalog.swift | 22 +++ Sources/MeanwhileCore/HotKeyPreferences.swift | 55 ++++++ .../MeanwhileConfiguration.swift | 36 +++- Sources/MeanwhileCore/RecentSignalStore.swift | 84 ++++++++ .../AgentEventIntegrationTests.swift | 1 + .../AgentIntegrationInstallerTests.swift | 29 +++ .../GitHubRepositoryCatalogTests.swift | 19 ++ .../MeanwhileConfigurationTests.swift | 54 +++++ .../RecentSignalStoreTests.swift | 45 +++++ docs/v0.1.1-authored-settings.md | 59 ++++++ 28 files changed, 1785 insertions(+), 153 deletions(-) create mode 100644 Sources/Meanwhile/ConnectionHealthSection.swift create mode 100644 Sources/Meanwhile/ControlsSettingsSection.swift create mode 100644 Sources/Meanwhile/GlobalHotKey.swift create mode 100644 Sources/Meanwhile/RecentSignalsSection.swift create mode 100644 Sources/Meanwhile/RepositorySourcesSection.swift create mode 100644 Sources/Meanwhile/SettingsSupport.swift create mode 100644 Sources/Meanwhile/ShortcutRecorder.swift create mode 100644 Sources/Meanwhile/StatusItemSettingsSection.swift create mode 100644 Sources/MeanwhileCore/HotKeyPreferences.swift create mode 100644 Sources/MeanwhileCore/RecentSignalStore.swift create mode 100644 Tests/MeanwhileCoreTests/RecentSignalStoreTests.swift create mode 100644 docs/v0.1.1-authored-settings.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6275775..8fea94c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,7 @@ jobs: - name: Verify packaged artifacts run: | - test -f "${{ runner.temp }}/meanwhile-dist/Meanwhile-0.1.0-unsigned.zip" + version="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' App/Info.plist)" + test -f "${{ runner.temp }}/meanwhile-dist/Meanwhile-${version}-unsigned.zip" test -f "${{ runner.temp }}/meanwhile-dist/meanwhile.rb" ruby -c "${{ runner.temp }}/meanwhile-dist/meanwhile.rb" diff --git a/App/Info.plist b/App/Info.plist index e6f5656..57177ed 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.0 + 0.1.1 CFBundleVersion - 1 + 2 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/README.md b/README.md index adcb1ce..89f11db 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Meanwhile is a macOS 14 menu-bar app that turns coding-agent wait time into one small, actionable GitHub task. It is built in Swift 5.10 with no third-party dependencies. -## v0.1 behavior +## v0.1.1 behavior - Uses Claude Code and Codex lifecycle hooks—not process or CPU guesses—to track each session as `thinking`, `needs-you`, or `idle`. @@ -15,7 +15,8 @@ dependencies. - Surfaces two sources: failing CI on your own open pull requests, then review requests, oldest first within each source. - Orders all work deterministically: needs-you, red CI, reviews, oldest first. -- Supports 15-minute snooze and dismiss from the right-click menu. +- Supports 15-minute snooze and **Hide Until It Changes** from the right-click + menu. - Renders the current item in the Claude Code status line when that slot is available. - Tracks parallel Claude and Codex sessions independently and filters both @@ -32,17 +33,19 @@ Authenticate the GitHub CLI first: gh auth status ``` -Launch Meanwhile, then approve **Install Agent Integrations** on first run (or -choose it later from the right-click menu). The installer merges local hooks -into `~/.claude/settings.json` and `~/.codex/hooks.json` without replacing -unrelated settings. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored when set. +On first launch, Meanwhile opens Settings with integration health and one clear +**Install or Update…** action. The installer merges local hooks into +`~/.claude/settings.json` and `~/.codex/hooks.json` without replacing unrelated +settings. `CLAUDE_CONFIG_DIR` and `CODEX_HOME` are honored when set. Tool-boundary hooks refresh active sessions, while active or blocked sessions use a separate 24-hour crash-safety expiry. If Claude already has a custom status line, Meanwhile preserves it and reports the conflict instead of overwriting it. -Codex requires one additional trust step: open `/hooks` in Codex and approve the -new Meanwhile hooks. Hook events and presentation state stay on the Mac under -`~/Library/Application Support/Meanwhile`; Meanwhile adds no telemetry. +Codex may require one additional trust step: open `/hooks` in Codex and approve +the new Meanwhile hooks. Settings reports hook installation, GitHub +authentication, and the last agent event so setup failures are visible. Hook +events, the latest event, and a bounded recent-signals list stay on the Mac; +Meanwhile adds no telemetry. Terminal focus uses the terminal metadata captured with the agent working directory. Terminal.app and iTerm sessions are selected by TTY; other supported @@ -75,8 +78,15 @@ fields retain their defaults: } ``` -Repository selection remains in macOS user defaults and is managed through -**Settings…** in the right-click menu. +Repository selection, the optional global shortcut, and agent integration +installation are managed through **Settings…** in the right-click menu. Click +the shortcut recorder and press a modified letter, digit, Space, Tab, Return, +or Escape. The shortcut opens the current menu-bar item, the same as clicking +the status item. + +Settings also explains the menu-bar language and keeps the five newest agent, +review, CI, snooze, hide, and installation signals visible for lightweight +diagnosis. ## GitHub access @@ -97,9 +107,9 @@ cask with: GITHUB_REPOSITORY="tcballard/Meanwhile" ./Scripts/release-unsigned.sh ``` -This produces `dist/Meanwhile-0.1.0-unsigned.zip` and `dist/meanwhile.rb`. +This produces `dist/Meanwhile-0.1.1-unsigned.zip` and `dist/meanwhile.rb`. Publish the archive only as a GitHub **pre-release** tagged -`v0.1.0-unsigned`. The generated cask identifies it as unsigned and tells users +`v0.1.1-unsigned`. The generated cask identifies it as unsigned and tells users that Gatekeeper will block the first launch. Users who trust the build must explicitly remove quarantine themselves; the cask does not bypass Gatekeeper. @@ -115,7 +125,7 @@ GITHUB_REPOSITORY="owner/Meanwhile" \ ``` The script builds and signs the app in a temporary local directory, then -produces `dist/Meanwhile-0.1.0.zip` and `dist/meanwhile.rb`. It verifies the +produces `dist/Meanwhile-0.1.1.zip` and `dist/meanwhile.rb`. It verifies the stapled app and a clean extraction of the final archive before returning. Publishing the archive, cask, and GitHub release remains an explicit release-owner action. Set `RELEASE_OUTPUT_DIR` to write the final artifacts diff --git a/Scripts/run-meanwhile.sh b/Scripts/run-meanwhile.sh index 547b37c..0345dc3 100755 --- a/Scripts/run-meanwhile.sh +++ b/Scripts/run-meanwhile.sh @@ -2,5 +2,6 @@ set -euo pipefail ROOT="${0:A:h:h}" +pkill -x Meanwhile 2>/dev/null || true APP="$("$ROOT/Scripts/package-app.sh")" open "$APP" diff --git a/Sources/Meanwhile/ConnectionHealthSection.swift b/Sources/Meanwhile/ConnectionHealthSection.swift new file mode 100644 index 0000000..683deee --- /dev/null +++ b/Sources/Meanwhile/ConnectionHealthSection.swift @@ -0,0 +1,160 @@ +import MeanwhileCore +import SwiftUI + +struct ConnectionHealthSection: View { + let integrationHealth: AgentIntegrationHealth + let integrationHealthError: String? + let lastAgentEvent: AgentSessionState? + let githubAuthenticationStatus: GitHubAuthenticationStatus + let now: Date + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HealthRow( + systemImage: integrationHealthSymbol, + tint: integrationHealthTint, + title: "Agent hooks", + value: integrationHealthTitle, + detail: integrationHealthDetail + ) + HealthRow( + systemImage: agentEventSymbol, + tint: agentEventTint, + title: "Last agent event", + value: lastAgentEventTitle, + detail: lastAgentEventDetail + ) + HealthRow( + systemImage: githubHealthSymbol, + tint: githubHealthTint, + title: "GitHub", + value: githubHealthTitle, + detail: githubHealthDetail + ) + } + } + + private var integrationHealthTitle: String { + switch integrationHealth.state { + case .installed: return "Installed" + case .needsReview: return "Needs review" + case .notInstalled: return "Not installed" + } + } + + private var integrationHealthDetail: String { + if let integrationHealthError { return integrationHealthError } + if integrationHealth.claudeStatuslineConflict { + return "Claude and Codex hooks are installed; your Claude status line was preserved." + } + switch integrationHealth.state { + case .installed: + return "Claude and Codex are connected." + case .needsReview: + let missing = integrationHealth.claudeHooksInstalled ? "Codex" : "Claude" + return "\(missing) hooks are missing or incomplete." + case .notInstalled: + return "Install hooks to let Meanwhile hear agent lifecycle events." + } + } + + private var integrationHealthSymbol: String { + switch integrationHealth.state { + case .installed: return "checkmark.circle.fill" + case .needsReview: return "exclamationmark.triangle.fill" + case .notInstalled: return "circle.dashed" + } + } + + private var integrationHealthTint: Color { + switch integrationHealth.state { + case .installed: return .green + case .needsReview: return .orange + case .notInstalled: return .secondary + } + } + + private var lastAgentEventTitle: String { + guard let lastAgentEvent else { return "No events yet" } + switch lastAgentEvent.phase { + case .thinking: return "Thinking" + case .needsYou: return "Needs you" + case .idle: return "Finished" + } + } + + private var lastAgentEventDetail: String { + guard let lastAgentEvent else { + return "Install integrations, then start an agent session." + } + return "\(providerDisplayName(lastAgentEvent.provider)) · \(relativeDateString(lastAgentEvent.updatedAt, relativeTo: now))" + } + + private var agentEventSymbol: String { + guard let lastAgentEvent else { return "clock.badge.questionmark" } + switch lastAgentEvent.phase { + case .thinking: return "ellipsis.message.fill" + case .needsYou: return "exclamationmark.bubble.fill" + case .idle: return "checkmark.circle.fill" + } + } + + private var agentEventTint: Color { + guard let lastAgentEvent else { return .secondary } + switch lastAgentEvent.phase { + case .thinking: return .orange + case .needsYou: return .red + case .idle: return .green + } + } + + private var githubHealthTitle: String { + githubAuthenticationStatus == .authenticated + ? "Authenticated" + : "Not authenticated" + } + + private var githubHealthDetail: String { + githubAuthenticationStatus == .authenticated + ? "Using the GitHub CLI session on this Mac." + : "Run gh auth login, then refresh repository sources." + } + + private var githubHealthSymbol: String { + githubAuthenticationStatus == .authenticated + ? "checkmark.circle.fill" + : "person.crop.circle.badge.exclamationmark" + } + + private var githubHealthTint: Color { + githubAuthenticationStatus == .authenticated ? .green : .orange + } +} + +private struct HealthRow: View { + let systemImage: String + let tint: Color + let title: String + let value: String + let detail: String + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + Text(title) + .frame(width: 118, alignment: .leading) + VStack(alignment: .leading, spacing: 2) { + Text(value) + .font(.callout.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .accessibilityElement(children: .combine) + } +} diff --git a/Sources/Meanwhile/ControlsSettingsSection.swift b/Sources/Meanwhile/ControlsSettingsSection.swift new file mode 100644 index 0000000..1e4ef84 --- /dev/null +++ b/Sources/Meanwhile/ControlsSettingsSection.swift @@ -0,0 +1,82 @@ +import MeanwhileCore +import SwiftUI + +struct ControlsSettingsSection: View { + @Binding var hotKey: HotKeyConfiguration? + let hotKeyRegistrationError: String? + let setHotKeyRegistrationError: (String?) -> Void + let isInstallingIntegrations: Bool + let integrationActionMessage: String? + let integrationActionIsError: Bool + let installIntegrations: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 11) { + SettingsSectionHeader(title: "Controls") + + HStack(spacing: 10) { + Text("Keyboard shortcut") + .frame(width: 132, alignment: .leading) + + ShortcutRecorder( + shortcut: $hotKey, + validationMessage: setHotKeyRegistrationError + ) + .frame(width: 190, height: 26) + + Button { + hotKey = nil + } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.borderless) + .disabled(hotKey == nil) + .help("Clear keyboard shortcut") + .accessibilityLabel("Clear keyboard shortcut") + + Spacer() + + Text("Opens the current item") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let hotKeyRegistrationError { + Label(hotKeyRegistrationError, systemImage: "exclamationmark.triangle.fill") + .font(.caption) + .foregroundStyle(.orange) + .padding(.leading, 142) + } + + HStack(spacing: 10) { + Text("Agent integrations") + .frame(width: 132, alignment: .leading) + + Button("Install or Update…", action: installIntegrations) + .disabled(isInstallingIntegrations) + + if isInstallingIntegrations { + ProgressView() + .controlSize(.small) + } + + Text("Adds local Claude and Codex lifecycle hooks") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + if let integrationActionMessage { + Label( + integrationActionMessage, + systemImage: integrationActionIsError + ? "exclamationmark.triangle.fill" + : "checkmark.circle.fill" + ) + .font(.caption) + .foregroundStyle(integrationActionIsError ? .orange : .green) + .padding(.leading, 142) + } + } + } +} diff --git a/Sources/Meanwhile/GlobalHotKey.swift b/Sources/Meanwhile/GlobalHotKey.swift new file mode 100644 index 0000000..5dbd834 --- /dev/null +++ b/Sources/Meanwhile/GlobalHotKey.swift @@ -0,0 +1,156 @@ +import Carbon.HIToolbox +import Foundation +import MeanwhileCore + +@MainActor +final class GlobalHotKey { + private static var nextID: UInt32 = 1 + + private var eventHandler: EventHandlerRef? + private var hotKey: EventHotKeyRef? + private let handler: () -> Void + + init(configuration: HotKeyConfiguration, handler: @escaping () -> Void) throws { + self.handler = handler + + guard let keyCode = Self.keyCode(for: configuration.key) else { + throw HotKeyError.unsupportedKey(configuration.key) + } + + let modifierFlags = Self.modifierFlags(for: configuration.modifiers) + guard modifierFlags != 0 else { + throw HotKeyError.missingModifiers + } + + var eventType = EventTypeSpec( + eventClass: OSType(kEventClassKeyboard), + eventKind: UInt32(kEventHotKeyPressed) + ) + + let installStatus = InstallEventHandler( + GetApplicationEventTarget(), + { _, _, userData in + guard let userData else { return noErr } + let hotKey = Unmanaged + .fromOpaque(userData) + .takeUnretainedValue() + Task { @MainActor in hotKey.handler() } + return noErr + }, + 1, + &eventType, + Unmanaged.passUnretained(self).toOpaque(), + &eventHandler + ) + guard installStatus == noErr else { + throw HotKeyError.registrationFailed(installStatus) + } + + let id = EventHotKeyID( + signature: Self.signature("MWKH"), + id: Self.nextID + ) + Self.nextID += 1 + + let registerStatus = RegisterEventHotKey( + UInt32(keyCode), + modifierFlags, + id, + GetApplicationEventTarget(), + 0, + &hotKey + ) + guard registerStatus == noErr else { + throw HotKeyError.registrationFailed(registerStatus) + } + } + + deinit { + if let hotKey { + UnregisterEventHotKey(hotKey) + } + if let eventHandler { + RemoveEventHandler(eventHandler) + } + } + + private static func modifierFlags(for modifiers: [HotKeyModifier]) -> UInt32 { + modifiers.reduce(UInt32(0)) { flags, modifier in + switch modifier { + case .command: return flags | UInt32(cmdKey) + case .control: return flags | UInt32(controlKey) + case .option: return flags | UInt32(optionKey) + case .shift: return flags | UInt32(shiftKey) + } + } + } + + private static func keyCode(for key: String) -> Int? { + switch key.lowercased() { + case "a": return kVK_ANSI_A + case "b": return kVK_ANSI_B + case "c": return kVK_ANSI_C + case "d": return kVK_ANSI_D + case "e": return kVK_ANSI_E + case "f": return kVK_ANSI_F + case "g": return kVK_ANSI_G + case "h": return kVK_ANSI_H + case "i": return kVK_ANSI_I + case "j": return kVK_ANSI_J + case "k": return kVK_ANSI_K + case "l": return kVK_ANSI_L + case "m": return kVK_ANSI_M + case "n": return kVK_ANSI_N + case "o": return kVK_ANSI_O + case "p": return kVK_ANSI_P + case "q": return kVK_ANSI_Q + case "r": return kVK_ANSI_R + case "s": return kVK_ANSI_S + case "t": return kVK_ANSI_T + case "u": return kVK_ANSI_U + case "v": return kVK_ANSI_V + case "w": return kVK_ANSI_W + case "x": return kVK_ANSI_X + case "y": return kVK_ANSI_Y + case "z": return kVK_ANSI_Z + case "0": return kVK_ANSI_0 + case "1": return kVK_ANSI_1 + case "2": return kVK_ANSI_2 + case "3": return kVK_ANSI_3 + case "4": return kVK_ANSI_4 + case "5": return kVK_ANSI_5 + case "6": return kVK_ANSI_6 + case "7": return kVK_ANSI_7 + case "8": return kVK_ANSI_8 + case "9": return kVK_ANSI_9 + case "space": return kVK_Space + case "tab": return kVK_Tab + case "return", "enter": return kVK_Return + case "escape", "esc": return kVK_Escape + default: return nil + } + } + + private static func signature(_ string: String) -> OSType { + string.utf8.reduce(OSType(0)) { result, byte in + (result << 8) + OSType(byte) + } + } +} + +enum HotKeyError: LocalizedError { + case unsupportedKey(String) + case missingModifiers + case registrationFailed(OSStatus) + + var errorDescription: String? { + switch self { + case .unsupportedKey(let key): + return "Unsupported hotkey key: \(key)" + case .missingModifiers: + return "Hotkey requires at least one modifier" + case .registrationFailed(let status): + return "Could not register hotkey: \(status)" + } + } +} diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift index ea79d55..bef16d9 100644 --- a/Sources/Meanwhile/MeanwhileApp.swift +++ b/Sources/Meanwhile/MeanwhileApp.swift @@ -12,16 +12,41 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private let repositoryPreferences = RepositoryPreferences() private let settingsWindowController = SettingsWindowController() private let terminalFocuser = TerminalFocuser() + private let configuration = MeanwhileConfiguration.load() + private let eventStore = AgentEventStore() + private let recentSignalStore = RecentSignalStore() + private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL()) + private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey) private var menuBar: MenuBarController? private var runtime: MeanwhileRuntime? private var currentItem: WorkItem? private var openItemMenuItem: NSMenuItem? private var snoozeMenuItem: NSMenuItem? - private var dismissMenuItem: NSMenuItem? + private var hideMenuItem: NSMenuItem? + private var hotKey: GlobalHotKey? + private var lastRecordedItemID: String? private lazy var settingsModel = RepositorySettingsModel( preferences: repositoryPreferences, + hotKeyPreferences: hotKeyPreferences, + integrationInstaller: integrationInstaller, + eventStore: eventStore, + recentSignalStore: recentSignalStore, selectionDidChange: { [weak self] in self?.runtime?.repositorySelectionDidChange() + }, + hotKeyDidChange: { [weak self] _ in + self?.registerHotKeyFromPreferences() + }, + integrationDidInstall: { [weak self] _ in + guard let self else { return } + UserDefaults.standard.set(true, forKey: DefaultsKey.integrationPrompted) + recentSignalStore.record( + RecentSignal( + kind: .integrationsInstalled, + title: "Agent integrations installed", + detail: "Claude and Codex" + ) + ) } ) @@ -48,16 +73,19 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { repositoryIsAllowed: { preferences.allows(repository: $0) } ) let runtime = MeanwhileRuntime( + eventStore: eventStore, reviewSource: reviewSource, - ciSource: ciSource + ciSource: ciSource, + configuration: configuration ) { [weak self] presentation in Task { @MainActor [weak self] in self?.present(presentation) } } self.runtime = runtime runtime.start() + registerHotKeyFromPreferences() DispatchQueue.main.async { [weak self] in - self?.offerAgentIntegrationIfNeeded() + self?.showFirstRunSettingsIfNeeded() } } @@ -67,6 +95,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private func present(_ presentation: MeanwhilePresentation) { currentItem = presentation.item + recordPresentationIfNeeded(presentation.item) let statusText = MenuBarPresenter.statusText(item: presentation.item) menuBar?.setTitle(statusText) menuBar?.statusItem.length = statusText == nil @@ -98,7 +127,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } ?? "No Item Available" openItemMenuItem?.isEnabled = item != nil snoozeMenuItem?.isEnabled = item != nil - dismissMenuItem?.isEnabled = item != nil + hideMenuItem?.isEnabled = item != nil } private func accessibilityDescription(for presentation: MeanwhilePresentation) -> String { @@ -140,26 +169,17 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { snoozeMenuItem = snooze menu.addItem(snooze) - let dismiss = NSMenuItem( - title: "Dismiss", - action: #selector(dismissCurrentItem), + let hide = NSMenuItem( + title: "Hide Until It Changes", + action: #selector(hideCurrentItemUntilChange), keyEquivalent: "" ) - dismiss.target = self - dismiss.isEnabled = false - dismissMenuItem = dismiss - menu.addItem(dismiss) + hide.target = self + hide.isEnabled = false + hideMenuItem = hide + menu.addItem(hide) menu.addItem(.separator()) - let integrations = NSMenuItem( - title: "Install Agent Integrations…", - action: #selector(installAgentIntegrations), - keyEquivalent: "" - ) - integrations.image = NSImage(systemSymbolName: "link", accessibilityDescription: nil) - integrations.target = self - menu.addItem(integrations) - let settings = NSMenuItem( title: "Settings…", action: #selector(openSettings), @@ -189,41 +209,49 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } } - @objc private func snoozeCurrentItem() { - runtime?.snoozeCurrent() + private func registerHotKeyFromPreferences() { + hotKey = nil + settingsModel.setHotKeyRegistrationError(nil) + guard let hotKeyConfiguration = hotKeyPreferences.hotKey else { return } + do { + hotKey = try GlobalHotKey(configuration: hotKeyConfiguration) { [weak self] in + self?.openCurrentItem() + } + } catch { + settingsModel.setHotKeyRegistrationError(error.localizedDescription) + } } - @objc private func dismissCurrentItem() { - runtime?.dismissCurrent() + @objc private func snoozeCurrentItem() { + if let item = currentItem { + recentSignalStore.record( + RecentSignal( + kind: .snoozed, + title: "Snoozed for 15 minutes", + detail: item.title + ) + ) + } + runtime?.snoozeCurrent() } - @objc private func installAgentIntegrations() { - do { - let result = try AgentIntegrationInstaller(helperURL: helperURL()).install() - UserDefaults.standard.set(true, forKey: DefaultsKey.integrationPrompted) - let alert = NSAlert() - alert.messageText = "Agent integrations installed" - alert.informativeText = result.claudeStatuslineConflict - ? "Claude and Codex hooks are installed. Your existing Claude status line was preserved; add the MeanwhileHook statusline command manually if you want Meanwhile there too. In Codex, open /hooks once to review and trust the new hooks." - : "Claude and Codex hooks and the Claude status line are installed. In Codex, open /hooks once to review and trust the new hooks." - alert.runModal() - } catch { - let alert = NSAlert(error: error) - alert.runModal() + @objc private func hideCurrentItemUntilChange() { + if let item = currentItem { + recentSignalStore.record( + RecentSignal( + kind: .hiddenUntilChange, + title: "Hidden until it changes", + detail: item.title + ) + ) } + runtime?.dismissCurrent() } - private func offerAgentIntegrationIfNeeded() { + private func showFirstRunSettingsIfNeeded() { guard !UserDefaults.standard.bool(forKey: DefaultsKey.integrationPrompted) else { return } UserDefaults.standard.set(true, forKey: DefaultsKey.integrationPrompted) - let alert = NSAlert() - alert.messageText = "Connect Claude Code and Codex?" - alert.informativeText = "Meanwhile uses local lifecycle hooks to know when an agent is thinking or needs you. Existing hook settings are preserved." - alert.addButton(withTitle: "Install Integrations") - alert.addButton(withTitle: "Later") - if alert.runModal() == .alertFirstButtonReturn { - installAgentIntegrations() - } + openSettings() } private func helperURL() -> URL { @@ -236,9 +264,39 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } @objc private func openSettings() { + settingsModel.refreshStatus() settingsWindowController.show(model: settingsModel) } + private func recordPresentationIfNeeded(_ item: WorkItem?) { + guard item?.id != lastRecordedItemID else { return } + lastRecordedItemID = item?.id + guard let item else { return } + + let signal: RecentSignal + switch item.kind { + case .needsYou: + signal = RecentSignal( + kind: .agentNeedsYou, + title: item.title, + detail: item.detail + ) + case .review: + signal = RecentSignal( + kind: .reviewSurfaced, + title: "\(item.title) surfaced", + detail: item.detail + ) + case .failingCI: + signal = RecentSignal( + kind: .ciFailed, + title: "CI failure surfaced", + detail: item.detail + ) + } + recentSignalStore.record(signal) + } + @objc private func quitMeanwhile() { NSApplication.shared.terminate(nil) } diff --git a/Sources/Meanwhile/RecentSignalsSection.swift b/Sources/Meanwhile/RecentSignalsSection.swift new file mode 100644 index 0000000..3096a45 --- /dev/null +++ b/Sources/Meanwhile/RecentSignalsSection.swift @@ -0,0 +1,78 @@ +import MeanwhileCore +import SwiftUI + +struct RecentSignalsSection: View { + let signals: [RecentSignal] + let now: Date + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if signals.isEmpty { + HStack(spacing: 10) { + Image(systemName: "waveform.path") + .foregroundStyle(.secondary) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text("No signals recorded yet") + .font(.callout.weight(.medium)) + Text("Agent, review, CI, snooze, and hide activity will appear here.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } else { + ForEach(Array(signals.prefix(5).enumerated()), id: \.element.id) { index, signal in + if index > 0 { Divider().padding(.leading, 28) } + RecentSignalRow(signal: signal, now: now) + } + } + } + } +} + +private struct RecentSignalRow: View { + let signal: RecentSignal + let now: Date + + var body: some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: symbol) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 2) { + Text(signal.title) + .font(.callout.weight(.medium)) + Text(signal.detail) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer(minLength: 12) + Text(relativeDateString(signal.date, relativeTo: now)) + .font(.caption2) + .foregroundStyle(.tertiary) + .monospacedDigit() + } + .accessibilityElement(children: .combine) + } + + private var symbol: String { + switch signal.kind { + case .agentNeedsYou: return "exclamationmark.bubble.fill" + case .reviewSurfaced: return "arrow.triangle.pull" + case .ciFailed: return "xmark.circle.fill" + case .snoozed: return "clock.fill" + case .hiddenUntilChange: return "eye.slash.fill" + case .integrationsInstalled: return "checkmark.circle.fill" + } + } + + private var tint: Color { + switch signal.kind { + case .agentNeedsYou, .ciFailed: return .red + case .reviewSurfaced, .snoozed: return .orange + case .hiddenUntilChange: return .secondary + case .integrationsInstalled: return .green + } + } +} diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift index df13fb6..f565281 100644 --- a/Sources/Meanwhile/RepositorySettingsModel.swift +++ b/Sources/Meanwhile/RepositorySettingsModel.swift @@ -8,23 +8,95 @@ final class RepositorySettingsModel: ObservableObject { @Published private(set) var availableRepositories: [String] = [] @Published private(set) var isLoading = false @Published private(set) var errorMessage: String? + @Published private(set) var hotKey: HotKeyConfiguration? + @Published private(set) var hotKeyRegistrationError: String? + @Published private(set) var integrationHealth = AgentIntegrationHealth( + state: .notInstalled, + claudeHooksInstalled: false, + codexHooksInstalled: false, + claudeStatuslineConflict: false + ) + @Published private(set) var integrationHealthError: String? + @Published private(set) var integrationActionMessage: String? + @Published private(set) var integrationActionIsError = false + @Published private(set) var isInstallingIntegrations = false + @Published private(set) var isCheckingHealth = false + @Published private(set) var githubAuthenticationStatus: GitHubAuthenticationStatus = .notAuthenticated + @Published private(set) var lastAgentEvent: AgentSessionState? + @Published private(set) var recentSignals: [RecentSignal] = [] + + var connectedRepositoryCount: Int { + includesAllRepositories ? availableRepositories.count : selectedRepositories.count + } + + var repositoryScopeDescription: String { + if includesAllRepositories { + return availableRepositories.isEmpty + ? "All accessible repositories" + : "All \(availableRepositories.count) accessible repositories" + } + + if selectedRepositories.isEmpty { + return "No repositories connected" + } + + return "\(selectedRepositories.count) selected repositories" + } + + var sourceHealthDescription: String { + if isLoading { + return "Refreshing repositories" + } + + if errorMessage != nil { + return "Refresh needs attention" + } + + if availableRepositories.isEmpty { + return "Waiting for GitHub" + } + + return "Reviews and failing CI ready" + } private let preferences: RepositoryPreferences + private let hotKeyPreferences: HotKeyPreferences private let catalog: GitHubRepositoryCatalog + private let authenticationChecker: GitHubAuthenticationChecker + private let integrationInstaller: AgentIntegrationInstaller + private let eventStore: AgentEventStore + private let recentSignalStore: RecentSignalStore private let selectionDidChange: () -> Void + private let hotKeyDidChange: (HotKeyConfiguration?) -> Void + private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void private var hasLoaded = false init( preferences: RepositoryPreferences, + hotKeyPreferences: HotKeyPreferences, catalog: GitHubRepositoryCatalog = GitHubRepositoryCatalog(), - selectionDidChange: @escaping () -> Void + authenticationChecker: GitHubAuthenticationChecker = GitHubAuthenticationChecker(), + integrationInstaller: AgentIntegrationInstaller, + eventStore: AgentEventStore, + recentSignalStore: RecentSignalStore, + selectionDidChange: @escaping () -> Void, + hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void, + integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void ) { self.preferences = preferences + self.hotKeyPreferences = hotKeyPreferences self.catalog = catalog + self.authenticationChecker = authenticationChecker + self.integrationInstaller = integrationInstaller + self.eventStore = eventStore + self.recentSignalStore = recentSignalStore self.selectionDidChange = selectionDidChange + self.hotKeyDidChange = hotKeyDidChange + self.integrationDidInstall = integrationDidInstall let snapshot = preferences.snapshot includesAllRepositories = snapshot.includesAllRepositories selectedRepositories = snapshot.selectedRepositories + hotKey = hotKeyPreferences.hotKey } func loadRepositories(force: Bool = false) { @@ -53,6 +125,42 @@ final class RepositorySettingsModel: ObservableObject { } } + func refreshStatus() { + guard !isCheckingHealth else { return } + isCheckingHealth = true + let authenticationChecker = self.authenticationChecker + let integrationInstaller = self.integrationInstaller + let eventStore = self.eventStore + let recentSignalStore = self.recentSignalStore + + Task.detached { + let healthResult = Result { try integrationInstaller.health() } + let authenticationStatus = authenticationChecker.status() + let latestEvent = eventStore.latestEvent() + let signals = recentSignalStore.signals + await MainActor.run { [weak self] in + guard let self else { return } + switch healthResult { + case .success(let health): + integrationHealth = health + integrationHealthError = nil + case .failure(let error): + integrationHealth = AgentIntegrationHealth( + state: .needsReview, + claudeHooksInstalled: false, + codexHooksInstalled: false, + claudeStatuslineConflict: false + ) + integrationHealthError = error.localizedDescription + } + githubAuthenticationStatus = authenticationStatus + lastAgentEvent = latestEvent + recentSignals = signals + isCheckingHealth = false + } + } + } + func setIncludesAllRepositories(_ includesAll: Bool) { if !includesAll, selectedRepositories.isEmpty { selectedRepositories = Set(availableRepositories) @@ -76,4 +184,48 @@ final class RepositorySettingsModel: ObservableObject { preferences.setRepository(repository, isSelected: isSelected) selectionDidChange() } + + func setHotKey(_ hotKey: HotKeyConfiguration?) { + hotKeyPreferences.setHotKey(hotKey) + self.hotKey = hotKeyPreferences.hotKey + hotKeyDidChange(self.hotKey) + } + + func setHotKeyRegistrationError(_ message: String?) { + hotKeyRegistrationError = message + } + + func installIntegrations() { + guard !isInstallingIntegrations else { return } + isInstallingIntegrations = true + integrationActionMessage = nil + integrationActionIsError = false + let installer = integrationInstaller + Task.detached { + let result = Result { try installer.install() } + await MainActor.run { [weak self] in + guard let self else { return } + isInstallingIntegrations = false + switch result { + case .success(let installResult): + integrationDidInstall(installResult) + integrationHealth = AgentIntegrationHealth( + state: .installed, + claudeHooksInstalled: installResult.claudeHooksInstalled, + codexHooksInstalled: installResult.codexHooksInstalled, + claudeStatuslineConflict: installResult.claudeStatuslineConflict + ) + integrationHealthError = nil + integrationActionMessage = installResult.claudeStatuslineConflict + ? "Hooks installed. Your existing Claude status line was preserved." + : "Claude and Codex hooks are installed." + integrationActionIsError = false + refreshStatus() + case .failure(let error): + integrationActionMessage = error.localizedDescription + integrationActionIsError = true + } + } + } + } } diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift index 70f0794..b9185ff 100644 --- a/Sources/Meanwhile/RepositorySettingsView.swift +++ b/Sources/Meanwhile/RepositorySettingsView.swift @@ -1,8 +1,14 @@ +import MeanwhileCore import SwiftUI struct RepositorySettingsView: View { @ObservedObject var model: RepositorySettingsModel + @AppStorage("settings.section.statusItem.expanded") private var isStatusItemExpanded = true + @AppStorage("settings.section.connectionHealth.expanded") private var isConnectionHealthExpanded = true + @AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true + @AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true @State private var searchText = "" + @State private var now = Date() private var filteredRepositories: [String] { guard !searchText.isEmpty else { return model.availableRepositories } @@ -12,97 +18,86 @@ struct RepositorySettingsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 16) { - VStack(alignment: .leading, spacing: 4) { - Text("GitHub repositories") - .font(.title2.weight(.semibold)) - Text("Choose where Meanwhile can surface reviews and failing CI.") - .foregroundStyle(.secondary) - } - - Toggle( - "Include all accessible repositories", - isOn: Binding( - get: { model.includesAllRepositories }, - set: model.setIncludesAllRepositories + ScrollView { + VStack(alignment: .leading, spacing: 18) { + CollapsibleSettingsSection( + title: "Status item", + isExpanded: $isStatusItemExpanded + ) { + StatusItemSettingsSection( + repositoryScopeDescription: model.repositoryScopeDescription, + sourceHealthDescription: model.sourceHealthDescription, + sourceHasError: model.errorMessage != nil + ) + } + Divider() + ControlsSettingsSection( + hotKey: Binding(get: { model.hotKey }, set: model.setHotKey), + hotKeyRegistrationError: model.hotKeyRegistrationError, + setHotKeyRegistrationError: model.setHotKeyRegistrationError, + isInstallingIntegrations: model.isInstallingIntegrations, + integrationActionMessage: model.integrationActionMessage, + integrationActionIsError: model.integrationActionIsError, + installIntegrations: model.installIntegrations ) - ) - .toggleStyle(.checkbox) - - Divider() - - HStack { - TextField("Filter repositories", text: $searchText) - .textFieldStyle(.roundedBorder) - Button { - model.loadRepositories(force: true) - } label: { - Image(systemName: "arrow.clockwise") + Divider() + CollapsibleSettingsSection( + title: "Connection health", + isExpanded: $isConnectionHealthExpanded, + showsProgress: model.isCheckingHealth + ) { + ConnectionHealthSection( + integrationHealth: model.integrationHealth, + integrationHealthError: model.integrationHealthError, + lastAgentEvent: model.lastAgentEvent, + githubAuthenticationStatus: model.githubAuthenticationStatus, + now: now + ) + } + Divider() + CollapsibleSettingsSection( + title: "Repository sources", + trailing: model.availableRepositories.isEmpty + ? nil + : "\(filteredRepositories.count) shown", + isExpanded: $isRepositorySourcesExpanded + ) { + RepositorySourcesSection( + searchText: $searchText, + includesAllRepositories: Binding( + get: { model.includesAllRepositories }, + set: model.setIncludesAllRepositories + ), + availableRepositories: model.availableRepositories, + filteredRepositories: filteredRepositories, + isLoading: model.isLoading, + errorMessage: model.errorMessage, + refresh: { model.loadRepositories(force: true) }, + isSelected: model.isSelected, + setRepository: model.setRepository + ) + } + Divider() + CollapsibleSettingsSection( + title: "Recent signals", + isExpanded: $isRecentSignalsExpanded + ) { + RecentSignalsSection(signals: model.recentSignals, now: now) } - .help("Refresh repositories") - .disabled(model.isLoading) - } - - repositoryList - - if !model.includesAllRepositories && model.selectedRepositories.isEmpty { - Label("No repositories are connected.", systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.orange) } + .padding(.horizontal, 24) + .padding(.vertical, 20) } - .padding(20) - .frame(width: 460, height: 420) + .frame(width: 620, height: 680) + .background(Color(nsColor: .windowBackgroundColor)) .task { model.loadRepositories() - } - } - - @ViewBuilder - private var repositoryList: some View { - if model.isLoading && model.availableRepositories.isEmpty { - VStack(spacing: 8) { - ProgressView() - Text("Loading repositories from GitHub…") - .font(.caption) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let errorMessage = model.errorMessage, - model.availableRepositories.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Couldn’t load repositories") - .font(.headline) - Text(errorMessage) - .font(.caption) - .foregroundStyle(.secondary) - Button("Try Again") { - model.loadRepositories(force: true) - } - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 9) { - ForEach(filteredRepositories, id: \.self) { repository in - Toggle( - repository, - isOn: Binding( - get: { model.isSelected(repository) }, - set: { model.setRepository(repository, isSelected: $0) } - ) - ) - .toggleStyle(.checkbox) - .disabled(model.includesAllRepositories) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .overlay { - if filteredRepositories.isEmpty { - Text("No matching repositories") - .foregroundStyle(.secondary) - } + model.refreshStatus() + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 30_000_000_000) + guard !Task.isCancelled else { break } + now = Date() + model.refreshStatus() } } } diff --git a/Sources/Meanwhile/RepositorySourcesSection.swift b/Sources/Meanwhile/RepositorySourcesSection.swift new file mode 100644 index 0000000..73d8832 --- /dev/null +++ b/Sources/Meanwhile/RepositorySourcesSection.swift @@ -0,0 +1,116 @@ +import SwiftUI + +struct RepositorySourcesSection: View { + @Binding var searchText: String + @Binding var includesAllRepositories: Bool + let availableRepositories: [String] + let filteredRepositories: [String] + let isLoading: Bool + let errorMessage: String? + let refresh: () -> Void + let isSelected: (String) -> Bool + let setRepository: (String, Bool) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 11) { + Toggle("Include all accessible repositories", isOn: $includesAllRepositories) + .toggleStyle(.checkbox) + + Text("Turn this off to surface review and CI work only from selected repositories.") + .font(.caption) + .foregroundStyle(.secondary) + + HStack(spacing: 8) { + TextField("Filter repositories", text: $searchText) + .textFieldStyle(.roundedBorder) + Button(action: refresh) { + Image(systemName: "arrow.clockwise") + } + .help("Refresh repositories") + .disabled(isLoading) + } + + repositoryList + } + } + + @ViewBuilder + private var repositoryList: some View { + if isLoading && availableRepositories.isEmpty { + VStack(spacing: 8) { + ProgressView() + Text("Loading repositories from GitHub") + .font(.callout.weight(.medium)) + Text("Meanwhile uses the GitHub CLI session already on this Mac.") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, minHeight: 150) + } else if let errorMessage, availableRepositories.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Couldn’t load repositories") + .font(.headline) + Text(errorMessage) + .font(.caption) + .foregroundStyle(.secondary) + Button("Try Again", action: refresh) + } + .frame(maxWidth: .infinity, minHeight: 150, alignment: .topLeading) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(filteredRepositories, id: \.self) { repository in + RepositoryRow( + repository: repository, + includesAllRepositories: includesAllRepositories, + isSelected: Binding( + get: { isSelected(repository) }, + set: { setRepository(repository, $0) } + ) + ) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + } + .frame(maxWidth: .infinity, minHeight: 150, maxHeight: 210) + .background(.quaternary.opacity(0.16), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .overlay { + if filteredRepositories.isEmpty { + ContentUnavailableView( + "No matching repositories", + systemImage: "magnifyingglass", + description: Text("Try a different filter or refresh GitHub.") + ) + .foregroundStyle(.secondary) + } + } + } + } +} + +private struct RepositoryRow: View { + let repository: String + let includesAllRepositories: Bool + @Binding var isSelected: Bool + + var body: some View { + if includesAllRepositories { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .font(.callout) + .foregroundStyle(.secondary) + Text(repository) + .foregroundStyle(.primary) + Spacer() + } + .font(.callout) + .padding(.vertical, 1) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(repository), included") + } else { + Toggle(repository, isOn: $isSelected) + .toggleStyle(.checkbox) + } + } +} diff --git a/Sources/Meanwhile/SettingsSupport.swift b/Sources/Meanwhile/SettingsSupport.swift new file mode 100644 index 0000000..323e636 --- /dev/null +++ b/Sources/Meanwhile/SettingsSupport.swift @@ -0,0 +1,76 @@ +import MeanwhileCore +import SwiftUI + +struct SettingsSectionHeader: View { + let title: String + var trailing: String? = nil + var showsProgress = false + + var body: some View { + HStack(alignment: .firstTextBaseline) { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + Spacer() + if let trailing { + Text(trailing) + .font(.caption2) + .foregroundStyle(.tertiary) + } + if showsProgress { + ProgressView() + .controlSize(.small) + } + } + } +} + +struct CollapsibleSettingsSection: View { + let title: String + var trailing: String? = nil + @Binding var isExpanded: Bool + var showsProgress = false + private let content: Content + + init( + title: String, + trailing: String? = nil, + isExpanded: Binding, + showsProgress: Bool = false, + @ViewBuilder content: () -> Content + ) { + self.title = title + self.trailing = trailing + _isExpanded = isExpanded + self.showsProgress = showsProgress + self.content = content() + } + + var body: some View { + DisclosureGroup(isExpanded: $isExpanded) { + content + .padding(.top, 10) + } label: { + SettingsSectionHeader( + title: title, + trailing: trailing, + showsProgress: showsProgress + ) + } + } +} + +func providerDisplayName(_ provider: AgentProvider) -> String { + switch provider { + case .claude: return "Claude" + case .codex: return "Codex" + case .unknown: return "Agent" + } +} + +func relativeDateString(_ date: Date, relativeTo reference: Date) -> String { + if date >= reference { return "now" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: date, relativeTo: reference) +} diff --git a/Sources/Meanwhile/SettingsWindowController.swift b/Sources/Meanwhile/SettingsWindowController.swift index 30c44f0..6932997 100644 --- a/Sources/Meanwhile/SettingsWindowController.swift +++ b/Sources/Meanwhile/SettingsWindowController.swift @@ -17,12 +17,13 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { rootView: RepositorySettingsView(model: model) ) let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 460, height: 420), + contentRect: NSRect(x: 0, y: 0, width: 620, height: 680), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false ) window.title = "Meanwhile Settings" + window.minSize = NSSize(width: 580, height: 560) window.contentViewController = hostingController window.isReleasedWhenClosed = false window.delegate = self diff --git a/Sources/Meanwhile/ShortcutRecorder.swift b/Sources/Meanwhile/ShortcutRecorder.swift new file mode 100644 index 0000000..3c7653f --- /dev/null +++ b/Sources/Meanwhile/ShortcutRecorder.swift @@ -0,0 +1,186 @@ +import AppKit +import MeanwhileCore +import SwiftUI + +struct ShortcutRecorder: NSViewRepresentable { + @Binding var shortcut: HotKeyConfiguration? + let validationMessage: (String?) -> Void + + func makeCoordinator() -> Coordinator { + Coordinator(shortcut: $shortcut, validationMessage: validationMessage) + } + + func makeNSView(context: Context) -> ShortcutRecorderButton { + let button = ShortcutRecorderButton() + button.captureHandler = { configuration in + context.coordinator.shortcut.wrappedValue = configuration + } + button.validationHandler = context.coordinator.validationMessage + button.configuration = shortcut + return button + } + + func updateNSView(_ button: ShortcutRecorderButton, context: Context) { + context.coordinator.shortcut = $shortcut + context.coordinator.validationMessage = validationMessage + button.captureHandler = { configuration in + context.coordinator.shortcut.wrappedValue = configuration + } + button.validationHandler = context.coordinator.validationMessage + button.configuration = shortcut + } + + final class Coordinator { + var shortcut: Binding + var validationMessage: (String?) -> Void + + init( + shortcut: Binding, + validationMessage: @escaping (String?) -> Void + ) { + self.shortcut = shortcut + self.validationMessage = validationMessage + } + } +} + +final class ShortcutRecorderButton: NSButton { + var captureHandler: ((HotKeyConfiguration?) -> Void)? + var validationHandler: ((String?) -> Void)? + var configuration: HotKeyConfiguration? { + didSet { + if !isRecording { updateTitle() } + } + } + + private var isRecording = false + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + setButtonType(.momentaryPushIn) + bezelStyle = .rounded + controlSize = .regular + alignment = .center + focusRingType = .default + target = self + action = #selector(beginRecording) + toolTip = "Record a global keyboard shortcut" + setAccessibilityLabel("Keyboard shortcut") + setAccessibilityHelp("Press a key with Command, Control, Option, or Shift. Press Delete to clear.") + updateTitle() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override var acceptsFirstResponder: Bool { true } + + override func becomeFirstResponder() -> Bool { + guard super.becomeFirstResponder() else { return false } + isRecording = true + title = "Type shortcut…" + validationHandler?(nil) + return true + } + + override func resignFirstResponder() -> Bool { + let resigned = super.resignFirstResponder() + if resigned { + isRecording = false + updateTitle() + } + return resigned + } + + override func keyDown(with event: NSEvent) { + let modifierFlags = event.modifierFlags.intersection([.command, .control, .option, .shift]) + if event.keyCode == 53, modifierFlags.isEmpty { + finishRecording() + return + } + if (event.keyCode == 51 || event.keyCode == 117), modifierFlags.isEmpty { + validationHandler?(nil) + captureHandler?(nil) + finishRecording() + return + } + + guard let key = Self.key(for: event) else { + validationHandler?("Use a letter, number, Space, Tab, Return, or Escape.") + NSSound.beep() + return + } + let modifiers = Self.modifiers(from: modifierFlags) + guard !modifiers.isEmpty else { + validationHandler?("Include at least one modifier key.") + NSSound.beep() + return + } + + let configuration = HotKeyConfiguration(key: key, modifiers: modifiers) + validationHandler?(nil) + captureHandler?(configuration) + finishRecording() + } + + @objc private func beginRecording() { + window?.makeFirstResponder(self) + } + + private func finishRecording() { + isRecording = false + window?.makeFirstResponder(nil) + updateTitle() + } + + private func updateTitle() { + title = configuration.map(Self.displayName) ?? "Click to record shortcut" + setAccessibilityValue(configuration.map(Self.displayName) ?? "Not set") + } + + private static func key(for event: NSEvent) -> String? { + switch event.keyCode { + case 36: return "return" + case 48: return "tab" + case 49: return "space" + case 53: return "escape" + default: + guard let characters = event.charactersIgnoringModifiers?.lowercased(), + characters.count == 1, + let scalar = characters.unicodeScalars.first else { + return nil + } + if ("a"..."z").contains(Character(String(scalar))) + || ("0"..."9").contains(Character(String(scalar))) { + return String(scalar) + } + return nil + } + } + + private static func modifiers(from flags: NSEvent.ModifierFlags) -> [HotKeyModifier] { + var modifiers: [HotKeyModifier] = [] + if flags.contains(.control) { modifiers.append(.control) } + if flags.contains(.option) { modifiers.append(.option) } + if flags.contains(.shift) { modifiers.append(.shift) } + if flags.contains(.command) { modifiers.append(.command) } + return modifiers + } + + private static func displayName(_ configuration: HotKeyConfiguration) -> String { + var value = "" + if configuration.modifiers.contains(.control) { value += "⌃" } + if configuration.modifiers.contains(.option) { value += "⌥" } + if configuration.modifiers.contains(.shift) { value += "⇧" } + if configuration.modifiers.contains(.command) { value += "⌘" } + switch configuration.key { + case "space": value += "Space" + case "return": value += "Return" + case "tab": value += "Tab" + case "escape": value += "Esc" + default: value += configuration.key.uppercased() + } + return value + } +} diff --git a/Sources/Meanwhile/StatusItemSettingsSection.swift b/Sources/Meanwhile/StatusItemSettingsSection.swift new file mode 100644 index 0000000..427b83a --- /dev/null +++ b/Sources/Meanwhile/StatusItemSettingsSection.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct StatusItemSettingsSection: View { + let repositoryScopeDescription: String + let sourceHealthDescription: String + let sourceHasError: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 11) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Watching") + .font(.caption) + .foregroundStyle(.secondary) + Text(repositoryScopeDescription) + .font(.callout.weight(.semibold)) + Spacer(minLength: 12) + Label( + sourceHealthDescription, + systemImage: sourceHasError + ? "exclamationmark.triangle.fill" + : "checkmark.circle.fill" + ) + .font(.caption.weight(.medium)) + .foregroundStyle(sourceHasError ? .orange : .green) + } + + Grid(alignment: .leading, horizontalSpacing: 14, verticalSpacing: 7) { + StatusLanguageRow( + systemImage: "exclamationmark.bubble.fill", + tint: .red, + label: "Needs you", + meaning: "An agent is blocked on you" + ) + StatusLanguageRow( + systemImage: "rectangle.stack.fill", + tint: .orange, + label: "#78", + meaning: "A review is ready while an agent works" + ) + StatusLanguageRow( + systemImage: "rectangle.stack.fill", + tint: .orange, + label: "CI!", + meaning: "Failing CI is ready while an agent works" + ) + StatusLanguageRow( + systemImage: "rectangle.stack", + tint: .secondary, + label: "Icon only", + meaning: "Nothing needs your attention" + ) + } + .accessibilityElement(children: .contain) + .accessibilityLabel("Menu bar status meanings") + } + } +} + +private struct StatusLanguageRow: View { + let systemImage: String + let tint: Color + let label: String + let meaning: String + + var body: some View { + GridRow { + HStack(spacing: 6) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: 16) + Text(label) + .font(.caption.weight(.semibold)) + } + .frame(width: 92, alignment: .leading) + + Text(meaning) + .font(.caption) + .foregroundStyle(.secondary) + } + } +} diff --git a/Sources/MeanwhileCore/AgentEventStore.swift b/Sources/MeanwhileCore/AgentEventStore.swift index 9c0ad01..22a8c11 100644 --- a/Sources/MeanwhileCore/AgentEventStore.swift +++ b/Sources/MeanwhileCore/AgentEventStore.swift @@ -2,6 +2,7 @@ import Foundation public final class AgentEventStore: @unchecked Sendable { public let directory: URL + public let latestEventURL: URL private let fileManager: FileManager private let lock = NSLock() private let encoder: JSONEncoder @@ -14,6 +15,8 @@ public final class AgentEventStore: @unchecked Sendable { self.fileManager = fileManager self.directory = directory ?? fileManager.homeDirectoryForCurrentUser .appendingPathComponent("Library/Application Support/Meanwhile/Sessions", isDirectory: true) + latestEventURL = self.directory + .appendingPathComponent(".latest-agent-event.json") encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 decoder = JSONDecoder() @@ -26,6 +29,7 @@ public final class AgentEventStore: @unchecked Sendable { try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) let data = try encoder.encode(session) try data.write(to: url(for: session), options: .atomic) + try data.write(to: latestEventURL, options: .atomic) } public func session(provider: AgentProvider, sessionID: String) -> AgentSessionState? { @@ -42,6 +46,12 @@ public final class AgentEventStore: @unchecked Sendable { return decode(url: url(for: prototype)) } + public func latestEvent() -> AgentSessionState? { + lock.lock() + defer { lock.unlock() } + return decode(url: latestEventURL) + } + public func sessions( now: Date = Date(), staleAfter: TimeInterval = 3_600, diff --git a/Sources/MeanwhileCore/AgentIntegrationInstaller.swift b/Sources/MeanwhileCore/AgentIntegrationInstaller.swift index e92a244..09d21ba 100644 --- a/Sources/MeanwhileCore/AgentIntegrationInstaller.swift +++ b/Sources/MeanwhileCore/AgentIntegrationInstaller.swift @@ -19,7 +19,32 @@ public struct AgentIntegrationInstallResult: Equatable, Sendable { } } -public final class AgentIntegrationInstaller { +public enum AgentIntegrationHealthState: Equatable, Sendable { + case installed + case needsReview + case notInstalled +} + +public struct AgentIntegrationHealth: Equatable, Sendable { + public var state: AgentIntegrationHealthState + public var claudeHooksInstalled: Bool + public var codexHooksInstalled: Bool + public var claudeStatuslineConflict: Bool + + public init( + state: AgentIntegrationHealthState, + claudeHooksInstalled: Bool, + codexHooksInstalled: Bool, + claudeStatuslineConflict: Bool + ) { + self.state = state + self.claudeHooksInstalled = claudeHooksInstalled + self.codexHooksInstalled = codexHooksInstalled + self.claudeStatuslineConflict = claudeStatuslineConflict + } +} + +public final class AgentIntegrationInstaller: @unchecked Sendable { public let claudeSettingsURL: URL public let codexHooksURL: URL private let helperURL: URL @@ -114,6 +139,35 @@ public final class AgentIntegrationInstaller { ) } + public func health() throws -> AgentIntegrationHealth { + let claude = try loadObject(at: claudeSettingsURL) + let codex = try loadObject(at: codexHooksURL) + let claudeInstalled = containsMeanwhileHook( + in: claude["hooks"], + provider: .claude + ) + let codexInstalled = containsMeanwhileHook( + in: codex["hooks"], + provider: .codex + ) + let statuslineCommand = (claude["statusLine"] as? [String: Any])?["command"] as? String + let statuslineConflict = statuslineCommand != nil + && statuslineCommand?.contains("MeanwhileHook") != true + + let state: AgentIntegrationHealthState + switch (claudeInstalled, codexInstalled) { + case (true, true): state = .installed + case (false, false): state = .notInstalled + default: state = .needsReview + } + return AgentIntegrationHealth( + state: state, + claudeHooksInstalled: claudeInstalled, + codexHooksInstalled: codexInstalled, + claudeStatuslineConflict: statuslineConflict + ) + } + private func command(provider: AgentProvider) -> String { "\(shellQuote(helperURL.path)) hook --provider \(provider.rawValue)" } @@ -148,6 +202,21 @@ public final class AgentIntegrationInstaller { hooks[event] = groups } + private func containsMeanwhileHook(in value: Any?, provider: AgentProvider) -> Bool { + if let dictionary = value as? [String: Any] { + if let command = dictionary["command"] as? String, + command.contains("MeanwhileHook"), + command.contains("hook --provider \(provider.rawValue)") { + return true + } + return dictionary.values.contains { containsMeanwhileHook(in: $0, provider: provider) } + } + if let array = value as? [Any] { + return array.contains { containsMeanwhileHook(in: $0, provider: provider) } + } + return false + } + private func loadObject(at url: URL) throws -> [String: Any] { guard fileManager.fileExists(atPath: url.path) else { return [:] } let value = try JSONSerialization.jsonObject(with: Data(contentsOf: url)) diff --git a/Sources/MeanwhileCore/GitHubRepositoryCatalog.swift b/Sources/MeanwhileCore/GitHubRepositoryCatalog.swift index 1f98ba4..7f6fc7f 100644 --- a/Sources/MeanwhileCore/GitHubRepositoryCatalog.swift +++ b/Sources/MeanwhileCore/GitHubRepositoryCatalog.swift @@ -44,3 +44,25 @@ public struct GitHubRepositoryCatalog: Sendable { ).sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending } } } + +public enum GitHubAuthenticationStatus: Equatable, Sendable { + case authenticated + case notAuthenticated +} + +public struct GitHubAuthenticationChecker: Sendable { + public static let command = "gh auth status" + + private let runner: any CommandRunning + + public init(runner: any CommandRunning = PeripheralCommandRunner()) { + self.runner = runner + } + + public func status() -> GitHubAuthenticationStatus { + guard let result = try? runner.run(Self.command, timeoutSeconds: 10) else { + return .notAuthenticated + } + return result.exitCode == 0 ? .authenticated : .notAuthenticated + } +} diff --git a/Sources/MeanwhileCore/HotKeyPreferences.swift b/Sources/MeanwhileCore/HotKeyPreferences.swift new file mode 100644 index 0000000..a7ae075 --- /dev/null +++ b/Sources/MeanwhileCore/HotKeyPreferences.swift @@ -0,0 +1,55 @@ +import Foundation + +public final class HotKeyPreferences: @unchecked Sendable { + private enum Key { + static let configured = "Meanwhile.hotKey.configured" + static let key = "Meanwhile.hotKey.key" + static let modifiers = "Meanwhile.hotKey.modifiers" + } + + private let lock = NSLock() + private let defaults: UserDefaults + private let defaultHotKey: HotKeyConfiguration? + + public init( + defaults: UserDefaults = .standard, + defaultHotKey: HotKeyConfiguration? = nil + ) { + self.defaults = defaults + self.defaultHotKey = defaultHotKey + } + + public var hotKey: HotKeyConfiguration? { + lock.lock() + defer { lock.unlock() } + + guard defaults.object(forKey: Key.configured) != nil else { + return defaultHotKey + } + + guard defaults.bool(forKey: Key.configured), + let key = defaults.string(forKey: Key.key) else { + return nil + } + + let modifiers = (defaults.stringArray(forKey: Key.modifiers) ?? []) + .compactMap(HotKeyModifier.init(rawValue:)) + return HotKeyConfiguration(key: key, modifiers: modifiers).normalized + } + + public func setHotKey(_ hotKey: HotKeyConfiguration?) { + lock.lock() + defer { lock.unlock() } + + guard let hotKey = hotKey?.normalized else { + defaults.set(false, forKey: Key.configured) + defaults.removeObject(forKey: Key.key) + defaults.removeObject(forKey: Key.modifiers) + return + } + + defaults.set(true, forKey: Key.configured) + defaults.set(hotKey.key, forKey: Key.key) + defaults.set(hotKey.modifiers.map(\.rawValue), forKey: Key.modifiers) + } +} diff --git a/Sources/MeanwhileCore/MeanwhileConfiguration.swift b/Sources/MeanwhileCore/MeanwhileConfiguration.swift index 2fc82dc..1416cc2 100644 --- a/Sources/MeanwhileCore/MeanwhileConfiguration.swift +++ b/Sources/MeanwhileCore/MeanwhileConfiguration.swift @@ -7,19 +7,22 @@ public struct MeanwhileConfiguration: Codable, Equatable, Sendable { public var activeSessionStaleSeconds: TimeInterval public var enableReviews: Bool public var enableFailingCI: Bool + public var hotKey: HotKeyConfiguration? public init( snoozeSeconds: TimeInterval = 900, sessionStaleSeconds: TimeInterval = 3_600, activeSessionStaleSeconds: TimeInterval = 86_400, enableReviews: Bool = true, - enableFailingCI: Bool = true + enableFailingCI: Bool = true, + hotKey: HotKeyConfiguration? = nil ) { self.snoozeSeconds = Self.valid(snoozeSeconds, fallback: 900) self.sessionStaleSeconds = Self.valid(sessionStaleSeconds, fallback: 3_600) self.activeSessionStaleSeconds = Self.valid(activeSessionStaleSeconds, fallback: 86_400) self.enableReviews = enableReviews self.enableFailingCI = enableFailingCI + self.hotKey = hotKey?.normalized } public init(from decoder: Decoder) throws { @@ -29,7 +32,8 @@ public struct MeanwhileConfiguration: Codable, Equatable, Sendable { sessionStaleSeconds: try values.decodeIfPresent(TimeInterval.self, forKey: .sessionStaleSeconds) ?? 3_600, activeSessionStaleSeconds: try values.decodeIfPresent(TimeInterval.self, forKey: .activeSessionStaleSeconds) ?? 86_400, enableReviews: try values.decodeIfPresent(Bool.self, forKey: .enableReviews) ?? true, - enableFailingCI: try values.decodeIfPresent(Bool.self, forKey: .enableFailingCI) ?? true + enableFailingCI: try values.decodeIfPresent(Bool.self, forKey: .enableFailingCI) ?? true, + hotKey: try values.decodeIfPresent(HotKeyConfiguration.self, forKey: .hotKey) ) } @@ -41,3 +45,31 @@ public struct MeanwhileConfiguration: Codable, Equatable, Sendable { value.isFinite && value > 0 ? value : fallback } } + +public struct HotKeyConfiguration: Codable, Equatable, Sendable { + public var key: String + public var modifiers: [HotKeyModifier] + + public init(key: String, modifiers: [HotKeyModifier]) { + self.key = key + self.modifiers = modifiers + } + + public var normalized: HotKeyConfiguration? { + let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let normalizedModifiers = Array(Set(modifiers)).sorted() + guard !normalizedKey.isEmpty, !normalizedModifiers.isEmpty else { return nil } + return HotKeyConfiguration(key: normalizedKey, modifiers: normalizedModifiers) + } +} + +public enum HotKeyModifier: String, Codable, Comparable, Sendable { + case command + case control + case option + case shift + + public static func < (lhs: HotKeyModifier, rhs: HotKeyModifier) -> Bool { + lhs.rawValue < rhs.rawValue + } +} diff --git a/Sources/MeanwhileCore/RecentSignalStore.swift b/Sources/MeanwhileCore/RecentSignalStore.swift new file mode 100644 index 0000000..1848fb3 --- /dev/null +++ b/Sources/MeanwhileCore/RecentSignalStore.swift @@ -0,0 +1,84 @@ +import Foundation + +public enum RecentSignalKind: String, Codable, Sendable { + case agentNeedsYou + case reviewSurfaced + case ciFailed + case snoozed + case hiddenUntilChange + case integrationsInstalled +} + +public struct RecentSignal: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var kind: RecentSignalKind + public var title: String + public var detail: String + public var date: Date + + public init( + id: UUID = UUID(), + kind: RecentSignalKind, + title: String, + detail: String, + date: Date = Date() + ) { + self.id = id + self.kind = kind + self.title = title + self.detail = detail + self.date = date + } +} + +public final class RecentSignalStore: @unchecked Sendable { + private let defaults: UserDefaults + private let key: String + private let limit: Int + private let lock = NSLock() + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init( + defaults: UserDefaults = .standard, + key: String = "Meanwhile.recentSignals", + limit: Int = 8 + ) { + self.defaults = defaults + self.key = key + self.limit = max(1, limit) + } + + public var signals: [RecentSignal] { + lock.lock() + defer { lock.unlock() } + return load() + } + + public func record(_ signal: RecentSignal) { + lock.lock() + defer { lock.unlock() } + var current = load() + if let latest = current.first, + latest.kind == signal.kind, + latest.title == signal.title, + latest.detail == signal.detail, + signal.date.timeIntervalSince(latest.date) < 60 { + return + } + current.insert(signal, at: 0) + if current.count > limit { + current.removeLast(current.count - limit) + } + guard let data = try? encoder.encode(current) else { return } + defaults.set(data, forKey: key) + } + + private func load() -> [RecentSignal] { + guard let data = defaults.data(forKey: key), + let values = try? decoder.decode([RecentSignal].self, from: data) else { + return [] + } + return values.sorted { $0.date > $1.date } + } +} diff --git a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift index 2a8105b..f9076f0 100644 --- a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift +++ b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift @@ -71,6 +71,7 @@ final class AgentEventIntegrationTests: XCTestCase { ), [] ) + XCTAssertEqual(store.latestEvent(), session) } func testNotificationPermissionPromptMapsToNeedsYou() throws { diff --git a/Tests/MeanwhileCoreTests/AgentIntegrationInstallerTests.swift b/Tests/MeanwhileCoreTests/AgentIntegrationInstallerTests.swift index 43ba8b4..26d8c0e 100644 --- a/Tests/MeanwhileCoreTests/AgentIntegrationInstallerTests.swift +++ b/Tests/MeanwhileCoreTests/AgentIntegrationInstallerTests.swift @@ -20,6 +20,8 @@ final class AgentIntegrationInstallerTests: XCTestCase { _ = try installer.install() _ = try installer.install() + XCTAssertEqual(try installer.health().state, .installed) + let claude = try object(installer.claudeSettingsURL) XCTAssertEqual(claude["theme"] as? String, "dark") let hooks = try XCTUnwrap(claude["hooks"] as? [String: Any]) @@ -78,10 +80,37 @@ final class AgentIntegrationInstallerTests: XCTestCase { ) let result = try installer.install() XCTAssertTrue(result.claudeStatuslineConflict) + XCTAssertTrue(try installer.health().claudeStatuslineConflict) let claude = try object(installer.claudeSettingsURL) XCTAssertEqual((claude["statusLine"] as? [String: Any])?["command"] as? String, "my-status") } + func testHealthDistinguishesMissingAndPartialInstallations() throws { + let home = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: home) } + let installer = AgentIntegrationInstaller( + helperURL: URL(fileURLWithPath: "/tmp/MeanwhileHook"), + homeDirectory: home, + environment: [:] + ) + XCTAssertEqual(try installer.health().state, .notInstalled) + + try FileManager.default.createDirectory( + at: installer.claudeSettingsURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + let partial = """ + {"hooks":{"Stop":[{"hooks":[{"command":"'/tmp/MeanwhileHook' hook --provider claude"}]}]}} + """ + try Data(partial.utf8).write(to: installer.claudeSettingsURL) + + let health = try installer.health() + XCTAssertEqual(health.state, .needsReview) + XCTAssertTrue(health.claudeHooksInstalled) + XCTAssertFalse(health.codexHooksInstalled) + } + func testHonorsClaudeAndCodexConfigurationDirectories() throws { let home = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true) diff --git a/Tests/MeanwhileCoreTests/GitHubRepositoryCatalogTests.swift b/Tests/MeanwhileCoreTests/GitHubRepositoryCatalogTests.swift index e59a1e1..00c134f 100644 --- a/Tests/MeanwhileCoreTests/GitHubRepositoryCatalogTests.swift +++ b/Tests/MeanwhileCoreTests/GitHubRepositoryCatalogTests.swift @@ -25,6 +25,25 @@ final class GitHubRepositoryCatalogTests: XCTestCase { ["Acme/widgets", "zeta/tools"] ) } + + func testAuthenticationCheckerReflectsGitHubCLIExitStatus() { + XCTAssertEqual( + GitHubAuthenticationChecker( + runner: CatalogCommandRunner( + result: ShellCommandResult(exitCode: 0, stdout: "", stderr: "") + ) + ).status(), + .authenticated + ) + XCTAssertEqual( + GitHubAuthenticationChecker( + runner: CatalogCommandRunner( + result: ShellCommandResult(exitCode: 1, stdout: "", stderr: "login required") + ) + ).status(), + .notAuthenticated + ) + } } private struct CatalogCommandRunner: CommandRunning { diff --git a/Tests/MeanwhileCoreTests/MeanwhileConfigurationTests.swift b/Tests/MeanwhileCoreTests/MeanwhileConfigurationTests.swift index 58f2ffa..974a30c 100644 --- a/Tests/MeanwhileCoreTests/MeanwhileConfigurationTests.swift +++ b/Tests/MeanwhileCoreTests/MeanwhileConfigurationTests.swift @@ -13,5 +13,59 @@ final class MeanwhileConfigurationTests: XCTestCase { XCTAssertEqual(config.activeSessionStaleSeconds, 86_400) XCTAssertTrue(config.enableReviews) XCTAssertFalse(config.enableFailingCI) + XCTAssertNil(config.hotKey) + } + + func testDecodesAndNormalizesHotKeyConfiguration() throws { + let config = try JSONDecoder().decode( + MeanwhileConfiguration.self, + from: Data(""" + { + "hotKey": { + "key": " Space ", + "modifiers": ["option", "control", "option"] + } + } + """.utf8) + ) + + XCTAssertEqual( + config.hotKey, + HotKeyConfiguration(key: "space", modifiers: [.control, .option]) + ) + } + + func testIgnoresHotKeyWithoutModifiers() throws { + let config = try JSONDecoder().decode( + MeanwhileConfiguration.self, + from: Data(""" + { + "hotKey": { + "key": "m", + "modifiers": [] + } + } + """.utf8) + ) + + XCTAssertNil(config.hotKey) + } + + func testHotKeyPreferencesUseConfigDefaultUntilUserChangesSetting() { + let suiteName = "MeanwhileTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + let defaultHotKey = HotKeyConfiguration(key: "space", modifiers: [.control, .option]) + let preferences = HotKeyPreferences(defaults: defaults, defaultHotKey: defaultHotKey) + + XCTAssertEqual(preferences.hotKey, defaultHotKey) + + preferences.setHotKey(HotKeyConfiguration(key: "m", modifiers: [.command])) + XCTAssertEqual( + preferences.hotKey, + HotKeyConfiguration(key: "m", modifiers: [.command]) + ) + + preferences.setHotKey(nil) + XCTAssertNil(preferences.hotKey) } } diff --git a/Tests/MeanwhileCoreTests/RecentSignalStoreTests.swift b/Tests/MeanwhileCoreTests/RecentSignalStoreTests.swift new file mode 100644 index 0000000..39a0faf --- /dev/null +++ b/Tests/MeanwhileCoreTests/RecentSignalStoreTests.swift @@ -0,0 +1,45 @@ +import Foundation +import XCTest +@testable import MeanwhileCore + +final class RecentSignalStoreTests: XCTestCase { + func testStoresNewestSignalsWithinLimit() throws { + let suiteName = "RecentSignalStoreTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = RecentSignalStore(defaults: defaults, key: "signals", limit: 2) + let start = Date(timeIntervalSince1970: 1_000) + + store.record(RecentSignal(kind: .reviewSurfaced, title: "Review #1", detail: "a/repo", date: start)) + store.record(RecentSignal(kind: .ciFailed, title: "CI failure", detail: "b/repo", date: start.addingTimeInterval(1))) + store.record(RecentSignal(kind: .agentNeedsYou, title: "Codex needs you", detail: "/tmp", date: start.addingTimeInterval(2))) + + XCTAssertEqual(store.signals.map(\.title), ["Codex needs you", "CI failure"]) + } + + func testCoalescesImmediateDuplicateSignals() throws { + let suiteName = "RecentSignalStoreTests.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suiteName)) + defer { defaults.removePersistentDomain(forName: suiteName) } + let store = RecentSignalStore(defaults: defaults, key: "signals") + let start = Date(timeIntervalSince1970: 2_000) + let signal = RecentSignal( + kind: .reviewSurfaced, + title: "Review #78 surfaced", + detail: "acme/repo", + date: start + ) + + store.record(signal) + store.record( + RecentSignal( + kind: signal.kind, + title: signal.title, + detail: signal.detail, + date: start.addingTimeInterval(30) + ) + ) + + XCTAssertEqual(store.signals, [signal]) + } +} diff --git a/docs/v0.1.1-authored-settings.md b/docs/v0.1.1-authored-settings.md new file mode 100644 index 0000000..fdb6de6 --- /dev/null +++ b/docs/v0.1.1-authored-settings.md @@ -0,0 +1,59 @@ +# v0.1.1 authored settings brief + +## Direction + +Meanwhile should feel like a quiet attention instrument: precise, compact, and +native. The menu-bar chip is the signature element. It tells the user when the +wait has turned into an action, and it should otherwise stay out of the way. + +## Product model + +- The menu bar is for immediate status and action. +- The settings window is for status language, connection health, controls, + repository scope, and lightweight recovery. +- Long explanations belong in settings, not in the menu. +- Repository filtering is a source rule, not a project browser. + +## State language + +- `Needs you`: agent permission or prompt is waiting. +- `#78`: pull request review waiting while an agent is thinking. +- `CI!`: failing CI waiting while an agent is thinking. +- Glyph only: no eligible wait-gated item. + +## v0.1.1 scope + +- Keep the native `NSStatusItem` menu. +- Preserve the compact menu commands. +- Make Settings teach `Needs you`, review, CI, and quiet menu-bar states. +- Open Settings on first launch instead of interrupting with an integration + alert. +- Report hook installation, the last agent event, and GitHub authentication. +- Record global shortcuts through one focused native control. +- Use **Hide Until It Changes** for the non-temporary disposition. +- Keep a bounded, local recent-signals list for trust and diagnosis. +- Let users collapse the status, health, repository, and recent-signal groups; + keep primary controls visible and remember disclosure choices. +- Improve loading, empty, and error states. +- Keep macOS 14 materials and controls; do not fake Liquid Glass. + +## Interaction contract + +- Clicking the menu-bar item opens the current item; right-clicking exposes + immediate actions and Settings. +- Snooze temporarily removes the current item. Hide Until It Changes suppresses + the same source item until it leaves the active source set. +- Shortcut recording accepts a modified letter, digit, Space, Tab, Return, or + Escape. Escape alone cancels recording and Delete clears it. +- Settings persists safe changes immediately. Integration and registration + failures remain visible in context. +- Information-heavy Settings groups use native disclosure controls and restore + their last expanded or collapsed state when Settings reopens. +- Health refreshes while Settings is open without polling GitHub continuously. + +## Signature element + +Trust is the authored signature: the menu-bar language, connection health, and +recent signals form one compact evidence trail from agent event to surfaced +action. Orange and red remain semantic attention colours; the rest of the +window uses adaptive system materials and native control density.