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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- **Separate Claude accounts**: `bin/claude-profile` runs Claude Code with the account that owns the current folder, from `~/.config/claude-profiles`, and its `guard` hook blocks prompts sent from the wrong account.
- The Claude tab has an account switcher for projects, sessions, cost and plan limits, and running Claude sessions show which account they use.
- **GitHub accounts per folder**: `claude-profile gh` runs `gh` as the GitHub login a profile names in a `github` line of `~/.config/claude-profiles`; the account switcher shows each profile's login, or a "Sign in to GitHub" button.
- **Keep awake**: a cup button in the menu bar app header stops the Mac from sleeping and, after 50 seconds idle, nudges the cursor one point and back so chat apps keep you active. The menu bar icon turns into a cup while it is on. The nudge needs Accessibility access.

## 0.3.0

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ devdash shows all of it on one screen:
- **Claude Code**: running sessions, projects and recent sessions. Resume any of them in a new Terminal window.
- **Claude usage**: your plan limits (5-hour session and weekly) and what your usage would cost at API prices.
- **Separate Claude accounts**: work and personal Claude Code accounts on one Mac, picked by the folder you start in.
- **Keep awake**: a mouse jiggler in the menu bar. Stops the Mac sleeping and keeps Teams and Slack from marking you away.
- **System**: CPU, memory, swap, disk, network and the heaviest processes.

