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.