Skip to content
Open
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 CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ CI runs the suite on every push and PR — `swift test` is one of the required c
- `shared/` — code shared by both binaries (currently `AppActivator.swift`)
- `phrases/` — per-language voice phrase pools sourced by `notify.sh` at hook time
- `notify.sh` — the shell entry-point CC/Cursor/Gemini hooks invoke; routes events to banner / panel / voice surfaces
- `assets/` — logo / icon source files
- `panel/Companion/`: the companion character (skin engine in `LayerRig` and `BakedRig`, its skins, window and menu)
- `assets/`: logo / icon source files, plus `assets/skins/` (the companion's baked art, copied into the app bundle by `build.sh`)

## macOS permissions during development

Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ Independent of quota: stack-nudge can also fire a banner when an individual Clau

#### Settings tab

Reachable from the tab strip or `Cmd+5`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, a Mute/Resume action row with mute duration, unanswered-prompt reminder interval, stalled-session threshold, pin panel, launch at login), widget (snap-to-corners toggle, corner, mascot picker, opacity), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold + show-remaining), event history (recording toggle + clear), Slack (paste setup, detected user, test message, notifications toggle, idle threshold, message detail, finished-turn toggle), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit).
Reachable from the tab strip or `Cmd+5`. Keyboard-driven rows for hotkey, behavior toggles (banner, mute when focused, a Mute/Resume action row with mute duration, unanswered-prompt reminder interval, stalled-session threshold, pin panel, launch at login), widget (snap-to-corners toggle, corner, mascot picker, opacity), companion (on/off, count badge, size, pose preview, reset position), sound picks (with preview-on-cycle), voice notifications + picker + speed (with preview-on-cycle using a random conversational phrase), usage config (quota tracking + alerts + threshold + poll frequency + context alert threshold + show-remaining), event history (recording toggle + clear), Slack (paste setup, detected user, test message, notifications toggle, idle threshold, message detail, finished-turn toggle), and action rows (edit phrases, check permissions, open config file, view release notes, check for updates, uninstall, quit).