## Install
Expand Down Expand Up @@ -62,7 +63,7 @@ Run it again to update. You can also run `devdash --update`, or use the prompt t
open ~/Applications/DevDash.app
```

The menu bar shows how many dev servers and containers are running, plus your current Claude session usage (for example `9 · 42%`). The icon switches to a flame when CPU or memory goes over your configured threshold. Click it to open the dashboard.
The menu bar shows how many dev servers and containers are running, plus your current Claude session usage (for example `9 · 42%`). The icon switches to a flame when CPU or memory goes over your configured threshold, and to a cup while keep awake is on. Click it to open the dashboard.

| Running, by project | Project overview | System |
|---|---|---|
Expand All @@ -85,6 +86,7 @@ What you can do:
- project pages with one-click launch: new session, continue, plan mode, resume picker, skip permissions, or a plain terminal in that folder.
- **Cleanup**: listed under Needs you when devdash finds idle, orphaned or zombie processes, or containers running longer than your stale threshold. Pick which ones to remove and clean them up together.
- **Footer**: Disk tree, dependency graph and activity heatmap. The `...` menu has JSON export, the terminal UI, What's new, Check for updates and Quit.
- **Keep awake**: the cup in the header. While it's on, the Mac doesn't sleep, and after 50 seconds without input devdash moves the cursor one point and back, too fast to see, so Teams and Slack keep you active. Moving the cursor needs Accessibility access, which macOS asks for the first time you turn it on; without it only the sleep prevention works. It stays on across restarts. More in [Mouse jiggler for Mac](https://bavrk.com/articles/mouse-jiggler-for-mac).
- **Keyboard**: Cmd+1 to Cmd+3 switch tabs, Cmd+F filters, Esc clears the filter.
- **Notifications**: a macOS notification when a dev server starts or exits, a container stops, or a watched port comes up.
- **Settings**: launch at login, editor (VS Code or Cursor), whether the menu bar shows the count and usage percentage, notifications, and a custom `devdash` path.
Expand Down
8 changes: 6 additions & 2 deletions macos/Sources/DevDashBar/DevDashBarApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
// Also applies when run unbundled via `swift run`, where LSUIElement is absent.
NSApp.setActivationPolicy(.accessory)
MainActor.assumeIsolated { Updater.shared.start() }
MainActor.assumeIsolated {
Updater.shared.start()
Jiggler.shared.start()
}
}

func applicationWillTerminate(_ notification: Notification) {
Expand Down Expand Up @@ -95,7 +98,8 @@ struct MenuBarLabel: View {
private var symbol: String {
switch store.connection {
case .failed, .missingBinary: "exclamationmark.triangle"
default: store.isUnderPressure ? "flame" : "server.rack"
default:
if store.isUnderPressure { "flame" } else if Jiggler.shared.isOn { "cup.and.saucer.fill" } else { "server.rack" }
}
}
}
90 changes: 90 additions & 0 deletions macos/Sources/DevDashBar/Jiggler.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import AppKit
import IOKit.pwr_mgt
import Observation

/// Keeps the Mac awake and the user active while on: holds a display sleep assertion and, once
/// the user has been idle for a while, nudges the cursor one point and back so chat apps stay green.
/// The nudge needs Accessibility access; without it only the power assertion and a declared user
/// activity are used.
@MainActor
@Observable
final class Jiggler {
static let shared = Jiggler()

private static let enabledKey = "jigglerEnabled"
private static let checkInterval: Duration = .seconds(20)
private static let idleThreshold: TimeInterval = 50

private(set) var isOn = false
/// Whether the cursor nudge can be posted; refreshed on every check.
private(set) var canNudge = CGPreflightPostEventAccess()

@ObservationIgnored private var assertion: IOPMAssertionID = 0
@ObservationIgnored private var activity: IOPMAssertionID = 0
@ObservationIgnored private var loop: Task<Void, Never>?

/// Restores the state from the last launch.
func start() {
if UserDefaults.standard.bool(forKey: Self.enabledKey) { setOn(true) }
}

func toggle() {
// Shows the system prompt once; the nudge starts working as soon as access is granted.
if !isOn, !CGPreflightPostEventAccess() { CGRequestPostEventAccess() }
setOn(!isOn)
}

func setOn(_ on: Bool) {
guard on != isOn else { return }
isOn = on
UserDefaults.standard.set(on, forKey: Self.enabledKey)
if on {
IOPMAssertionCreateWithName(
kIOPMAssertionTypePreventUserIdleDisplaySleep as CFString,
IOPMAssertionLevel(kIOPMAssertionLevelOn),
"devdash jiggler" as CFString,
&assertion
)
loop = Task { [weak self] in
while !Task.isCancelled {
self?.tick()
try? await Task.sleep(for: Self.checkInterval)
}
}
} else {
loop?.cancel()
loop = nil
release(&assertion)
release(&activity)
}
}

private func tick() {
canNudge = CGPreflightPostEventAccess()
guard idleSeconds >= Self.idleThreshold else { return }
if canNudge { nudge() }
// Resets the system idle timer even without Accessibility access.
IOPMAssertionDeclareUserActivity("devdash jiggler" as CFString, kIOPMUserActiveLocal, &activity)
}

private var idleSeconds: TimeInterval {
// kCGAnyInputEventType: time since any keyboard, mouse or tablet event.
CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: CGEventType(rawValue: ~0)!)
}

/// Moves the cursor one point and straight back, too fast to see.
private func nudge() {
guard let location = CGEvent(source: nil)?.location else { return }
let source = CGEventSource(stateID: .hidSystemState)
for point in [CGPoint(x: location.x + 1, y: location.y), location] {
CGEvent(mouseEventSource: source, mouseType: .mouseMoved, mouseCursorPosition: point, mouseButton: .left)?
.post(tap: .cghidEventTap)
}
}

private func release(_ id: inout IOPMAssertionID) {
guard id != 0 else { return }
IOPMAssertionRelease(id)
id = 0
}
}
14 changes: 14 additions & 0 deletions macos/Sources/DevDashBar/Views/PopoverView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -209,13 +209,27 @@ private struct HeaderView: View {
.foregroundStyle(.tertiary)
}
}
IconButton(
symbol: jiggler.isOn ? "cup.and.saucer.fill" : "cup.and.saucer",
help: jigglerHelp,
tint: jiggler.isOn ? .accentColor : .primary
) { jiggler.toggle() }
IconButton(symbol: "arrow.clockwise", help: "Refresh now") { store.refresh() }
}
.padding(.horizontal, 12)
.padding(.top, 10)
.padding(.bottom, 8)
}

private var jiggler: Jiggler { .shared }

private var jigglerHelp: String {
guard jiggler.isOn else { return "Keep awake: stop the Mac from sleeping and you from showing as away" }
return jiggler.canNudge
? "Keeping awake. Click to stop"
: "Keeping awake, without the cursor nudge: allow devdash under Privacy & Security > Accessibility"
}

private var statusColor: Color {
switch store.connection {
case .connected: .green
Expand Down
Loading