diff --git a/App/Info.plist b/App/Info.plist index 57177ed..b151367 100644 --- a/App/Info.plist +++ b/App/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.1.1 + 0.1.2 CFBundleVersion - 2 + 3 LSMinimumSystemVersion 14.0 LSUIElement diff --git a/README.md b/README.md index 89f11db..0c36144 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.1 behavior +## v0.1.2 behavior - Uses Claude Code and Codex lifecycle hooks—not process or CPU guesses—to track each session as `thinking`, `needs-you`, or `idle`. @@ -21,6 +21,15 @@ dependencies. available. - Tracks parallel Claude and Codex sessions independently and filters both GitHub sources using the repository settings. +- Offers a native **Launch at Login** switch that reflects macOS's real Login + Items state, including approval when the system requires it. +- Shows the installed version and latest GitHub release in Settings without + downloading or installing anything automatically. +- Identifies agent sessions that may be stuck and lets you clear only those + sessions after confirmation. +- Copies a privacy-safe diagnostics report with coarse state, counts, and + timestamps—never repository names, paths, prompts, session IDs, or + credentials. The wait-gate is the invariant: ordinary items never appear while no agent is thinking. @@ -38,14 +47,17 @@ On first launch, Meanwhile opens Settings with integration health and one clear `~/.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. +use a separate 24-hour crash-safety expiry. Settings flags non-idle sessions +that have not received an event for the configured stale interval and provides +an explicit recovery action. If Claude already has a custom status line, +Meanwhile preserves it and reports the conflict instead of overwriting it. 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. +Meanwhile adds no telemetry. Launch at login uses macOS Service Management and +can be changed directly in Settings. Terminal focus uses the terminal metadata captured with the agent working directory. Terminal.app and iTerm sessions are selected by TTY; other supported @@ -78,11 +90,12 @@ fields retain their defaults: } ``` -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. +Repository selection, launch at login, the optional global shortcut, agent +integration installation, update visibility, diagnostics, and stuck-session +recovery 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 @@ -107,9 +120,9 @@ cask with: GITHUB_REPOSITORY="tcballard/Meanwhile" ./Scripts/release-unsigned.sh ``` -This produces `dist/Meanwhile-0.1.1-unsigned.zip` and `dist/meanwhile.rb`. +This produces `dist/Meanwhile-0.1.2-unsigned.zip` and `dist/meanwhile.rb`. Publish the archive only as a GitHub **pre-release** tagged -`v0.1.1-unsigned`. The generated cask identifies it as unsigned and tells users +`v0.1.2-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. @@ -125,7 +138,7 @@ GITHUB_REPOSITORY="owner/Meanwhile" \ ``` The script builds and signs the app in a temporary local directory, then -produces `dist/Meanwhile-0.1.1.zip` and `dist/meanwhile.rb`. It verifies the +produces `dist/Meanwhile-0.1.2.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/Sources/Meanwhile/AppSettingsSection.swift b/Sources/Meanwhile/AppSettingsSection.swift new file mode 100644 index 0000000..11b4b1a --- /dev/null +++ b/Sources/Meanwhile/AppSettingsSection.swift @@ -0,0 +1,127 @@ +import MeanwhileCore +import SwiftUI + +struct AppSettingsSection: View { + let appVersion: String + let buildVersion: String + let updateState: ReleaseUpdateState + let updateErrorMessage: String? + let checkForUpdates: () -> Void + let openLatestRelease: () -> Void + let diagnosticsCopyMessage: String? + let diagnosticsCopyIsError: Bool + let copyDiagnostics: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 11) { + HStack(alignment: .top, spacing: 10) { + Text("Software update") + .frame(width: 132, alignment: .leading) + + Image(systemName: updateSymbol) + .foregroundStyle(updateTint) + .frame(width: 18, height: 18) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(updateTitle) + .font(.callout.weight(.medium)) + Text(updateDetail) + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 8) + + if updateState.releaseURL != nil { + Button("View Release…", action: openLatestRelease) + } + Button("Check Again", action: checkForUpdates) + .disabled(updateState == .checking) + } + + if let updateErrorMessage { + SettingsInlineMessage( + updateErrorMessage, + systemImage: "exclamationmark.triangle.fill", + tint: .orange + ) + } + + HStack(alignment: .top, spacing: 10) { + Text("Support") + .frame(width: 132, alignment: .leading) + + Button(action: copyDiagnostics) { + if let diagnosticsCopyMessage { + Label( + diagnosticsCopyMessage, + systemImage: diagnosticsCopyIsError + ? "exclamationmark.triangle.fill" + : "checkmark.circle.fill" + ) + .foregroundStyle(diagnosticsCopyIsError ? .orange : .green) + } else { + Text("Copy Diagnostics") + } + } + .frame(minWidth: 116) + .accessibilityLabel(diagnosticsCopyMessage ?? "Copy Diagnostics") + .accessibilityHint("Copies a privacy-safe support summary to the clipboard.") + + Text("Copies a privacy-safe status summary without repository names, paths, prompts, session IDs, or credentials.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + private var updateTitle: String { + switch updateState { + case .notChecked: return "Version \(appVersion)" + case .checking: return "Checking for updates…" + case .current: return "Meanwhile is up to date" + case .updateAvailable(let version, _): return "\(version) is available" + case .developmentBuild: return "Development build" + case .unavailable: return "Update check unavailable" + } + } + + private var updateDetail: String { + switch updateState { + case .notChecked: + return "Installed version \(appVersion) (\(buildVersion))." + case .checking: + return "Comparing \(appVersion) with the latest GitHub release." + case .current(let version, _): + return "Installed \(appVersion) (\(buildVersion)); latest release \(version)." + case .updateAvailable: + return "Installed \(appVersion) (\(buildVersion)). Meanwhile never updates automatically." + case .developmentBuild(let version, _): + return "Installed \(appVersion) (\(buildVersion)); latest public release \(version)." + case .unavailable: + return "Installed \(appVersion) (\(buildVersion))." + } + } + + private var updateSymbol: String { + switch updateState { + case .current: return "checkmark.circle.fill" + case .updateAvailable: return "arrow.down.circle.fill" + case .developmentBuild: return "hammer.circle.fill" + case .unavailable: return "exclamationmark.triangle.fill" + case .notChecked, .checking: return "arrow.triangle.2.circlepath" + } + } + + private var updateTint: Color { + switch updateState { + case .current: return .green + case .updateAvailable: return .accentColor + case .developmentBuild: return .purple + case .unavailable: return .orange + case .notChecked, .checking: return .secondary + } + } +} diff --git a/Sources/Meanwhile/ConnectionHealthSection.swift b/Sources/Meanwhile/ConnectionHealthSection.swift index 683deee..a6f0b0d 100644 --- a/Sources/Meanwhile/ConnectionHealthSection.swift +++ b/Sources/Meanwhile/ConnectionHealthSection.swift @@ -6,7 +6,14 @@ struct ConnectionHealthSection: View { let integrationHealthError: String? let lastAgentEvent: AgentSessionState? let githubAuthenticationStatus: GitHubAuthenticationStatus + let sessionInspection: AgentSessionInspection + let sessionRecoveryMessage: String? + let sessionRecoveryIsError: Bool + let isClearingStuckSessions: Bool + let staleAfter: String + let clearStuckSessions: () -> Void let now: Date + @State private var isConfirmingClear = false var body: some View { VStack(alignment: .leading, spacing: 10) { @@ -17,6 +24,17 @@ struct ConnectionHealthSection: View { value: integrationHealthTitle, detail: integrationHealthDetail ) + HealthRow( + systemImage: sessionHealthSymbol, + tint: sessionHealthTint, + title: "Agent sessions", + value: sessionHealthTitle, + detail: sessionHealthDetail, + actionTitle: sessionInspection.stuckCount > 0 ? "Clear…" : nil, + actionAccessibilityLabel: "Clear stuck agent sessions", + action: { isConfirmingClear = true }, + actionDisabled: isClearingStuckSessions + ) HealthRow( systemImage: agentEventSymbol, tint: agentEventTint, @@ -32,6 +50,54 @@ struct ConnectionHealthSection: View { detail: githubHealthDetail ) } + .confirmationDialog( + "Clear sessions that may be stuck?", + isPresented: $isConfirmingClear + ) { + Button("Clear Stuck Sessions", role: .destructive) { + clearStuckSessions() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Meanwhile will recheck and forget only non-idle sessions with no event for more than \(staleAfter). The latest event summary is kept.") + } + } + + private var sessionHealthTitle: String { + if isClearingStuckSessions { return "Clearing stuck sessions…" } + if sessionInspection.stuckCount == 1 { return "1 session may be stuck" } + if sessionInspection.stuckCount > 1 { + return "\(sessionInspection.stuckCount) sessions may be stuck" + } + if sessionInspection.activeCount == 1 { return "1 active session" } + if sessionInspection.activeCount > 1 { + return "\(sessionInspection.activeCount) active sessions" + } + return "No active sessions" + } + + private var sessionHealthDetail: String { + if let sessionRecoveryMessage { return sessionRecoveryMessage } + if sessionInspection.stuckCount > 0 { + return "No agent event for more than \(staleAfter). Clear only if the agent has stopped." + } + if sessionInspection.activeCount > 0 { + return "No sessions currently look stuck." + } + return "Agent sessions will appear here while Claude or Codex is active." + } + + private var sessionHealthSymbol: String { + if sessionRecoveryIsError { return "exclamationmark.triangle.fill" } + if sessionInspection.stuckCount > 0 { return "clock.badge.exclamationmark.fill" } + if sessionInspection.activeCount > 0 { return "waveform.circle.fill" } + return "circle.dashed" + } + + private var sessionHealthTint: Color { + if sessionRecoveryIsError || sessionInspection.stuckCount > 0 { return .orange } + if sessionInspection.activeCount > 0 { return .green } + return .secondary } private var integrationHealthTitle: String { @@ -137,24 +203,36 @@ private struct HealthRow: View { let title: String let value: String let detail: String + var actionTitle: String? = nil + var actionAccessibilityLabel: String? = nil + var action: (() -> Void)? = nil + var actionDisabled = false 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) + HStack(alignment: .top, spacing: 10) { + Image(systemName: systemImage) + .foregroundStyle(tint) + .frame(width: 18, height: 18) + .accessibilityHidden(true) + 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) + if let actionTitle, let action { + Button(actionTitle, action: action) + .disabled(actionDisabled) + .accessibilityLabel(actionAccessibilityLabel ?? actionTitle) } - Spacer(minLength: 0) } - .accessibilityElement(children: .combine) } } diff --git a/Sources/Meanwhile/ControlsSettingsSection.swift b/Sources/Meanwhile/ControlsSettingsSection.swift index 1e4ef84..4e3a1c5 100644 --- a/Sources/Meanwhile/ControlsSettingsSection.swift +++ b/Sources/Meanwhile/ControlsSettingsSection.swift @@ -9,6 +9,10 @@ struct ControlsSettingsSection: View { let integrationActionMessage: String? let integrationActionIsError: Bool let installIntegrations: () -> Void + let launchAtLoginStatus: LaunchAtLoginStatus + let launchAtLoginError: String? + let setLaunchAtLoginEnabled: (Bool) -> Void + let openLoginItemsSettings: () -> Void var body: some View { VStack(alignment: .leading, spacing: 11) { @@ -48,6 +52,48 @@ struct ControlsSettingsSection: View { .padding(.leading, 142) } + HStack(alignment: .top, spacing: 10) { + Text("Launch at login") + .frame(width: 132, alignment: .leading) + + Toggle( + "Launch Meanwhile when you log in", + isOn: Binding( + get: { launchAtLoginStatus.isRequested }, + set: { setLaunchAtLoginEnabled($0) } + ) + ) + .labelsHidden() + .toggleStyle(.switch) + .accessibilityHint("Controls whether Meanwhile opens automatically after login.") + + VStack(alignment: .leading, spacing: 2) { + Text(launchAtLoginTitle) + .font(.callout.weight(.medium)) + Text(launchAtLoginDetail) + .font(.caption) + .foregroundStyle( + launchAtLoginStatus == .requiresApproval || launchAtLoginError != nil + ? .orange + : .secondary + ) + } + + Spacer(minLength: 8) + + if launchAtLoginStatus == .requiresApproval || launchAtLoginError != nil { + Button("Open Login Items…", action: openLoginItemsSettings) + } + } + + if let launchAtLoginError { + SettingsInlineMessage( + launchAtLoginError, + systemImage: "exclamationmark.triangle.fill", + tint: .orange + ) + } + HStack(spacing: 10) { Text("Agent integrations") .frame(width: 132, alignment: .leading) @@ -79,4 +125,32 @@ struct ControlsSettingsSection: View { } } } + + private var launchAtLoginTitle: String { + switch launchAtLoginStatus { + case .disabled: return "Off" + case .enabled: return "On" + case .requiresApproval: return "Approval needed" + case .unavailable: return "Unavailable" + } + } + + private var launchAtLoginDetail: String { + switch launchAtLoginStatus { + case .disabled: + return "Meanwhile opens only when you launch it." + case .enabled: + return "Meanwhile will be ready after your next login." + case .requiresApproval: + return "Allow Meanwhile in System Settings to finish enabling it." + case .unavailable: + return "macOS could not find the app's login item." + } + } +} + +private extension LaunchAtLoginStatus { + var isRequested: Bool { + self == .enabled || self == .requiresApproval + } } diff --git a/Sources/Meanwhile/LaunchAtLoginController.swift b/Sources/Meanwhile/LaunchAtLoginController.swift new file mode 100644 index 0000000..28e9717 --- /dev/null +++ b/Sources/Meanwhile/LaunchAtLoginController.swift @@ -0,0 +1,38 @@ +import MeanwhileCore +import ServiceManagement + +@MainActor +final class LaunchAtLoginController { + private var service: SMAppService { .mainApp } + + var status: LaunchAtLoginStatus { + switch service.status { + case .notRegistered: + return .disabled + case .enabled: + return .enabled + case .requiresApproval: + return .requiresApproval + case .notFound: + return .unavailable + @unknown default: + return .unavailable + } + } + + @discardableResult + func setEnabled(_ enabled: Bool) throws -> LaunchAtLoginStatus { + if enabled { + if service.status == .notRegistered || service.status == .notFound { + try service.register() + } + } else if service.status != .notRegistered { + try service.unregister() + } + return status + } + + func openSystemSettings() { + SMAppService.openSystemSettingsLoginItems() + } +} diff --git a/Sources/Meanwhile/MeanwhileApp.swift b/Sources/Meanwhile/MeanwhileApp.swift index bef16d9..c7f4fab 100644 --- a/Sources/Meanwhile/MeanwhileApp.swift +++ b/Sources/Meanwhile/MeanwhileApp.swift @@ -15,6 +15,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private let configuration = MeanwhileConfiguration.load() private let eventStore = AgentEventStore() private let recentSignalStore = RecentSignalStore() + private let launchAtLoginController = LaunchAtLoginController() private lazy var integrationInstaller = AgentIntegrationInstaller(helperURL: helperURL()) private lazy var hotKeyPreferences = HotKeyPreferences(defaultHotKey: configuration.hotKey) private var menuBar: MenuBarController? @@ -25,12 +26,42 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { private var hideMenuItem: NSMenuItem? private var hotKey: GlobalHotKey? private var lastRecordedItemID: String? + private lazy var agentFocusRouter = AgentFocusRouter( + focusTerminal: { [terminalFocuser] session in + terminalFocuser.focus(session) + } + ) private lazy var settingsModel = RepositorySettingsModel( preferences: repositoryPreferences, hotKeyPreferences: hotKeyPreferences, integrationInstaller: integrationInstaller, eventStore: eventStore, recentSignalStore: recentSignalStore, + sessionStaleAfter: configuration.sessionStaleSeconds, + appVersion: Bundle.main.object( + forInfoDictionaryKey: "CFBundleShortVersionString" + ) as? String ?? "Unknown", + buildVersion: Bundle.main.object( + forInfoDictionaryKey: "CFBundleVersion" + ) as? String ?? "Unknown", + operatingSystemVersion: ProcessInfo.processInfo.operatingSystemVersionString, + launchAtLoginStatus: { [launchAtLoginController] in + launchAtLoginController.status + }, + setLaunchAtLoginEnabled: { [launchAtLoginController] enabled in + try launchAtLoginController.setEnabled(enabled) + }, + openLoginItemsSettings: { [launchAtLoginController] in + launchAtLoginController.openSystemSettings() + }, + copyText: { text in + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + return pasteboard.setString(text, forType: .string) + }, + openURL: { url in + NSWorkspace.shared.open(url) + }, selectionDidChange: { [weak self] in self?.runtime?.repositorySelectionDidChange() }, @@ -63,6 +94,7 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } menuBar.statusItem.length = NSStatusItem.squareLength menuBar.statusItem.button?.toolTip = "Meanwhile — waiting for an agent event" + menuBar.setAccessibility(label: "Meanwhile, idle") self.menuBar = menuBar let preferences = repositoryPreferences @@ -109,44 +141,34 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { } menuBar?.setIcon( systemName: MenuBarPresenter.iconName(phase: presentation.phase), - accessibilityDescription: accessibilityDescription(for: presentation), + accessibilityDescription: nil, tintColor: iconColor ) - menuBar?.statusItem.button?.toolTip = tooltip(for: presentation) + menuBar?.setAccessibility( + label: MenuBarPresenter.accessibilityLabel( + phase: presentation.phase, + item: presentation.item + ), + help: MenuBarPresenter.accessibilityHelp( + phase: presentation.phase, + item: presentation.item + ) + ) + menuBar?.statusItem.button?.toolTip = MenuBarPresenter.tooltip( + phase: presentation.phase, + item: presentation.item + ) updateItemMenu(presentation.item) } private func updateItemMenu(_ item: WorkItem?) { - openItemMenuItem?.title = item.map { item in - switch item.kind { - case .review, .failingCI: - return "Open \(item.title) — \(item.detail)" - case .needsYou: - return "Open \(item.title)" - } - } ?? "No Item Available" + openItemMenuItem?.title = item.map(MenuBarPresenter.openActionTitle(item:)) + ?? "No Item Available" openItemMenuItem?.isEnabled = item != nil snoozeMenuItem?.isEnabled = item != nil hideMenuItem?.isEnabled = item != nil } - private func accessibilityDescription(for presentation: MeanwhilePresentation) -> String { - switch presentation.phase { - case .idle: return "Meanwhile — idle" - case .thinking: return "Meanwhile — agent thinking" - case .needsYou: return "Meanwhile — agent needs you" - } - } - - private func tooltip(for presentation: MeanwhilePresentation) -> String { - if let item = presentation.item { return "\(item.title): \(item.detail)" } - switch presentation.phase { - case .idle: return "Meanwhile — idle" - case .thinking: return "Agent thinking — no eligible items" - case .needsYou: return "Agent needs you" - } - } - private func makeContextMenu() -> NSMenu { let menu = NSMenu() let openItem = NSMenuItem( @@ -203,12 +225,35 @@ private final class AppDelegate: NSObject, NSApplicationDelegate { @objc private func openCurrentItem() { guard let item = currentItem else { return } if item.kind == .needsYou, let session = item.session { - _ = terminalFocuser.focus(session) + if agentFocusRouter.focus(session) == .unavailable { + showFocusFailure(for: item) + } } else if let url = item.url { NSWorkspace.shared.open(url) } } + private func showFocusFailure(for item: WorkItem) { + let provider = item.session.map { providerDisplayName($0.provider) } ?? "agent" + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = "Couldn’t return to \(provider)" + if item.session?.provider == .codex { + alert.informativeText = """ + Meanwhile couldn’t open this task in Codex or focus its original terminal. \ + The interruption is still active. + """ + } else { + alert.informativeText = """ + Meanwhile couldn’t focus this task’s original terminal. \ + The interruption is still active. + """ + } + alert.addButton(withTitle: "OK") + NSApp.activate(ignoringOtherApps: true) + alert.runModal() + } + private func registerHotKeyFromPreferences() { hotKey = nil settingsModel.setHotKeyRegistrationError(nil) diff --git a/Sources/Meanwhile/RepositorySettingsModel.swift b/Sources/Meanwhile/RepositorySettingsModel.swift index f565281..81b24dc 100644 --- a/Sources/Meanwhile/RepositorySettingsModel.swift +++ b/Sources/Meanwhile/RepositorySettingsModel.swift @@ -24,6 +24,19 @@ final class RepositorySettingsModel: ObservableObject { @Published private(set) var githubAuthenticationStatus: GitHubAuthenticationStatus = .notAuthenticated @Published private(set) var lastAgentEvent: AgentSessionState? @Published private(set) var recentSignals: [RecentSignal] = [] + @Published private(set) var launchAtLoginStatus: LaunchAtLoginStatus + @Published private(set) var launchAtLoginError: String? + @Published private(set) var updateState: ReleaseUpdateState = .notChecked + @Published private(set) var updateErrorMessage: String? + @Published private(set) var diagnosticsCopyMessage: String? + @Published private(set) var diagnosticsCopyIsError = false + @Published private(set) var sessionInspection = AgentSessionInspection.empty + @Published private(set) var sessionRecoveryMessage: String? + @Published private(set) var sessionRecoveryIsError = false + @Published private(set) var isClearingStuckSessions = false + + let appVersion: String + let buildVersion: String var connectedRepositoryCount: Int { includesAllRepositories ? availableRepositories.count : selectedRepositories.count @@ -59,6 +72,15 @@ final class RepositorySettingsModel: ObservableObject { return "Reviews and failing CI ready" } + var sessionStaleAfterDescription: String { + let minutes = max(1, Int(sessionStaleAfter / 60)) + if minutes.isMultiple(of: 60) { + let hours = minutes / 60 + return hours == 1 ? "1 hour" : "\(hours) hours" + } + return minutes == 1 ? "1 minute" : "\(minutes) minutes" + } + private let preferences: RepositoryPreferences private let hotKeyPreferences: HotKeyPreferences private let catalog: GitHubRepositoryCatalog @@ -66,10 +88,19 @@ final class RepositorySettingsModel: ObservableObject { private let integrationInstaller: AgentIntegrationInstaller private let eventStore: AgentEventStore private let recentSignalStore: RecentSignalStore + private let releaseUpdateChecker: ReleaseUpdateChecker + private let sessionStaleAfter: TimeInterval + private let operatingSystemVersion: String + private let launchAtLoginStatusProvider: () -> LaunchAtLoginStatus + private let launchAtLoginSetter: (Bool) throws -> LaunchAtLoginStatus + private let openLoginItemsSettingsAction: () -> Void + private let copyText: (String) -> Bool + private let openURL: (URL) -> Void private let selectionDidChange: () -> Void private let hotKeyDidChange: (HotKeyConfiguration?) -> Void private let integrationDidInstall: (AgentIntegrationInstallResult) -> Void private var hasLoaded = false + private var diagnosticsFeedbackID = UUID() init( preferences: RepositoryPreferences, @@ -79,6 +110,16 @@ final class RepositorySettingsModel: ObservableObject { integrationInstaller: AgentIntegrationInstaller, eventStore: AgentEventStore, recentSignalStore: RecentSignalStore, + releaseUpdateChecker: ReleaseUpdateChecker = ReleaseUpdateChecker(), + sessionStaleAfter: TimeInterval, + appVersion: String, + buildVersion: String, + operatingSystemVersion: String, + launchAtLoginStatus: @escaping () -> LaunchAtLoginStatus, + setLaunchAtLoginEnabled: @escaping (Bool) throws -> LaunchAtLoginStatus, + openLoginItemsSettings: @escaping () -> Void, + copyText: @escaping (String) -> Bool, + openURL: @escaping (URL) -> Void, selectionDidChange: @escaping () -> Void, hotKeyDidChange: @escaping (HotKeyConfiguration?) -> Void, integrationDidInstall: @escaping (AgentIntegrationInstallResult) -> Void @@ -90,6 +131,16 @@ final class RepositorySettingsModel: ObservableObject { self.integrationInstaller = integrationInstaller self.eventStore = eventStore self.recentSignalStore = recentSignalStore + self.releaseUpdateChecker = releaseUpdateChecker + self.sessionStaleAfter = sessionStaleAfter + self.appVersion = appVersion + self.buildVersion = buildVersion + self.operatingSystemVersion = operatingSystemVersion + launchAtLoginStatusProvider = launchAtLoginStatus + launchAtLoginSetter = setLaunchAtLoginEnabled + openLoginItemsSettingsAction = openLoginItemsSettings + self.copyText = copyText + self.openURL = openURL self.selectionDidChange = selectionDidChange self.hotKeyDidChange = hotKeyDidChange self.integrationDidInstall = integrationDidInstall @@ -97,6 +148,7 @@ final class RepositorySettingsModel: ObservableObject { includesAllRepositories = snapshot.includesAllRepositories selectedRepositories = snapshot.selectedRepositories hotKey = hotKeyPreferences.hotKey + self.launchAtLoginStatus = launchAtLoginStatus() } func loadRepositories(force: Bool = false) { @@ -132,12 +184,14 @@ final class RepositorySettingsModel: ObservableObject { let integrationInstaller = self.integrationInstaller let eventStore = self.eventStore let recentSignalStore = self.recentSignalStore + let sessionStaleAfter = self.sessionStaleAfter Task.detached { let healthResult = Result { try integrationInstaller.health() } let authenticationStatus = authenticationChecker.status() let latestEvent = eventStore.latestEvent() let signals = recentSignalStore.signals + let sessionInspection = eventStore.inspectSessions(staleAfter: sessionStaleAfter) await MainActor.run { [weak self] in guard let self else { return } switch healthResult { @@ -156,11 +210,130 @@ final class RepositorySettingsModel: ObservableObject { githubAuthenticationStatus = authenticationStatus lastAgentEvent = latestEvent recentSignals = signals + if self.sessionInspection != sessionInspection { + sessionRecoveryMessage = nil + sessionRecoveryIsError = false + } + self.sessionInspection = sessionInspection + launchAtLoginStatus = launchAtLoginStatusProvider() isCheckingHealth = false } } } + func checkForUpdates() { + guard updateState != .checking else { return } + updateState = .checking + updateErrorMessage = nil + let checker = releaseUpdateChecker + let currentVersion = appVersion + + Task.detached { + let result = Result { + try checker.check(currentVersion: currentVersion) + } + await MainActor.run { [weak self] in + guard let self else { return } + switch result { + case .success(let state): + updateState = state + case .failure(let error): + updateState = .unavailable + updateErrorMessage = ( + error as? ReleaseUpdateCheckerError + )?.localizedDescription + ?? "Could not check GitHub releases. Confirm the GitHub CLI is installed and authenticated." + } + } + } + } + + func setLaunchAtLoginEnabled(_ enabled: Bool) { + launchAtLoginError = nil + do { + launchAtLoginStatus = try launchAtLoginSetter(enabled) + } catch { + launchAtLoginStatus = launchAtLoginStatusProvider() + launchAtLoginError = + "Meanwhile could not change this Login Item. Open Login Items in System Settings and try again." + } + } + + func openLoginItemsSettings() { + openLoginItemsSettingsAction() + } + + func openLatestRelease() { + guard let releaseURL = updateState.releaseURL else { return } + openURL(releaseURL) + } + + func clearStuckSessions() { + guard !isClearingStuckSessions, sessionInspection.stuckCount > 0 else { return } + isClearingStuckSessions = true + sessionRecoveryMessage = nil + sessionRecoveryIsError = false + let eventStore = self.eventStore + let staleAfter = sessionStaleAfter + + Task.detached { + let result = Result { + try eventStore.clearStuckSessions(staleAfter: staleAfter) + } + let inspection = eventStore.inspectSessions(staleAfter: staleAfter) + await MainActor.run { [weak self] in + guard let self else { return } + isClearingStuckSessions = false + sessionInspection = inspection + switch result { + case .success(let count): + let message = count == 1 + ? "Cleared 1 stuck session." + : "Cleared \(count) stuck sessions." + sessionRecoveryMessage = message + Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard self?.sessionRecoveryMessage == message else { return } + self?.sessionRecoveryMessage = nil + } + case .failure: + sessionRecoveryMessage = "Meanwhile could not clear every stuck session." + sessionRecoveryIsError = true + } + } + } + } + + func copyDiagnostics() { + let snapshot = DiagnosticsSnapshot( + appVersion: appVersion, + buildVersion: buildVersion, + operatingSystemVersion: operatingSystemVersion, + launchAtLoginStatus: launchAtLoginStatus, + updateState: updateState, + integrationHealth: integrationHealth, + githubAuthenticationStatus: githubAuthenticationStatus, + repositoryScopeIncludesAll: includesAllRepositories, + accessibleRepositoryCount: availableRepositories.count, + selectedRepositoryCount: selectedRepositories.count, + hotKeyConfigured: hotKey != nil, + sessionInspection: sessionInspection, + lastAgentEvent: lastAgentEvent, + recentSignals: recentSignals + ) + let copied = copyText(MeanwhileDiagnosticsReport.make(snapshot: snapshot)) + let feedbackID = UUID() + diagnosticsFeedbackID = feedbackID + diagnosticsCopyMessage = copied ? "Copied" : "Try Again" + diagnosticsCopyIsError = !copied + Task { [weak self] in + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard self?.diagnosticsFeedbackID == feedbackID else { return } + self?.diagnosticsCopyMessage = nil + self?.diagnosticsCopyIsError = false + } + } + func setIncludesAllRepositories(_ includesAll: Bool) { if !includesAll, selectedRepositories.isEmpty { selectedRepositories = Set(availableRepositories) diff --git a/Sources/Meanwhile/RepositorySettingsView.swift b/Sources/Meanwhile/RepositorySettingsView.swift index b9185ff..879cf13 100644 --- a/Sources/Meanwhile/RepositorySettingsView.swift +++ b/Sources/Meanwhile/RepositorySettingsView.swift @@ -5,6 +5,7 @@ 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.aboutSupport.expanded") private var isAboutSupportExpanded = true @AppStorage("settings.section.repositorySources.expanded") private var isRepositorySourcesExpanded = true @AppStorage("settings.section.recentSignals.expanded") private var isRecentSignalsExpanded = true @State private var searchText = "" @@ -17,6 +18,30 @@ struct RepositorySettingsView: View { } } + private var aboutSupportTrailing: String { + switch model.updateState { + case .updateAvailable(let version, _): + return "\(version) available" + case .unavailable: + return "Check unavailable" + case .checking: + return "Checking" + case .notChecked, .current, .developmentBuild: + return "Version \(model.appVersion)" + } + } + + private var aboutSupportTrailingTint: Color { + switch model.updateState { + case .updateAvailable: + return .accentColor + case .unavailable: + return .orange + case .notChecked, .checking, .current, .developmentBuild: + return Color(nsColor: .tertiaryLabelColor) + } + } + var body: some View { ScrollView { VStack(alignment: .leading, spacing: 18) { @@ -32,17 +57,48 @@ struct RepositorySettingsView: View { } Divider() ControlsSettingsSection( - hotKey: Binding(get: { model.hotKey }, set: model.setHotKey), + hotKey: Binding( + get: { model.hotKey }, + set: { model.setHotKey($0) } + ), hotKeyRegistrationError: model.hotKeyRegistrationError, setHotKeyRegistrationError: model.setHotKeyRegistrationError, isInstallingIntegrations: model.isInstallingIntegrations, integrationActionMessage: model.integrationActionMessage, integrationActionIsError: model.integrationActionIsError, - installIntegrations: model.installIntegrations + installIntegrations: model.installIntegrations, + launchAtLoginStatus: model.launchAtLoginStatus, + launchAtLoginError: model.launchAtLoginError, + setLaunchAtLoginEnabled: { model.setLaunchAtLoginEnabled($0) }, + openLoginItemsSettings: model.openLoginItemsSettings ) Divider() + CollapsibleSettingsSection( + title: "About & support", + trailing: aboutSupportTrailing, + trailingTint: aboutSupportTrailingTint, + isExpanded: $isAboutSupportExpanded, + showsProgress: model.updateState == .checking + ) { + AppSettingsSection( + appVersion: model.appVersion, + buildVersion: model.buildVersion, + updateState: model.updateState, + updateErrorMessage: model.updateErrorMessage, + checkForUpdates: model.checkForUpdates, + openLatestRelease: model.openLatestRelease, + diagnosticsCopyMessage: model.diagnosticsCopyMessage, + diagnosticsCopyIsError: model.diagnosticsCopyIsError, + copyDiagnostics: model.copyDiagnostics + ) + } + Divider() CollapsibleSettingsSection( title: "Connection health", + trailing: model.sessionInspection.stuckCount > 0 + ? "\(model.sessionInspection.stuckCount) may be stuck" + : nil, + trailingTint: .orange, isExpanded: $isConnectionHealthExpanded, showsProgress: model.isCheckingHealth ) { @@ -51,6 +107,12 @@ struct RepositorySettingsView: View { integrationHealthError: model.integrationHealthError, lastAgentEvent: model.lastAgentEvent, githubAuthenticationStatus: model.githubAuthenticationStatus, + sessionInspection: model.sessionInspection, + sessionRecoveryMessage: model.sessionRecoveryMessage, + sessionRecoveryIsError: model.sessionRecoveryIsError, + isClearingStuckSessions: model.isClearingStuckSessions, + staleAfter: model.sessionStaleAfterDescription, + clearStuckSessions: model.clearStuckSessions, now: now ) } @@ -66,7 +128,7 @@ struct RepositorySettingsView: View { searchText: $searchText, includesAllRepositories: Binding( get: { model.includesAllRepositories }, - set: model.setIncludesAllRepositories + set: { model.setIncludesAllRepositories($0) } ), availableRepositories: model.availableRepositories, filteredRepositories: filteredRepositories, @@ -93,6 +155,7 @@ struct RepositorySettingsView: View { .task { model.loadRepositories() model.refreshStatus() + model.checkForUpdates() while !Task.isCancelled { try? await Task.sleep(nanoseconds: 30_000_000_000) guard !Task.isCancelled else { break } diff --git a/Sources/Meanwhile/SettingsSupport.swift b/Sources/Meanwhile/SettingsSupport.swift index 323e636..8e78bce 100644 --- a/Sources/Meanwhile/SettingsSupport.swift +++ b/Sources/Meanwhile/SettingsSupport.swift @@ -4,6 +4,7 @@ import SwiftUI struct SettingsSectionHeader: View { let title: String var trailing: String? = nil + var trailingTint: Color = Color(nsColor: .tertiaryLabelColor) var showsProgress = false var body: some View { @@ -15,7 +16,7 @@ struct SettingsSectionHeader: View { if let trailing { Text(trailing) .font(.caption2) - .foregroundStyle(.tertiary) + .foregroundStyle(trailingTint) } if showsProgress { ProgressView() @@ -28,6 +29,7 @@ struct SettingsSectionHeader: View { struct CollapsibleSettingsSection: View { let title: String var trailing: String? = nil + var trailingTint: Color = Color(nsColor: .tertiaryLabelColor) @Binding var isExpanded: Bool var showsProgress = false private let content: Content @@ -35,12 +37,14 @@ struct CollapsibleSettingsSection: View { init( title: String, trailing: String? = nil, + trailingTint: Color = Color(nsColor: .tertiaryLabelColor), isExpanded: Binding, showsProgress: Bool = false, @ViewBuilder content: () -> Content ) { self.title = title self.trailing = trailing + self.trailingTint = trailingTint _isExpanded = isExpanded self.showsProgress = showsProgress self.content = content() @@ -54,12 +58,32 @@ struct CollapsibleSettingsSection: View { SettingsSectionHeader( title: title, trailing: trailing, + trailingTint: trailingTint, showsProgress: showsProgress ) } } } +struct SettingsInlineMessage: View { + let message: String + let systemImage: String + let tint: Color + + init(_ message: String, systemImage: String, tint: Color) { + self.message = message + self.systemImage = systemImage + self.tint = tint + } + + var body: some View { + Label(message, systemImage: systemImage) + .font(.caption) + .foregroundStyle(tint) + .padding(.leading, 142) + } +} + func providerDisplayName(_ provider: AgentProvider) -> String { switch provider { case .claude: return "Claude" diff --git a/Sources/Meanwhile/StatusItemSettingsSection.swift b/Sources/Meanwhile/StatusItemSettingsSection.swift index 427b83a..d70c1d4 100644 --- a/Sources/Meanwhile/StatusItemSettingsSection.swift +++ b/Sources/Meanwhile/StatusItemSettingsSection.swift @@ -28,8 +28,8 @@ struct StatusItemSettingsSection: View { StatusLanguageRow( systemImage: "exclamationmark.bubble.fill", tint: .red, - label: "Needs you", - meaning: "An agent is blocked on you" + label: "Codex needs you", + meaning: "Click to return; hover for the project" ) StatusLanguageRow( systemImage: "rectangle.stack.fill", diff --git a/Sources/MeanwhileCore/AgentEventStore.swift b/Sources/MeanwhileCore/AgentEventStore.swift index 22a8c11..cc19f7d 100644 --- a/Sources/MeanwhileCore/AgentEventStore.swift +++ b/Sources/MeanwhileCore/AgentEventStore.swift @@ -1,5 +1,27 @@ import Foundation +public struct AgentSessionInspection: Equatable, Sendable { + public var activeCount: Int + public var stuckCount: Int + public var oldestStuckUpdate: Date? + + public init( + activeCount: Int, + stuckCount: Int, + oldestStuckUpdate: Date? + ) { + self.activeCount = activeCount + self.stuckCount = stuckCount + self.oldestStuckUpdate = oldestStuckUpdate + } + + public static let empty = AgentSessionInspection( + activeCount: 0, + stuckCount: 0, + oldestStuckUpdate: nil + ) +} + public final class AgentEventStore: @unchecked Sendable { public let directory: URL public let latestEventURL: URL @@ -78,11 +100,59 @@ public final class AgentEventStore: @unchecked Sendable { return current.sorted { $0.id < $1.id } } + public func inspectSessions( + now: Date = Date(), + staleAfter: TimeInterval = 3_600 + ) -> AgentSessionInspection { + lock.lock() + defer { lock.unlock() } + let active = sessionFiles() + .compactMap(decode(url:)) + .filter { $0.phase != .idle } + let stuck = active.filter { + now.timeIntervalSince($0.updatedAt) > staleAfter + } + return AgentSessionInspection( + activeCount: active.count, + stuckCount: stuck.count, + oldestStuckUpdate: stuck.map(\.updatedAt).min() + ) + } + + @discardableResult + public func clearStuckSessions( + now: Date = Date(), + staleAfter: TimeInterval = 3_600 + ) throws -> Int { + lock.lock() + defer { lock.unlock() } + var removed = 0 + for url in sessionFiles() { + guard let session = decode(url: url), + session.phase != .idle, + now.timeIntervalSince(session.updatedAt) > staleAfter else { + continue + } + try fileManager.removeItem(at: url) + removed += 1 + } + return removed + } + private func decode(url: URL) -> AgentSessionState? { guard let data = try? Data(contentsOf: url) else { return nil } return try? decoder.decode(AgentSessionState.self, from: data) } + private func sessionFiles() -> [URL] { + guard let urls = try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { return [] } + return urls.filter { $0.pathExtension == "json" } + } + private func url(for session: AgentSessionState) -> URL { let raw = Data(session.sessionID.utf8).base64EncodedString() .replacingOccurrences(of: "/", with: "_") diff --git a/Sources/MeanwhileCore/AgentFocusRouter.swift b/Sources/MeanwhileCore/AgentFocusRouter.swift new file mode 100644 index 0000000..4efe6b0 --- /dev/null +++ b/Sources/MeanwhileCore/AgentFocusRouter.swift @@ -0,0 +1,35 @@ +import AppKit +import Foundation + +public enum AgentFocusOutcome: Equatable, Sendable { + case codexTask + case terminalFallback + case unavailable +} + +@MainActor +public final class AgentFocusRouter { + private let openURL: (URL) -> Bool + private let focusTerminal: (AgentSessionState) -> Bool + + public init( + openURL: @escaping (URL) -> Bool = { NSWorkspace.shared.open($0) }, + focusTerminal: @escaping (AgentSessionState) -> Bool + ) { + self.openURL = openURL + self.focusTerminal = focusTerminal + } + + public func focus(_ session: AgentSessionState) -> AgentFocusOutcome { + if let url = Self.codexThreadURL(for: session), openURL(url) { + return .codexTask + } + return focusTerminal(session) ? .terminalFallback : .unavailable + } + + nonisolated static func codexThreadURL(for session: AgentSessionState) -> URL? { + guard session.provider == .codex, + UUID(uuidString: session.sessionID) != nil else { return nil } + return URL(string: "codex://threads/\(session.sessionID)") + } +} diff --git a/Sources/MeanwhileCore/DiagnosticsReport.swift b/Sources/MeanwhileCore/DiagnosticsReport.swift new file mode 100644 index 0000000..7322fb5 --- /dev/null +++ b/Sources/MeanwhileCore/DiagnosticsReport.swift @@ -0,0 +1,151 @@ +import Foundation + +public enum LaunchAtLoginStatus: Equatable, Sendable { + case disabled + case enabled + case requiresApproval + case unavailable +} + +public struct DiagnosticsSnapshot: Sendable { + public var appVersion: String + public var buildVersion: String + public var operatingSystemVersion: String + public var launchAtLoginStatus: LaunchAtLoginStatus + public var updateState: ReleaseUpdateState + public var integrationHealth: AgentIntegrationHealth + public var githubAuthenticationStatus: GitHubAuthenticationStatus + public var repositoryScopeIncludesAll: Bool + public var accessibleRepositoryCount: Int + public var selectedRepositoryCount: Int + public var hotKeyConfigured: Bool + public var sessionInspection: AgentSessionInspection + public var lastAgentEvent: AgentSessionState? + public var recentSignals: [RecentSignal] + + public init( + appVersion: String, + buildVersion: String, + operatingSystemVersion: String, + launchAtLoginStatus: LaunchAtLoginStatus, + updateState: ReleaseUpdateState, + integrationHealth: AgentIntegrationHealth, + githubAuthenticationStatus: GitHubAuthenticationStatus, + repositoryScopeIncludesAll: Bool, + accessibleRepositoryCount: Int, + selectedRepositoryCount: Int, + hotKeyConfigured: Bool, + sessionInspection: AgentSessionInspection, + lastAgentEvent: AgentSessionState?, + recentSignals: [RecentSignal] + ) { + self.appVersion = appVersion + self.buildVersion = buildVersion + self.operatingSystemVersion = operatingSystemVersion + self.launchAtLoginStatus = launchAtLoginStatus + self.updateState = updateState + self.integrationHealth = integrationHealth + self.githubAuthenticationStatus = githubAuthenticationStatus + self.repositoryScopeIncludesAll = repositoryScopeIncludesAll + self.accessibleRepositoryCount = accessibleRepositoryCount + self.selectedRepositoryCount = selectedRepositoryCount + self.hotKeyConfigured = hotKeyConfigured + self.sessionInspection = sessionInspection + self.lastAgentEvent = lastAgentEvent + self.recentSignals = recentSignals + } +} + +public enum MeanwhileDiagnosticsReport { + public static func make( + snapshot: DiagnosticsSnapshot, + generatedAt: Date = Date() + ) -> String { + var lines = [ + "Meanwhile diagnostics", + "Schema: 1", + "Generated: \(timestamp(generatedAt))", + "App: \(snapshot.appVersion) (\(snapshot.buildVersion))", + "macOS: \(snapshot.operatingSystemVersion)", + "Launch at login: \(launchAtLoginDescription(snapshot.launchAtLoginStatus))", + "Update: \(updateDescription(snapshot.updateState))", + "Integrations: \(integrationDescription(snapshot.integrationHealth.state))", + "Claude hooks: \(yesNo(snapshot.integrationHealth.claudeHooksInstalled))", + "Codex hooks: \(yesNo(snapshot.integrationHealth.codexHooksInstalled))", + "Claude status line conflict: \(yesNo(snapshot.integrationHealth.claudeStatuslineConflict))", + "GitHub CLI: \(snapshot.githubAuthenticationStatus == .authenticated ? "authenticated" : "not authenticated")", + "Repository scope: \(repositoryScopeDescription(snapshot))", + "Keyboard shortcut: \(snapshot.hotKeyConfigured ? "configured" : "not configured")", + "Active agent sessions: \(snapshot.sessionInspection.activeCount)", + "Sessions that may be stuck: \(snapshot.sessionInspection.stuckCount)" + ] + + if let event = snapshot.lastAgentEvent { + lines.append( + "Last agent event: \(event.provider.rawValue), \(event.phase.rawValue), \(timestamp(event.updatedAt))" + ) + } else { + lines.append("Last agent event: none") + } + + lines.append("Recent signals: \(snapshot.recentSignals.count)") + for kind in RecentSignalKind.allCases { + let count = snapshot.recentSignals.lazy.filter { $0.kind == kind }.count + lines.append("Recent \(kind.rawValue): \(count)") + } + if let newest = snapshot.recentSignals.map(\.date).max() { + lines.append("Newest signal: \(timestamp(newest))") + } else { + lines.append("Newest signal: none") + } + + return lines.joined(separator: "\n") + "\n" + } + + private static func timestamp(_ date: Date) -> String { + ISO8601DateFormatter().string(from: date) + } + + private static func yesNo(_ value: Bool) -> String { + value ? "yes" : "no" + } + + private static func launchAtLoginDescription(_ status: LaunchAtLoginStatus) -> String { + switch status { + case .disabled: return "off" + case .enabled: return "on" + case .requiresApproval: return "needs approval" + case .unavailable: return "unavailable" + } + } + + private static func updateDescription(_ state: ReleaseUpdateState) -> String { + switch state { + case .notChecked: return "not checked" + case .checking: return "checking" + case .current(let version, _): return "current (\(version))" + case .updateAvailable(let version, _): return "available (\(version))" + case .developmentBuild(let version, _): return "development build; latest release \(version)" + case .unavailable: return "unavailable" + } + } + + private static func integrationDescription( + _ state: AgentIntegrationHealthState + ) -> String { + switch state { + case .installed: return "installed" + case .needsReview: return "needs review" + case .notInstalled: return "not installed" + } + } + + private static func repositoryScopeDescription( + _ snapshot: DiagnosticsSnapshot + ) -> String { + if snapshot.repositoryScopeIncludesAll { + return "all accessible (\(snapshot.accessibleRepositoryCount))" + } + return "selected only (\(snapshot.selectedRepositoryCount))" + } +} diff --git a/Sources/MeanwhileCore/MenuBarPresenter.swift b/Sources/MeanwhileCore/MenuBarPresenter.swift index 75b77ca..b182cc5 100644 --- a/Sources/MeanwhileCore/MenuBarPresenter.swift +++ b/Sources/MeanwhileCore/MenuBarPresenter.swift @@ -1,3 +1,5 @@ +import Foundation + public enum MenuBarPresenter { public static let idleIconName = "rectangle.stack" public static let thinkingIconName = "rectangle.stack.fill" @@ -14,7 +16,7 @@ public enum MenuBarPresenter { public static func statusText(item: WorkItem?) -> String? { guard let item else { return nil } switch item.kind { - case .needsYou: return "Needs you" + case .needsYou: return item.title case .failingCI: return "CI!" case .review: let number = item.title.split(separator: "#").last.map(String.init) @@ -22,6 +24,101 @@ public enum MenuBarPresenter { } } + public static func projectName(item: WorkItem) -> String? { + guard let cwd = item.session?.cwd.trimmingCharacters(in: .whitespacesAndNewlines), + !cwd.isEmpty, + cwd.rangeOfCharacter(from: .controlCharacters) == nil else { return nil } + let url = URL(fileURLWithPath: cwd).standardizedFileURL + guard url != FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL else { + return nil + } + let name = (cwd as NSString).lastPathComponent + guard !name.isEmpty, + name != "/", + name != ".", + name != "..", + name.rangeOfCharacter(from: .controlCharacters) == nil else { return nil } + return middleTruncated(name, limit: 32) + } + + public static func openActionTitle(item: WorkItem) -> String { + switch item.kind { + case .needsYou: + let action: String + switch item.session?.provider { + case .claude: action = "Return to Claude" + case .codex: action = "Return to Codex" + case .unknown, nil: action = "Return to waiting task" + } + if let project = projectName(item: item) { + return "\(action) — \(project)" + } + return action + case .review, .failingCI: + return "Open \(item.title) — \(item.detail)" + } + } + + public static func tooltip(phase: AgentDisplayPhase, item: WorkItem?) -> String { + if let item { + if item.kind == .needsYou { + if let project = projectName(item: item) { + return "\(item.title) in “\(project)” — click to return" + } + return "\(item.title) — click to return" + } + return "\(item.title): \(item.detail)" + } + switch phase { + case .idle: return "Meanwhile — idle" + case .thinking: return "Agent thinking — no eligible items" + case .needsYou: return "Waiting task hidden" + } + } + + public static func accessibilityLabel( + phase: AgentDisplayPhase, + item: WorkItem? + ) -> String { + if let item { + switch item.kind { + case .needsYou: + if let project = projectName(item: item) { + return "\(item.title) in the \(project) project" + } + return item.title + case .review, .failingCI: + return "\(item.title), \(item.detail)" + } + } + switch phase { + case .idle: return "Meanwhile, idle" + case .thinking: return "Meanwhile, agent thinking" + case .needsYou: return "Waiting task hidden" + } + } + + public static func accessibilityHelp( + phase: AgentDisplayPhase, + item: WorkItem? + ) -> String? { + if let item { + switch item.kind { + case .needsYou: + guard let provider = item.session?.provider, + provider != .unknown else { + return "Returns to the waiting task." + } + return "Returns to the waiting \(providerName(provider)) task." + case .review, .failingCI: + return "Opens \(item.title)." + } + } + return phase == .needsYou + ? "It will reappear when its state changes." + : nil + } + public static func statuslineText(item: WorkItem) -> String { switch item.kind { case .needsYou: return "Meanwhile: \(item.title)" @@ -29,4 +126,19 @@ public enum MenuBarPresenter { case .review: return "Meanwhile: \(item.title) — \(item.detail)" } } + + private static func providerName(_ provider: AgentProvider) -> String { + switch provider { + case .claude: return "Claude" + case .codex: return "Codex" + case .unknown: return "Agent" + } + } + + private static func middleTruncated(_ value: String, limit: Int) -> String { + guard value.count > limit, limit > 1 else { return value } + let leadingCount = (limit - 1) / 2 + let trailingCount = limit - leadingCount - 1 + return "\(value.prefix(leadingCount))…\(value.suffix(trailingCount))" + } } diff --git a/Sources/MeanwhileCore/RecentSignalStore.swift b/Sources/MeanwhileCore/RecentSignalStore.swift index 1848fb3..e0e2549 100644 --- a/Sources/MeanwhileCore/RecentSignalStore.swift +++ b/Sources/MeanwhileCore/RecentSignalStore.swift @@ -1,6 +1,6 @@ import Foundation -public enum RecentSignalKind: String, Codable, Sendable { +public enum RecentSignalKind: String, Codable, Sendable, CaseIterable { case agentNeedsYou case reviewSurfaced case ciFailed diff --git a/Sources/MeanwhileCore/ReleaseUpdateChecker.swift b/Sources/MeanwhileCore/ReleaseUpdateChecker.swift new file mode 100644 index 0000000..cbb11ad --- /dev/null +++ b/Sources/MeanwhileCore/ReleaseUpdateChecker.swift @@ -0,0 +1,120 @@ +import Foundation + +public enum ReleaseUpdateState: Equatable, Sendable { + case notChecked + case checking + case current(latestVersion: String, releaseURL: URL) + case updateAvailable(version: String, releaseURL: URL) + case developmentBuild(latestVersion: String, releaseURL: URL) + case unavailable + + public var releaseURL: URL? { + switch self { + case .current(_, let releaseURL), + .updateAvailable(_, let releaseURL), + .developmentBuild(_, let releaseURL): + return releaseURL + case .notChecked, .checking, .unavailable: + return nil + } + } +} + +public enum ReleaseUpdateCheckerError: Error, Equatable { + case commandFailed + case invalidResponse + case invalidVersion +} + +extension ReleaseUpdateCheckerError: LocalizedError { + public var errorDescription: String? { + switch self { + case .commandFailed: + return "Could not check GitHub releases. Confirm the GitHub CLI is installed and authenticated." + case .invalidResponse: + return "GitHub returned an unreadable release response." + case .invalidVersion: + return "The installed and latest release versions could not be compared." + } + } +} + +public struct ReleaseUpdateChecker: Sendable { + public static let command = + "gh release view --repo tcballard/Meanwhile --json tagName,url" + + private struct ReleasePayload: Decodable { + var tagName: String + var url: String + } + + private let runner: any CommandRunning + + public init(runner: any CommandRunning = PeripheralCommandRunner()) { + self.runner = runner + } + + public func check(currentVersion: String) throws -> ReleaseUpdateState { + let result = try runner.run(Self.command, timeoutSeconds: 15) + guard result.exitCode == 0 else { + throw ReleaseUpdateCheckerError.commandFailed + } + + guard let data = result.stdout.data(using: .utf8), + let payload = try? JSONDecoder().decode(ReleasePayload.self, from: data), + let releaseURL = URL(string: payload.url), + releaseURL.scheme == "https", + releaseURL.host == "github.com" else { + throw ReleaseUpdateCheckerError.invalidResponse + } + + guard let current = SemanticVersion(currentVersion), + let latest = SemanticVersion(payload.tagName) else { + throw ReleaseUpdateCheckerError.invalidVersion + } + + if current < latest { + return .updateAvailable(version: payload.tagName, releaseURL: releaseURL) + } + if latest < current { + return .developmentBuild( + latestVersion: payload.tagName, + releaseURL: releaseURL + ) + } + return .current(latestVersion: payload.tagName, releaseURL: releaseURL) + } +} + +public struct SemanticVersion: Comparable, Equatable, Sendable { + private let components: [Int] + + public init?(_ value: String) { + var normalized = value.trimmingCharacters(in: .whitespacesAndNewlines) + if normalized.lowercased().hasPrefix("v") { + normalized.removeFirst() + } + let core = normalized.split(separator: "-", maxSplits: 1).first.map(String.init) ?? "" + let parts = core.split(separator: ".", omittingEmptySubsequences: false) + guard (2...4).contains(parts.count), + parts.allSatisfy({ !$0.isEmpty && $0.allSatisfy(\.isNumber) }) else { + return nil + } + var parsed = parts.compactMap { Int($0) } + guard parsed.count == parts.count else { return nil } + while parsed.count < 3 { + parsed.append(0) + } + components = parsed + } + + public static func < (lhs: SemanticVersion, rhs: SemanticVersion) -> Bool { + let count = max(lhs.components.count, rhs.components.count) + for index in 0.. Bool - @discardableResult - public func focus(_ session: AgentSessionState) -> Bool { - if let tty = session.terminal.tty { - if isITerm(session.terminal.program), run(script: iTermScript(tty: tty)) { - return true - } - if isAppleTerminal(session.terminal.program), run(script: terminalScript(tty: tty)) { - return true - } - if run(script: terminalScript(tty: tty)) || run(script: iTermScript(tty: tty)) { - return true - } + public convenience init() { + self.init { script in + var error: NSDictionary? + let result = NSAppleScript(source: script)?.executeAndReturnError(&error) + return error == nil && result?.booleanValue == true } - return activateTerminal(named: session.terminal.program) } - private func activateTerminal(named program: String?) -> Bool { - let bundleIdentifiers: [String] - switch program?.lowercased() { - case "apple_terminal", "terminal", "terminal.app": - bundleIdentifiers = ["com.apple.Terminal"] - case "iterm.app", "iterm2", "iterm": - bundleIdentifiers = ["com.googlecode.iterm2"] - case "vscode", "visual studio code": - bundleIdentifiers = ["com.microsoft.VSCode"] - case "warpterminal", "warp": - bundleIdentifiers = ["dev.warp.Warp-Stable", "dev.warp.Warp"] - case "ghostty": - bundleIdentifiers = ["com.mitchellh.ghostty"] - default: - bundleIdentifiers = ["com.apple.Terminal", "com.googlecode.iterm2"] + init(scriptRunner: @escaping (String) -> Bool) { + self.scriptRunner = scriptRunner + } + + @discardableResult + public func focus(_ session: AgentSessionState) -> Bool { + guard let tty = session.terminal.tty else { return false } + if isITerm(session.terminal.program) { + return scriptRunner(iTermScript(tty: tty)) } - for identifier in bundleIdentifiers { - if let app = NSRunningApplication.runningApplications( - withBundleIdentifier: identifier - ).first { - return app.activate(options: [.activateAllWindows]) - } + if isAppleTerminal(session.terminal.program) { + return scriptRunner(terminalScript(tty: tty)) } return false } - private func run(script: String) -> Bool { - var error: NSDictionary? - let result = NSAppleScript(source: script)?.executeAndReturnError(&error) - return error == nil && result?.booleanValue == true - } - private func terminalScript(tty: String) -> String { let tty = appleScriptString(tty) return """ @@ -100,11 +76,18 @@ public final class TerminalFocuser { } private func isAppleTerminal(_ program: String?) -> Bool { - guard let program = program?.lowercased() else { return false } - return program.contains("terminal") && !program.contains("iterm") + guard let program = normalizedProgram(program) else { return false } + return ["apple_terminal", "terminal", "terminal.app"].contains(program) } private func isITerm(_ program: String?) -> Bool { - program?.lowercased().contains("iterm") == true + guard let program = normalizedProgram(program) else { return false } + return ["iterm", "iterm2", "iterm.app"].contains(program) + } + + private func normalizedProgram(_ program: String?) -> String? { + guard let program = program?.trimmingCharacters(in: .whitespacesAndNewlines), + !program.isEmpty else { return nil } + return (program as NSString).lastPathComponent.lowercased() } } diff --git a/Sources/Peripheral/MenuBarController.swift b/Sources/Peripheral/MenuBarController.swift index 9cf0a45..fa6a629 100644 --- a/Sources/Peripheral/MenuBarController.swift +++ b/Sources/Peripheral/MenuBarController.swift @@ -45,7 +45,7 @@ public final class MenuBarController: NSObject, NSMenuDelegate { public func setIcon( systemName: String?, - accessibilityDescription: String = "Peripheral", + accessibilityDescription: String? = "Peripheral", tintColor: NSColor? = nil ) { guard let systemName else { @@ -71,6 +71,11 @@ public final class MenuBarController: NSObject, NSMenuDelegate { updateImagePosition() } + public func setAccessibility(label: String, help: String? = nil) { + statusItem.button?.setAccessibilityLabel(label) + statusItem.button?.setAccessibilityHelp(help) + } + public func showPopover() { guard let button = statusItem.button else { return } popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY) diff --git a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift index f9076f0..3a5a747 100644 --- a/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift +++ b/Tests/MeanwhileCoreTests/AgentEventIntegrationTests.swift @@ -74,6 +74,59 @@ final class AgentEventIntegrationTests: XCTestCase { XCTAssertEqual(store.latestEvent(), session) } + func testInspectsAndExplicitlyClearsOnlyStuckActiveSessions() throws { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + defer { try? FileManager.default.removeItem(at: directory) } + let store = AgentEventStore(directory: directory) + let now = Date(timeIntervalSince1970: 100_000) + let fresh = AgentSessionState( + provider: .claude, + sessionID: "fresh", + cwd: "/private/fresh", + phase: .thinking, + enteredAt: now.addingTimeInterval(-300), + updatedAt: now.addingTimeInterval(-300) + ) + let stuck = AgentSessionState( + provider: .codex, + sessionID: "stuck", + cwd: "/private/stuck", + phase: .needsYou, + enteredAt: now.addingTimeInterval(-7_200), + updatedAt: now.addingTimeInterval(-7_200) + ) + try store.write(fresh) + try store.write(stuck) + + XCTAssertEqual( + store.inspectSessions(now: now, staleAfter: 3_600), + AgentSessionInspection( + activeCount: 2, + stuckCount: 1, + oldestStuckUpdate: stuck.updatedAt + ) + ) + XCTAssertEqual( + try store.clearStuckSessions(now: now, staleAfter: 3_600), + 1 + ) + XCTAssertEqual( + store.inspectSessions(now: now, staleAfter: 3_600), + AgentSessionInspection( + activeCount: 1, + stuckCount: 0, + oldestStuckUpdate: nil + ) + ) + XCTAssertEqual( + store.session(provider: .claude, sessionID: fresh.sessionID), + fresh + ) + XCTAssertNil(store.session(provider: .codex, sessionID: stuck.sessionID)) + XCTAssertEqual(store.latestEvent(), stuck) + } + func testNotificationPermissionPromptMapsToNeedsYou() throws { let data = """ {"session_id":"abc","cwd":"/tmp","hook_event_name":"Notification","notification_type":"permission_prompt"} diff --git a/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift b/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift new file mode 100644 index 0000000..7f8bb0b --- /dev/null +++ b/Tests/MeanwhileCoreTests/AgentFocusRouterTests.swift @@ -0,0 +1,142 @@ +import Foundation +import XCTest +@testable import MeanwhileCore + +final class AgentFocusRouterTests: XCTestCase { + func testBuildsExactCodexThreadURLForUUIDSession() { + let session = makeSession( + provider: .codex, + sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b" + ) + + XCTAssertEqual( + AgentFocusRouter.codexThreadURL(for: session)?.absoluteString, + "codex://threads/019f5ee8-576e-74b3-8e9a-2b6e2404970b" + ) + } + + func testRejectsUnvalidatedFocusURLs() { + XCTAssertNil( + AgentFocusRouter.codexThreadURL( + for: makeSession(provider: .codex, sessionID: "not-a-uuid") + ) + ) + XCTAssertNil( + AgentFocusRouter.codexThreadURL( + for: makeSession( + provider: .claude, + sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b" + ) + ) + ) + } + + @MainActor + func testAcceptedCodexURLDoesNotInvokeTerminalFallback() { + var openedURL: URL? + var terminalFocusCount = 0 + let router = AgentFocusRouter( + openURL: { url in + openedURL = url + return true + }, + focusTerminal: { _ in + terminalFocusCount += 1 + return true + } + ) + + XCTAssertEqual( + router.focus(makeSession(provider: .codex, sessionID: validSessionID)), + .codexTask + ) + XCTAssertEqual( + openedURL?.absoluteString, + "codex://threads/\(validSessionID)" + ) + XCTAssertEqual(terminalFocusCount, 0) + } + + @MainActor + func testRejectedCodexURLFallsBackToTerminalOnce() { + var terminalFocusCount = 0 + let router = AgentFocusRouter( + openURL: { _ in false }, + focusTerminal: { _ in + terminalFocusCount += 1 + return true + } + ) + + XCTAssertEqual( + router.focus(makeSession(provider: .codex, sessionID: validSessionID)), + .terminalFallback + ) + XCTAssertEqual(terminalFocusCount, 1) + } + + @MainActor + func testInvalidCodexIDSkipsURLAndFallsBackToTerminal() { + var openURLCount = 0 + let router = AgentFocusRouter( + openURL: { _ in + openURLCount += 1 + return true + }, + focusTerminal: { _ in true } + ) + + XCTAssertEqual( + router.focus(makeSession(provider: .codex, sessionID: "not-a-uuid")), + .terminalFallback + ) + XCTAssertEqual(openURLCount, 0) + } + + @MainActor + func testClaudeNeverAttemptsCodexURL() { + var openURLCount = 0 + let router = AgentFocusRouter( + openURL: { _ in + openURLCount += 1 + return true + }, + focusTerminal: { _ in true } + ) + + XCTAssertEqual( + router.focus(makeSession(provider: .claude, sessionID: validSessionID)), + .terminalFallback + ) + XCTAssertEqual(openURLCount, 0) + } + + @MainActor + func testReportsUnavailableWhenEveryRouteFails() { + let router = AgentFocusRouter( + openURL: { _ in false }, + focusTerminal: { _ in false } + ) + + XCTAssertEqual( + router.focus(makeSession(provider: .codex, sessionID: validSessionID)), + .unavailable + ) + } + + private let validSessionID = "019f5ee8-576e-74b3-8e9a-2b6e2404970b" + + private func makeSession( + provider: AgentProvider, + sessionID: String + ) -> AgentSessionState { + AgentSessionState( + provider: provider, + sessionID: sessionID, + cwd: "/Users/example/Developer/Meanwhile", + phase: .needsYou, + enteredAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) + ) + } +} diff --git a/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift new file mode 100644 index 0000000..456c154 --- /dev/null +++ b/Tests/MeanwhileCoreTests/DiagnosticsReportTests.swift @@ -0,0 +1,88 @@ +import Foundation +import XCTest +@testable import MeanwhileCore + +final class DiagnosticsReportTests: XCTestCase { + func testReportIsUsefulButOmitsPrivateContext() { + let generatedAt = Date(timeIntervalSince1970: 1_700_000_000) + let eventDate = generatedAt.addingTimeInterval(-120) + let signalDate = generatedAt.addingTimeInterval(-60) + let privateRepository = "private-owner/secret-repository" + let privatePath = "/Users/tom/Clients/Secret" + let privateSessionID = "session-super-secret" + let snapshot = DiagnosticsSnapshot( + appVersion: "0.1.2", + buildVersion: "3", + operatingSystemVersion: "macOS 15.5 (24F74)", + launchAtLoginStatus: .requiresApproval, + updateState: .updateAvailable( + version: "v0.1.3", + releaseURL: URL(string: "https://example.com/private-release-url")! + ), + integrationHealth: AgentIntegrationHealth( + state: .needsReview, + claudeHooksInstalled: true, + codexHooksInstalled: false, + claudeStatuslineConflict: true + ), + githubAuthenticationStatus: .authenticated, + repositoryScopeIncludesAll: false, + accessibleRepositoryCount: 68, + selectedRepositoryCount: 3, + hotKeyConfigured: true, + sessionInspection: AgentSessionInspection( + activeCount: 2, + stuckCount: 1, + oldestStuckUpdate: eventDate + ), + lastAgentEvent: AgentSessionState( + provider: .codex, + sessionID: privateSessionID, + cwd: privatePath, + phase: .needsYou, + enteredAt: eventDate, + updatedAt: eventDate, + terminal: TerminalContext( + program: "SecretTerminal", + sessionID: "private-terminal-id", + tty: "/dev/private-tty" + ) + ), + recentSignals: [ + RecentSignal( + kind: .ciFailed, + title: "Secret CI title", + detail: privateRepository, + date: signalDate + ) + ] + ) + + let report = MeanwhileDiagnosticsReport.make( + snapshot: snapshot, + generatedAt: generatedAt + ) + + XCTAssertTrue(report.contains("App: 0.1.2 (3)")) + XCTAssertTrue(report.contains("Schema: 1")) + XCTAssertTrue(report.contains("Launch at login: needs approval")) + XCTAssertTrue(report.contains("Update: available (v0.1.3)")) + XCTAssertTrue(report.contains("Repository scope: selected only (3)")) + XCTAssertTrue(report.contains("Sessions that may be stuck: 1")) + XCTAssertTrue(report.contains("Last agent event: codex, needs-you")) + XCTAssertTrue(report.contains("Recent ciFailed: 1")) + + for privateValue in [ + privateRepository, + privatePath, + privateSessionID, + "SecretTerminal", + "private-terminal-id", + "/dev/private-tty", + "Secret CI title", + "private-release-url" + ] { + XCTAssertFalse(report.contains(privateValue)) + } + } +} diff --git a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift index 50a7537..cc19096 100644 --- a/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift +++ b/Tests/MeanwhileCoreTests/MenuBarPresenterTests.swift @@ -11,18 +11,133 @@ final class MenuBarPresenterTests: XCTestCase { func testFormatsEachItemKindCompactly() { XCTAssertNil(MenuBarPresenter.statusText(item: nil)) - XCTAssertEqual(MenuBarPresenter.statusText(item: item(.needsYou, title: "Claude needs you")), "Needs you") + XCTAssertEqual( + MenuBarPresenter.statusText(item: item(.needsYou, title: "Claude needs you")), + "Claude needs you" + ) + XCTAssertEqual( + MenuBarPresenter.statusText(item: item(.needsYou, title: "Codex needs you")), + "Codex needs you" + ) XCTAssertEqual(MenuBarPresenter.statusText(item: item(.failingCI, title: "CI failed on #4")), "CI!") XCTAssertEqual(MenuBarPresenter.statusText(item: item(.review, title: "Review #78")), "#78") } - private func item(_ kind: WorkItemKind, title: String) -> WorkItem { + func testNeedsYouCopyAddsProjectContextOutsideTheStatusTitle() { + let item = item( + .needsYou, + title: "Codex needs you", + session: session(provider: .codex, cwd: "/Users/example/Developer/Meanwhile") + ) + + XCTAssertEqual(MenuBarPresenter.statusText(item: item), "Codex needs you") + XCTAssertEqual(MenuBarPresenter.projectName(item: item), "Meanwhile") + XCTAssertEqual( + MenuBarPresenter.openActionTitle(item: item), + "Return to Codex — Meanwhile" + ) + XCTAssertEqual( + MenuBarPresenter.tooltip(phase: .needsYou, item: item), + "Codex needs you in “Meanwhile” — click to return" + ) + XCTAssertEqual( + MenuBarPresenter.accessibilityLabel(phase: .needsYou, item: item), + "Codex needs you in the Meanwhile project" + ) + XCTAssertEqual( + MenuBarPresenter.accessibilityHelp(phase: .needsYou, item: item), + "Returns to the waiting Codex task." + ) + } + + func testNeedsYouCopyFallsBackCleanlyWithoutAProject() { + let item = item( + .needsYou, + title: "Claude needs you", + session: session(provider: .claude, cwd: "/") + ) + + XCTAssertNil(MenuBarPresenter.projectName(item: item)) + XCTAssertEqual(MenuBarPresenter.openActionTitle(item: item), "Return to Claude") + XCTAssertEqual( + MenuBarPresenter.tooltip(phase: .needsYou, item: item), + "Claude needs you — click to return" + ) + } + + func testAccessibilityNamesVisibleReviewAndCIItemsWhileAgentIsThinking() { + XCTAssertEqual( + MenuBarPresenter.accessibilityLabel( + phase: .thinking, + item: item(.review, title: "Review #78") + ), + "Review #78, acme/repo" + ) + XCTAssertEqual( + MenuBarPresenter.accessibilityLabel( + phase: .thinking, + item: item(.failingCI, title: "CI failed on #4") + ), + "CI failed on #4, acme/repo" + ) + } + + func testProjectNameRejectsPrivateOrUnhelpfulContexts() { + let rejected = [ + "/", + FileManager.default.homeDirectoryForCurrentUser.path, + "/tmp/.", + "/tmp/..", + "/tmp/bad\nname" + ] + + for cwd in rejected { + let item = item( + .needsYou, + title: "Codex needs you", + session: session(provider: .codex, cwd: cwd) + ) + XCTAssertNil(MenuBarPresenter.projectName(item: item), cwd) + } + } + + func testProjectNameMiddleTruncatesLongBasename() throws { + let item = item( + .needsYou, + title: "Codex needs you", + session: session( + provider: .codex, + cwd: "/tmp/very-long-project-name-that-needs-a-readable-tail" + ) + ) + + let project = try XCTUnwrap(MenuBarPresenter.projectName(item: item)) + XCTAssertEqual(project, "very-long-proje…-a-readable-tail") + } + + private func item( + _ kind: WorkItemKind, + title: String, + session: AgentSessionState? = nil + ) -> WorkItem { WorkItem( id: UUID().uuidString, kind: kind, title: title, detail: "acme/repo", - createdAt: Date(timeIntervalSince1970: 1) + createdAt: Date(timeIntervalSince1970: 1), + session: session + ) + } + + private func session(provider: AgentProvider, cwd: String) -> AgentSessionState { + AgentSessionState( + provider: provider, + sessionID: "019f5ee8-576e-74b3-8e9a-2b6e2404970b", + cwd: cwd, + phase: .needsYou, + enteredAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1) ) } } diff --git a/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift b/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift new file mode 100644 index 0000000..ea838ad --- /dev/null +++ b/Tests/MeanwhileCoreTests/ReleaseUpdateCheckerTests.swift @@ -0,0 +1,116 @@ +import Foundation +import XCTest +@testable import MeanwhileCore + +final class ReleaseUpdateCheckerTests: XCTestCase { + func testReportsNewerRelease() throws { + let checker = ReleaseUpdateChecker( + runner: StubRunner(stdout: payload(tag: "v0.1.3")) + ) + + XCTAssertEqual( + try checker.check(currentVersion: "0.1.2"), + .updateAvailable( + version: "v0.1.3", + releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.3")! + ) + ) + } + + func testReportsCurrentAndDevelopmentBuilds() throws { + let checker = ReleaseUpdateChecker( + runner: StubRunner(stdout: payload(tag: "v0.1.2")) + ) + XCTAssertEqual( + try checker.check(currentVersion: "0.1.2"), + .current( + latestVersion: "v0.1.2", + releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.2")! + ) + ) + + let developmentChecker = ReleaseUpdateChecker( + runner: StubRunner(stdout: payload(tag: "v0.1.1")) + ) + XCTAssertEqual( + try developmentChecker.check(currentVersion: "0.1.2"), + .developmentBuild( + latestVersion: "v0.1.1", + releaseURL: URL(string: "https://github.com/tcballard/Meanwhile/releases/tag/v0.1.1")! + ) + ) + } + + func testSemanticVersionComparisonIsNumeric() throws { + XCTAssertLessThan( + try XCTUnwrap(SemanticVersion("v0.1.9")), + try XCTUnwrap(SemanticVersion("0.1.10")) + ) + XCTAssertEqual( + SemanticVersion("1.2"), + SemanticVersion("v1.2.0") + ) + XCTAssertNil(SemanticVersion("release-next")) + } + + func testReturnsPurposefulErrorsWithoutCommandOutput() { + let failed = ReleaseUpdateChecker( + runner: StubRunner( + exitCode: 1, + stdout: "", + stderr: "token secret and private path" + ) + ) + XCTAssertThrowsError(try failed.check(currentVersion: "0.1.2")) { + XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .commandFailed) + XCTAssertFalse($0.localizedDescription.contains("token secret")) + } + + let malformed = ReleaseUpdateChecker( + runner: StubRunner(stdout: "not json") + ) + XCTAssertThrowsError(try malformed.check(currentVersion: "0.1.2")) { + XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .invalidResponse) + } + + let untrustedURL = ReleaseUpdateChecker( + runner: StubRunner( + stdout: #"{"tagName":"v0.1.3","url":"https://example.com/fake"}"# + ) + ) + XCTAssertThrowsError(try untrustedURL.check(currentVersion: "0.1.2")) { + XCTAssertEqual($0 as? ReleaseUpdateCheckerError, .invalidResponse) + } + } + + private func payload(tag: String) -> String { + """ + {"tagName":"\(tag)","url":"https://github.com/tcballard/Meanwhile/releases/tag/\(tag)"} + """ + } +} + +private struct StubRunner: CommandRunning { + var result: ShellCommandResult + + init( + exitCode: Int32 = 0, + stdout: String, + stderr: String = "" + ) { + result = ShellCommandResult( + exitCode: exitCode, + stdout: stdout, + stderr: stderr + ) + } + + func run( + _ command: String, + timeoutSeconds: TimeInterval + ) throws -> ShellCommandResult { + XCTAssertEqual(command, ReleaseUpdateChecker.command) + XCTAssertEqual(timeoutSeconds, 15) + return result + } +} diff --git a/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift b/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift new file mode 100644 index 0000000..467c8e6 --- /dev/null +++ b/Tests/MeanwhileCoreTests/TerminalFocuserTests.swift @@ -0,0 +1,72 @@ +import Foundation +import XCTest +@testable import MeanwhileCore + +final class TerminalFocuserTests: XCTestCase { + @MainActor + func testUsesOnlyRecordedAppleTerminalRoute() { + var scripts: [String] = [] + let focuser = TerminalFocuser { script in + scripts.append(script) + return true + } + + XCTAssertTrue(focuser.focus(session(program: "Apple_Terminal", tty: "/dev/ttys001"))) + XCTAssertEqual(scripts.count, 1) + XCTAssertTrue(scripts[0].contains("tell application \"Terminal\"")) + XCTAssertFalse(scripts[0].contains("tell application \"iTerm2\"")) + } + + @MainActor + func testUsesOnlyRecordedITermRoute() { + var scripts: [String] = [] + let focuser = TerminalFocuser { script in + scripts.append(script) + return true + } + + XCTAssertTrue(focuser.focus(session(program: "iTerm.app", tty: "/dev/ttys001"))) + XCTAssertEqual(scripts.count, 1) + XCTAssertTrue(scripts[0].contains("tell application \"iTerm2\"")) + XCTAssertFalse(scripts[0].contains("tell application \"Terminal\"")) + } + + @MainActor + func testUnknownMissingAndUnsupportedProgramsFailClosed() { + var scriptCount = 0 + let focuser = TerminalFocuser { _ in + scriptCount += 1 + return true + } + + XCTAssertFalse(focuser.focus(session(program: nil, tty: "/dev/ttys001"))) + XCTAssertFalse(focuser.focus(session(program: "SecretTerminal", tty: "/dev/ttys001"))) + XCTAssertFalse(focuser.focus(session(program: "WarpTerminal", tty: "/dev/ttys001"))) + XCTAssertFalse(focuser.focus(session(program: "Apple_Terminal", tty: nil))) + XCTAssertEqual(scriptCount, 0) + } + + @MainActor + func testStaleExactTTYReportsFailureWithoutAppOnlyActivation() { + var scriptCount = 0 + let focuser = TerminalFocuser { _ in + scriptCount += 1 + return false + } + + XCTAssertFalse(focuser.focus(session(program: "Apple_Terminal", tty: "/dev/ttys999"))) + XCTAssertEqual(scriptCount, 1) + } + + private func session(program: String?, tty: String?) -> AgentSessionState { + AgentSessionState( + provider: .claude, + sessionID: "session-1", + cwd: "/Users/example/Developer/Meanwhile", + phase: .needsYou, + enteredAt: Date(timeIntervalSince1970: 1), + updatedAt: Date(timeIntervalSince1970: 1), + terminal: TerminalContext(program: program, tty: tty) + ) + } +} diff --git a/docs/v0.1.2-always-ready.md b/docs/v0.1.2-always-ready.md new file mode 100644 index 0000000..56c2a2a --- /dev/null +++ b/docs/v0.1.2-always-ready.md @@ -0,0 +1,70 @@ +# Meanwhile v0.1.2 — Always Ready + +v0.1.2 is a reliability and self-service release. It makes the existing menu-bar +workflow easier to trust without expanding Meanwhile into a broader v0.2 +product. + +## Product contract + +### Agent-aware menu-bar signal + +- When an agent needs attention, the existing red speech-bubble status item + expands to name the agent: for example, `Codex needs you`. +- The bloom remains one native menu-bar button; its whole visible area returns + the user to the waiting agent session. +- Hover text and the context-menu return action name the working-directory + project without expanding the persistent status-item title. +- Codex returns through its validated `codex://threads/` task link; + supported terminal-backed sessions retain their exact TTY route, while stale + or unsupported focus targets fail visibly instead of activating an arbitrary + terminal or terminal window. +- Idle remains icon-only, and review and failing-CI labels remain compact. + +### Launch at login + +- Settings contains a native Launch at Login switch. +- The switch reflects the system's real Service Management state. +- If macOS requires approval, Settings explains that state and offers a direct + route to Login Items. +- Meanwhile never silently changes the login-item setting. + +### Update visibility + +- Settings shows the installed version and the latest published GitHub release. +- Meanwhile checks once when Settings opens and supports an explicit recheck. +- A newer release can be opened in the browser. +- v0.1.2 does not download, install, or relaunch itself. + +### Privacy-safe diagnostics + +- Settings can copy a plain-text support report to the clipboard. +- The report includes versions, coarse health states, counts, and timestamps. +- It excludes repository names, working directories, file paths, session IDs, + terminal metadata, signal titles/details, command output, and credentials. +- Meanwhile adds no telemetry and sends no report automatically. + +### Stuck-session recovery + +- Settings identifies non-idle agent sessions with no event for longer than the + configured stale-session interval. +- Suspected sessions remain active until their existing crash-safety expiry so + Meanwhile does not silently change the wait-gate. +- The user can clear only suspected stuck sessions after a destructive-action + confirmation. +- The latest event snapshot remains available for diagnostics after cleanup. + +## Non-goals + +- Automatic updates or a background update service. +- Remote diagnostics, analytics, or crash uploads. +- Session history, per-session management, or new work sources. +- A multi-session roster, waiting count, or attention inbox. +- Any change to the needs-you, CI, review, snooze, or hide ordering contract. + +## Acceptance + +- Existing tests remain green and new contract behavior has deterministic unit + coverage. +- The packaged macOS app builds, signs, launches, and exposes the complete flow + through Settings. +- The version is 0.1.2 (build 3).