| Key | Action |
|-----|--------|
Expand Down Expand Up @@ -352,6 +352,24 @@ grep -c stack-nudge-version ~/.stack-nudge/notify.sh # 0 means the script pred
./install.sh # or reinstall from the app
```

### Companion (macOS)

The companion is a small animated character that floats on your desktop and takes the pill widget's place while it is on. New installs start with it; on an existing install you are offered it once after an update, or you can choose it in **Settings → Appearance → Floating widget**, which is `Pill`, `Companion` or `Off` (`STACKNUDGE_FLOATING_WIDGET`). The pill's own rows (corner, opacity, type, mascot, snap) apply only to `Pill`. A config from before that setting is migrated on first load: `STACKNUDGE_COMPANION=true` becomes `companion`, else the old `STACKNUDGE_COMPACT_MODE` (on unless `false`) becomes `pill`, else `off`. On the update screen, `Tab` / `⇧Tab` move between **Turn on** and **Got it** (focus starts on **Got it**) and `⏎` presses the one with the ring.

- **Click** the character to open the panel beside it. The hotkey opens it there too.
- **Drag** it anywhere; it stays where you drop it, and comes back on screen if a display it was on goes away.
- **Right-click** for the menu, including a **Companion** submenu with its size and **Reset position**, which puts it back where a fresh install starts it.

Its pose follows what stack-nudge knows: thinking while an agent is busy, watching while agent sessions are live, waiting when an event or a permission prompt is queued, alert when a quota is past your alert threshold, muted while notifications are muted, and asleep after five quiet minutes. The waiting pose is a wave: it means something wants you to act, and a finished agent turn is one of those, so it still waves. A new finished turn also gets a single small nod on arrival, when the character is otherwise calm (idle or asleep), not muted, and Reduce Motion is off. A count badge at its corner shows how many events are waiting, grey while muted. It animates on the render server and pauses while it is covered, the display sleeps or the screen is locked, so it stays near 0% CPU. With Reduce Motion on, it holds still.

| Setting | Key | Values |
|---------|-----|--------|
| Floating widget (Appearance) | `STACKNUDGE_FLOATING_WIDGET` | `pill`, `companion`, `off` |
| Size | `STACKNUDGE_COMPANION_SIZE` | `small`, `medium` (default), `large` |
| Count badge | `STACKNUDGE_COMPANION_BADGE` | `true` (default) / `false` |

The Companion tab holds only the character's own settings. While Floating widget isn't `Companion` it shows a note and a **Turn on companion** row that switches it, and the settings are dimmed. `Space` or `⏎` flips Count badge, runs Reset position and Turn on companion, and on Floating widget, Size and Preview pose starts picking, as on every other row. Preview pose shows any pose on the character without changing anything else; it isn't saved. Holding `⌥` while the menu is open also reveals a **Debug** submenu under Companion with the same pose preview and an **Info (nod)** entry that plays the nod once. The character's last spot is kept in `STACKNUDGE_COMPANION_POS`.

### Menu bar (macOS)

When the panel daemon is running, a bell icon appears in your menu bar. The same items you can reach from the in-panel Settings tab are mirrored here for one-click access without summoning the panel:
Expand All @@ -362,6 +380,7 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same
| `Show banners` | Toggles macOS banner notifications. Enabling fires a confirmation banner. |
| `Voice notifications` | Toggles spoken notifications. Enabling speaks *"Voice notifications enabled"*. |
| `Mute notifications ▸` | Submenu (For 15m / 30m / 1h / 2h) to silence all nudges for a set time. While muted the item becomes `Resume notifications` and the menu-bar icon shows a `bell.slash` + countdown. |
| `Companion ▸` | While the companion is on: size, Reset position, and (with `⌥` held) Debug |
| `Show panel` | Brings the floating panel up (handy when no events are queued) |
| `Settings…` (⌘,) | Opens the panel straight on Settings |
| `Check permissions…` | Opens the Permissions page in the panel (see below) |
Expand Down Expand Up @@ -515,6 +534,7 @@ make test-notify # drive notify.sh against a fake panel socket (macOS, no Xcode)
Source layout:

- `panel/` — the single persistent `stack-nudge.app` binary: hotkey, floating NSPanel, socket listener for incoming events, macOS banner posting via `UNUserNotificationCenter`, sessions list, settings and its Permissions page, auto-updater, quota probe
- `panel/Companion/`: the companion character and its skin engine. The art is baked PNG parts and a manifest per skin, committed under `assets/skins/` and copied into the app bundle by `build.sh`; building the app needs no extra tooling.
- `shared/` — code shared with the standalone Linux/Windows surfaces (currently `AppActivator.swift`)
- `phrases/` — per-language voice phrase pools sourced by `notify.sh` at hook time
- `notify.sh` — the shell entry-point CC/Cursor/Gemini hooks invoke; on macOS posts events to the running app via Unix-domain socket, on Linux/Windows handles audio + libnotify directly
Expand Down
27 changes: 27 additions & 0 deletions Tests/StackNudgePanelCoreTests/CompanionMenuTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import XCTest

@testable import StackNudgePanelCore

final class CompanionMenuOptionTests: XCTestCase {

func testDebugIsHiddenUnlessOptionIsHeld() {
XCTAssertTrue(CompanionMenu.isDebugHidden(optionHeld: false))
XCTAssertFalse(CompanionMenu.isDebugHidden(optionHeld: true))
}

@MainActor
func testApplyOptionTogglesOnlyTheTaggedItem() {
let menu = NSMenu()
let other = NSMenuItem(title: "Other", action: nil, keyEquivalent: "")
let debug = NSMenuItem(title: "Debug", action: nil, keyEquivalent: "")
debug.tag = CompanionMenu.optionOnlyTag
menu.addItem(other)
menu.addItem(debug)
let entries = CompanionMenu(nav: PanelNav())
entries.applyOption(true, to: menu)
XCTAssertFalse(debug.isHidden)
entries.applyOption(false, to: menu)
XCTAssertTrue(debug.isHidden)
XCTAssertFalse(other.isHidden)
}
}
56 changes: 56 additions & 0 deletions Tests/StackNudgePanelCoreTests/CompanionNodTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import XCTest

@testable import StackNudgePanelCore

final class CompanionNodTests: XCTestCase {

private let now = Date()

private func agent(_ kind: NudgeKind) -> NudgeEvent {
NudgeEvent(agent: "claude", kind: kind, title: "t", message: "m")
}

private func nod(_ arrivals: [NudgeEvent], interrupts: @escaping (NudgeEvent) -> Bool = { _ in true },
muted: Bool = false, reduceMotion: Bool = false, gateActive: Bool = true,
before: SkinMode = .idle, after: SkinMode = .idle, last: Date? = nil) -> Bool {
CompanionNod.shouldNod(arrivals: arrivals, interrupts: interrupts, muted: muted, reduceMotion: reduceMotion,
gateActive: gateActive, before: before, after: after, lastNod: last, now: now)
}

func testAFinishedTurnNodsWhenIdleOrSleeping() {
XCTAssertTrue(nod([agent(.stop)]))
XCTAssertTrue(nod([agent(.stop)], before: .sleeping))
}

func testBusyPosesSkipIt() {
for pose in [SkinMode.alert, .pending, .thinking, .error, .muted] {
XCTAssertFalse(nod([agent(.stop)], before: pose), "before \(pose)")
XCTAssertFalse(nod([agent(.stop)], after: pose), "after \(pose)")
}
}

func testOnlyAFinishedTurnCounts() {
XCTAssertFalse(nod([agent(.permission)]))
XCTAssertFalse(nod([agent(.other)]))
XCTAssertFalse(nod([]))
XCTAssertTrue(nod([agent(.permission), agent(.stop)]))
}

func testEventsThatMayNotInterruptDoNotCount() {
XCTAssertFalse(nod([agent(.stop)], interrupts: { _ in false }))
let quiet = agent(.stop)
let loud = agent(.stop)
XCTAssertTrue(nod([quiet, loud], interrupts: { $0.id == loud.id }))
}

func testMutedReduceMotionAndAnInactiveGateSkipIt() {
XCTAssertFalse(nod([agent(.stop)], muted: true))
XCTAssertFalse(nod([agent(.stop)], reduceMotion: true))
XCTAssertFalse(nod([agent(.stop)], gateActive: false))
}

func testABurstWithinTheWindowNodsOnce() {
XCTAssertFalse(nod([agent(.stop)], last: now.addingTimeInterval(-1)))
XCTAssertTrue(nod([agent(.stop)], last: now.addingTimeInterval(-3)))
}
}
Loading
Loading