Skip to content

Commit 5f4e12e

Browse files
committed
Cycle tabs with Cmd+Left/Right
Ctrl+Tab was the only way to step between tabs, which is an awkward reach next to the ⌘-based tab shortcuts already in the panel. Bind Cmd+← / Cmd+→ to the same selectPreviousSession/selectNextSession pair — they already wrap with a modulo, so Cmd+← from the first tab lands on the last. The binding lives in TerminalPanel.performKeyEquivalent, but the arrow keys are also claimed by ClickThroughTerminalView's key monitor, which encodes them for the shell and ignores ⌘ when deriving the CSI modifier parameter — so ⌘← would otherwise reach the shell as a bare left arrow. Pass ⌘-only arrows through there instead. Local monitors run before key-equivalent dispatch, but this works either way: whichever sees the event first, the other never gets a chance to mis-handle it. Reserved in ReservedShortcut so the quick-input recorder rejects ⌘←/⌘→; ⌘⌥← and friends stay bindable. 117 tests in 9 suites pass.
1 parent aea4e55 commit 5f4e12e

5 files changed

Lines changed: 33 additions & 0 deletions

File tree

Notchy/QuickInput.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ enum ReservedShortcut {
6969
(51, .command, "Kill line"), // ⌘⌫
7070
(48, .control, "Next tab"), // Ctrl+Tab
7171
(48, [.control, .shift], "Previous tab"), // Ctrl+⇧Tab
72+
(124, .command, "Next tab"), // ⌘→
73+
(123, .command, "Previous tab"), // ⌘←
7274
(36, .shift, "Newline"), // ⇧Return
7375
]
7476

Notchy/TerminalManager.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,14 @@ class ClickThroughTerminalView: LocalProcessTerminalView {
518518
return nil
519519
}
520520

521+
// Cmd+←/→ switch tabs. The panel's performKeyEquivalent acts on them,
522+
// but local monitors run first, so they have to be passed through here
523+
// rather than encoded as an arrow for the shell.
524+
if event.keyCode == 123 || event.keyCode == 124,
525+
event.modifierFlags.intersection([.command, .shift, .option, .control]) == .command {
526+
return event
527+
}
528+
521529
let arrowCode: String?
522530
switch event.keyCode {
523531
case 126: arrowCode = "A" // Up

Notchy/TerminalPanel.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,17 @@ class TerminalPanel: NSPanel, NSWindowDelegate {
400400
return true
401401
}
402402
}
403+
// Cmd+← / Cmd+→: previous/next tab, wrapping around at both ends.
404+
// ClickThroughTerminalView's key monitor deliberately lets these through
405+
// instead of encoding an arrow for the shell.
406+
if cmdOnly, event.keyCode == 123 || event.keyCode == 124 {
407+
if event.keyCode == 123 {
408+
sessionStore.selectPreviousSession()
409+
} else {
410+
sessionStore.selectNextSession()
411+
}
412+
return true
413+
}
403414
// Cmd+Shift+P: toggle pin window. charactersIgnoringModifiers keeps the
404415
// Shift case, so this arrives as "P" — compare case-insensitively.
405416
if event.modifierFlags.intersection([.command, .shift, .control, .option]) == [.command, .shift],

NotchyTests/QuickInputTests.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@ struct QuickInputTests {
3535
#expect(ReservedShortcut.conflict(keyCode: 36, modifiers: shift) == "Newline") // ⇧↩
3636
}
3737

38+
@Test("Both tab-cycling bindings are reserved")
39+
func tabCyclingCombosConflict() {
40+
#expect(ReservedShortcut.conflict(keyCode: 123, modifiers: cmd) == "Previous tab") // ⌘←
41+
#expect(ReservedShortcut.conflict(keyCode: 124, modifiers: cmd) == "Next tab") // ⌘→
42+
#expect(ReservedShortcut.conflict(keyCode: 48, modifiers: ctrl) == "Next tab") // ⌃⇥
43+
#expect(ReservedShortcut.conflict(keyCode: 48, modifiers: ctrl | shift) == "Previous tab")
44+
// The arrows are only reserved with ⌘ alone — ⌘⌥← stays bindable.
45+
#expect(ReservedShortcut.conflict(keyCode: 123, modifiers: cmd | opt) == nil)
46+
#expect(ReservedShortcut.conflict(keyCode: 123, modifiers: 0) == nil)
47+
}
48+
3849
@Test("Shift disambiguates same-key combos")
3950
func shiftDisambiguates() {
4051
// ⌘T is "New tab"; ⌘⇧T is "Shadow tab" — the plain-⌘ entry must not

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ A macOS menu bar app that puts Claude Code or Codex right in your MacBook's notc
1313
- **AI agent auto-launch** — starts `claude` for projects with `CLAUDE.md`, or `codex` for projects with `AGENTS.md`
1414
- **Multi-session tabs** — run multiple Claude Code or Codex sessions side by side
1515
- **Drag to reorder tabs** — press and drag a tab left or right to change its position; drop it and the rest snap into place. Reordering also renumbers the Cmd+1…9 shortcuts
16+
- **Cycle tabs** — Cmd+← and Cmd+→ step to the tab on either side, wrapping around at both ends, so Cmd+← from the first tab lands on the last. Ctrl+Tab / Ctrl+Shift+Tab do the same
1617
- **Quick switcher** — Cmd+K opens a fuzzy-searchable list of sessions sorted by most-recently-used; type to filter, ↑↓ to move the selection, Enter to jump, Esc to cancel and return focus to the terminal you were in
1718
- **Pin tabs to persist** — right-click a `+` tab → **Pin Tab** to keep it across app restarts; Notchy remembers the tab's working directory and re-runs agent auto-launch on relaunch
1819
- **Shadow tabs** — right-click an Xcode or pinned tab → **Shadow Tab** to spawn a plain shell sibling cd'd into the same directory, for ad-hoc git/build commands without disturbing the agent

0 commit comments

Comments
 (0)