Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
4 changes: 2 additions & 2 deletions App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<string>0.1.1</string>
<key>CFBundleVersion</key>
<string>1</string>
<string>2</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
38 changes: 24 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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.

Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions Scripts/run-meanwhile.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
160 changes: 160 additions & 0 deletions Sources/Meanwhile/ConnectionHealthSection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
82 changes: 82 additions & 0 deletions Sources/Meanwhile/ControlsSettingsSection.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Loading
Loading