From 282355b2c5634f7556db7dee66ea972cde80ee4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:34:56 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(panel):=20Settings=E2=80=A6=20in=20the?= =?UTF-8?q?=20menu=20bar=20menu=20and=20=E2=8C=98,=20to=20open=20Settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reaching Settings meant opening the panel and finding the tab. The menu bar menu gains Settings… (⌘,), which opens the panel on Settings. ⌘, inside the panel does the same through globalKeyAction, so it works from a text field and follows the same rules as ⌘←/→: not while recording a hotkey, not on screens with no tab strip, and not on a held key. PanelController.showSettings(category:) can open a given category. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 +++- .../GlobalKeyActionTests.swift | 13 ++++++++++++- panel/MenuBar.swift | 5 +++++ panel/Panel.swift | 19 ++++++++++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c72153a..ac76ff4 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ If you'd rather not click banners with the mouse, stack-nudge runs a small float The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners, toggle **Settings → Banner notifications** off (panel-only mode). -Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Outcomes), `Cmd+5` (Settings), or click them. Banner and panel can run together, alone, or both off; the sound and voice still fire as passive signals. +Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Outcomes), `Cmd+5` (Settings), or click them; `Cmd+,` also opens Settings, and so does **Settings…** in the menu bar menu. Banner and panel can run together, alone, or both off; the sound and voice still fire as passive signals. These keys work the same on every page, including while a text field has focus: @@ -144,6 +144,7 @@ These keys work the same on every page, including while a text field has focus: |-----|--------| | `Esc` | Hide the panel. In a text field it cancels the field instead: a filter, search or new phrase empties and hands the keyboard back to the page, a rename or config field keeps its saved value. While a shortcut is being recorded it cancels that and keeps the old one. The footer's last hint shows which. | | `⌘←` / `⌘→` | Previous / next tab. From a sub-page, the tabs either side of the one it belongs to. Not while recording a hotkey, so either can be recorded, and not on the setup screen or Permissions opened from it, which have no tabs. | +| `⌘,` | Open Settings, as in any Mac app. Works from a text field too; not while recording a hotkey, or on the setup and update screens. | | `⌫` in an empty text field | Cancel the field, as `Esc` does. A fresh press only: holding `⌫` to clear a field stops once it's empty. | Outside a text field, `←` is back a level on sub-pages and detail panes: out of a detail pane, or from a sub-page to the page it belongs under. The exception is picking a value in Settings, where `←` / `→` step the value. @@ -362,6 +363,7 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same | `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. | | `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) | | `Open config file…` | Opens `~/.stack-nudge/config` in your default editor | | `Quit stack-nudge panel` | Exits the daemon | diff --git a/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift b/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift index e4de21e..1198526 100644 --- a/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift +++ b/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift @@ -6,7 +6,7 @@ import XCTest // The keys that mean one thing on every page, text field or not. Raw virtual // key codes, since the panel's KeyCode table is private: 53 Esc, 123 ←, -// 124 →, 51 delete, 0 "a". +// 124 →, 43 comma, 51 delete, 0 "a". final class GlobalKeyActionTests: XCTestCase { private func action(_ keyCode: UInt16, @@ -66,6 +66,17 @@ final class GlobalKeyActionTests: XCTestCase { XCTAssertNil(action(124, .command, recording: true)) } + // ⌘, is Settings from anywhere, a text field included. A recording may want + // it as a combo, and a held key shouldn't reopen Settings over and over. + func test_commandCommaOpensSettings() { + XCTAssertEqual(action(43, .command), .openSettings) + XCTAssertEqual(action(43, .command, inEmptyField: false), .openSettings) + XCTAssertNil(action(43, .command, recording: true)) + XCTAssertNil(action(43, .command, isRepeat: true)) + XCTAssertNil(action(43)) + XCTAssertNil(action(43, [.command, .shift])) + } + // Plain arrows belong to the page, and ⇧⌘← to the field editor. func test_otherArrowsAreLeftToThePage() { XCTAssertNil(action(123)) diff --git a/panel/MenuBar.swift b/panel/MenuBar.swift index 74e5e58..5723924 100644 --- a/panel/MenuBar.swift +++ b/panel/MenuBar.swift @@ -261,6 +261,7 @@ final class MenuBarController: NSObject, NSMenuDelegate { menu.addItem(.separator()) menu.addItem(action("Show panel", #selector(showPanelAction))) + menu.addItem(action("Settings…", #selector(showSettingsAction), keyEquivalent: ",")) menu.addItem(action("Check permissions…", #selector(showPermissionsAction))) menu.addItem(action("Open config file…", #selector(openConfigAction))) menu.addItem(.separator()) @@ -390,6 +391,10 @@ final class MenuBarController: NSObject, NSMenuDelegate { panelController?.showPanel() } + @objc private func showSettingsAction() { + panelController?.showSettings() + } + @objc private func showPermissionsAction() { panelController?.showPermissions() } diff --git a/panel/Panel.swift b/panel/Panel.swift index c3fa402..0292473 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -3799,7 +3799,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // The keys that mean one thing on every page (see AGENTS.md, Keyboard // navigation): Esc cancels a text field or, outside one, hides the panel, - // ⌘←/→ step through the tabs, and ⌫ in an empty text field cancels it too. + // ⌘←/→ step through the tabs, ⌘, opens Settings, and ⌫ in an empty text field cancels it too. func panelHandlesGlobalKey(_ event: NSEvent, fieldEditor: NSTextView?) -> Bool { let mods = event.modifierFlags.intersection([.command, .control, .option, .shift]) let recording = nav.recordingHotkey || nav.recordingSpeakHotkey @@ -3829,6 +3829,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, guard let target = Self.stepTabTarget(from: nav.mode, by: delta, in: nav.orderedTabs) else { return false } if target != nav.mode { nav.mode = target } + case .openSettings: + // Same rule as ⌘←/→: setup and the update screens have no tab strip. + guard Self.stepTabTarget(from: nav.mode, by: 0, in: nav.orderedTabs) != nil, + !(nav.mode == .permissions && nav.permissionsReturnMode == .bootstrap) + else { return false } + nav.mode = .settings case .cancelField: swallowingRepeatsOf = event.keyCode cancelField() @@ -3879,6 +3885,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, enum GlobalKeyAction: Equatable { case hide case stepTab(Int) + case openSettings case cancelField } @@ -3899,6 +3906,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return .stepTab(-1) case KeyCode.rightArrow where modifiers == .command && !recording: return .stepTab(1) + case KeyCode.comma where modifiers == .command && !recording && !isRepeat: + return .openSettings case KeyCode.delete where modifiers.isEmpty && inEmptyField == true && !isRepeat: return .cancelField default: @@ -5062,6 +5071,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, } } + // Straight to Settings from outside the panel (the menu bar menu), + // optionally on one category, rather than open-the-panel-then-find-the-tab. + func showSettings(category: SettingsCategory? = nil) { + nav.mode = .settings + if let category { nav.settingsCategory = category } + showPanel() + } + func showPanel() { // In compact mode, "show" means "expand" — bringing back the full // panel rather than just raising the pill (which is already visible). From 39e26f58ee4482cce16a6020f61e0760d43eb9d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s=20De=20Freitas?= <6485562+adefreitas@users.noreply.github.com> Date: Fri, 30 Oct 2026 11:22:43 +0000 Subject: [PATCH 2/2] fix(panel): address review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AGENTS.md: add the ⌘, row to the keys that mean the same on every page, and re-wrap the panelHandlesGlobalKey comment. - Pull the ⌘, guard into canOpenSettings beside stepTabTarget, with tests for tabs, sub-pages, setup, the update screens and Permissions from setup. - Menu Settings… shares canOpenSettings, so on setup or an update screen it only brings the panel up instead of replacing the screen. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + .../GlobalKeyActionTests.swift | 21 +++++++++++++++ panel/Panel.swift | 27 ++++++++++++++----- 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 6c7f694..b9ea9c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,7 @@ These are handled once, in `panelHandlesGlobalKey`, which `FloatingPanel.sendEve |-----|---------| | `Esc` | In a text field, or while a shortcut is being recorded, cancel it. Everywhere else, hide the panel: on every page, in every state, including setup, update and uninstall screens. Nothing else. | | `⌘←` / `⌘→` | Previous / next top-level tab, without wrapping. Left alone only while recording a hotkey, so the combo can be recorded. | +| `⌘,` | Open Settings, as in any Mac app. Works from a text field too; not while recording a hotkey, or on the setup and update screens. | | `⌫` in an empty text field | Cancel the field, as `Esc` does. | Cancelling a field puts it back the way it was before you started and hands the keyboard back to its page. Recording a shortcut counts as editing one: cancelling keeps the old shortcut. A search, filter or new-entry field empties. A field holding a value goes back to that value: a rename keeps the old name, an extension config field its saved value. `cancelField` in `Panel.swift` does this per page, so a page that adds a text field adds its case there. diff --git a/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift b/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift index 1198526..73da2b1 100644 --- a/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift +++ b/Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift @@ -115,6 +115,27 @@ final class GlobalKeyActionTests: XCTestCase { XCTAssertNil(PanelController.stepTabTarget(from: .updating, by: -1, in: tabs)) } + private func canOpenSettings(_ mode: PanelMode, + returningTo returnMode: PanelMode = .settings) -> Bool { + PanelController.canOpenSettings(mode: mode, permissionsReturnMode: returnMode, + orderedTabs: [.events, .sessions, .settings]) + } + + // From a tab or a Settings sub-page, ⌘, and the menu go straight to Settings. + func test_settingsOpensFromATabOrSubPage() { + XCTAssertTrue(canOpenSettings(.sessions)) + XCTAssertTrue(canOpenSettings(.phrases)) + XCTAssertTrue(canOpenSettings(.permissions)) + } + + // Leaving setup or an install in progress would strand it until a relaunch. + func test_settingsStaysShutOnSetupAndUpdateScreens() { + XCTAssertFalse(canOpenSettings(.bootstrap)) + XCTAssertFalse(canOpenSettings(.updating)) + XCTAssertFalse(canOpenSettings(.postUpdate)) + XCTAssertFalse(canOpenSettings(.permissions, returningTo: .bootstrap)) + } + func test_ordinaryKeysPassThrough() { XCTAssertNil(action(0)) XCTAssertNil(action(0, inEmptyField: true)) diff --git a/panel/Panel.swift b/panel/Panel.swift index 0292473..b0aa9d1 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -3799,7 +3799,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // The keys that mean one thing on every page (see AGENTS.md, Keyboard // navigation): Esc cancels a text field or, outside one, hides the panel, - // ⌘←/→ step through the tabs, ⌘, opens Settings, and ⌫ in an empty text field cancels it too. + // ⌘←/→ step through the tabs, ⌘, opens Settings, and ⌫ in an empty text + // field cancels it too. func panelHandlesGlobalKey(_ event: NSEvent, fieldEditor: NSTextView?) -> Bool { let mods = event.modifierFlags.intersection([.command, .control, .option, .shift]) let recording = nav.recordingHotkey || nav.recordingSpeakHotkey @@ -3830,9 +3831,9 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, else { return false } if target != nav.mode { nav.mode = target } case .openSettings: - // Same rule as ⌘←/→: setup and the update screens have no tab strip. - guard Self.stepTabTarget(from: nav.mode, by: 0, in: nav.orderedTabs) != nil, - !(nav.mode == .permissions && nav.permissionsReturnMode == .bootstrap) + guard Self.canOpenSettings(mode: nav.mode, + permissionsReturnMode: nav.permissionsReturnMode, + orderedTabs: nav.orderedTabs) else { return false } nav.mode = .settings case .cancelField: @@ -3882,6 +3883,15 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, return tabs.indices.contains(index + delta) ? tabs[index + delta] : mode } + // Where ⌘, and the menu's Settings… may take over the screen. Same rule as + // ⌘←/→: setup, the update screens and Permissions opened from setup have + // no tab strip, and leaving them would strand setup until a relaunch. + static func canOpenSettings(mode: PanelMode, permissionsReturnMode: PanelMode, + orderedTabs: [PanelMode]) -> Bool { + if mode == .permissions, permissionsReturnMode == .bootstrap { return false } + return stepTabTarget(from: mode, by: 0, in: orderedTabs) != nil + } + enum GlobalKeyAction: Equatable { case hide case stepTab(Int) @@ -5073,9 +5083,14 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, // Straight to Settings from outside the panel (the menu bar menu), // optionally on one category, rather than open-the-panel-then-find-the-tab. + // On setup or an update screen it only brings the panel up, as ⌘, does. func showSettings(category: SettingsCategory? = nil) { - nav.mode = .settings - if let category { nav.settingsCategory = category } + if Self.canOpenSettings(mode: nav.mode, + permissionsReturnMode: nav.permissionsReturnMode, + orderedTabs: nav.orderedTabs) { + nav.mode = .settings + if let category { nav.settingsCategory = category } + } showPanel() }