Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
11da260
Add a browser-style base keymap preset
teamleaderleo Sep 28, 2026
d04ee31
Merge main (a98c560063fc) into feat/keymap-chooser
teamleaderleo Sep 28, 2026
b07068b
Say what the browser preset actually changes
teamleaderleo Sep 28, 2026
ab8977c
Merge main (b7ff00616a71) into feat/keymap-browser-preset
teamleaderleo Sep 28, 2026
bd4e21d
Fix a keymap preset round-trip test that never compiled
teamleaderleo Sep 28, 2026
853a969
Merge main (0c753fe28dfd) into feat/keymap-browser-preset
teamleaderleo Sep 28, 2026
6720ab9
Merge main (b681e7e943ba) into feat/keymap-browser-preset
teamleaderleo Sep 28, 2026
e4edeab
Merge main (d2a290b56d71) into feat/keymap-browser-preset
teamleaderleo Sep 28, 2026
cb4c9a8
Merge main (478e3232b099) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
a101a84
Merge main (204b936a2c49) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
51fc806
Merge main (0e44675036c4) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
3ed76f5
Merge main (8b756786e2c5) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
cf2edfb
Merge main (8cfe728e0480) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
22092da
Merge main (6d5645c487a7) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
992f4de
Merge main (d78434aa026e) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
992a68d
Re-resolve Localizable.xcstrings against main's ordering
teamleaderleo Sep 30, 2026
f23df76
Merge remote-tracking branch 'origin/main' into feat/keymap-browser-p…
teamleaderleo Sep 30, 2026
bf2092b
Merge main (ed8129df81a6) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
788ce53
Merge main (ef75ca7a32c2) into feat/keymap-browser-preset
teamleaderleo Sep 30, 2026
fea3c1c
fix: document French action title identity
teamleaderleo Sep 30, 2026
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
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Foundation

/// A base keymap for people coming from another terminal, like Zed's base
/// keymap picker.
/// A base keymap for people coming from another terminal, or from a browser,
/// like Zed's base keymap picker.
///
/// A preset is a list of `shortcuts.bindings` overrides. Every override
/// differs from the cmux default, so the ``cmux`` preset has none and choosing
Expand All @@ -21,6 +21,23 @@ public enum ShortcutKeymapPreset: String, CaseIterable, Sendable {
/// tmux's default `ctrl+b` prefix, with tmux windows mapped to cmux
/// workspaces and tmux panes to cmux panes.
case tmux
/// A browser's tab keys: Ctrl-Tab and Ctrl-Shift-Tab cycle the tab bar and
/// Cmd-1…9 selects a tab. The system owns Cmd-Tab, so Ctrl-Tab stands in
/// for it, the same substitution Chrome and Firefox make on macOS.
///
/// Ctrl-Tab already cycled surfaces before this preset existed, through a
/// hardcoded legacy handler that no action owns. Binding it properly is
/// still worth doing: it puts the stroke in Settings and the View menu
/// where it can be seen and rebound, and it fixes Caps Lock, because the
/// legacy matcher compares raw modifier flags while a configured stroke
/// goes through `normalizedModifierFlags`, which drops Caps Lock. The cost
/// is that Cmd-Shift-[ and Cmd-Shift-] stop cycling surfaces and end up
/// unbound, since an action carries one binding.
///
/// cmux already agrees with a browser on Cmd-T, Cmd-W, Cmd-Shift-T,
/// Cmd-L, Cmd-R, Cmd-F and Cmd-[ / Cmd-], so this preset only writes the
/// two tab-cycling keys and the number row.
case browser

/// The `shortcuts.bindings` values this preset writes, keyed by action.
///
Expand Down Expand Up @@ -66,6 +83,23 @@ public enum ShortcutKeymapPreset: String, CaseIterable, Sendable {
.toggleSplitZoom: .chord(prefix, "z"),
.toggleTerminalCopyMode: .chord(prefix, "["),
]
case .browser:
return [
// The tab bar holds surfaces, so a browser's tab keys drive
// the surface actions. Ctrl-Tab already worked through the
// legacy handler; binding it here is what makes it visible,
// rebindable and correct with Caps Lock on. Cmd-Shift-[ and
// Cmd-Shift-] go unbound in the trade, because an action
// carries one binding.
.nextSurface: .stroke("ctrl+tab"),
.prevSurface: .stroke("ctrl+shift+tab"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Cmd-1…9 picks a tab in a browser, so it moves off
// workspaces and onto surfaces. Workspaces take the Option
// row, matching what the iTerm2 preset does with the same
// collision.
.selectSurfaceByNumber: .stroke("cmd+1"),
.selectWorkspaceByNumber: .stroke("cmd+opt+1"),
]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,115 @@ struct ShortcutKeymapPresetTests {
#expect(preset.plan(from: empty).systemConflicts.isEmpty)
}

@Test func browserPresetBindsTheTabKeysAndTheNumberRow() throws {
let plan = ShortcutKeymapPreset.browser.plan(from: empty)

#expect(Set(plan.changes.map(\.action)) == [
.nextSurface, .prevSurface, .selectSurfaceByNumber, .selectWorkspaceByNumber,
])
let next = try #require(plan.changes.first { $0.action == .nextSurface })
#expect(next.before == StoredShortcut(first: ShortcutStroke(key: "]", command: true, shift: true)))
#expect(next.after == StoredShortcut(first: ShortcutStroke(key: "\t", control: true)))
let previous = try #require(plan.changes.first { $0.action == .prevSurface })
#expect(previous.after == StoredShortcut(first: ShortcutStroke(key: "\t", shift: true, control: true)))
// Cmd-1…9 moves from workspaces to surfaces, so workspaces take the
// Option row rather than losing the number row.
let surfaces = try #require(plan.changes.first { $0.action == .selectSurfaceByNumber })
#expect(surfaces.after == StoredShortcut(first: ShortcutStroke(key: "1", command: true)))
let workspaces = try #require(plan.changes.first { $0.action == .selectWorkspaceByNumber })
#expect(workspaces.after == StoredShortcut(first: ShortcutStroke(key: "1", command: true, option: true)))
}

@Test func browserPresetLeavesTheShortcutsABrowserAlreadyAgreesWith() {
// Cmd-T, Cmd-W, Cmd-Shift-T, Cmd-L, Cmd-R, Cmd-F and Cmd-[ / Cmd-]
// are cmux defaults already, so the preset must not write them.
let untouched: [ShortcutAction: ShortcutStroke] = [
.newSurface: ShortcutStroke(key: "t", command: true),
.closeTab: ShortcutStroke(key: "w", command: true),
.reopenClosedBrowserPanel: ShortcutStroke(key: "t", command: true, shift: true),
.focusBrowserAddressBar: ShortcutStroke(key: "l", command: true),
.browserReload: ShortcutStroke(key: "r", command: true),
.find: ShortcutStroke(key: "f", command: true),
.focusHistoryBack: ShortcutStroke(key: "[", command: true),
.focusHistoryForward: ShortcutStroke(key: "]", command: true),
]

for (action, expected) in untouched {
#expect(ShortcutKeymapPreset.browser.overrides[action] == nil, "\(action)")
#expect(
action.defaultShortcut?.canonicalized() == StoredShortcut(first: expected).canonicalized(),
"\(action) is no longer a browser-style default"
)
}
}

@Test func browserPresetIsDetectedAndReversible() {
#expect(ShortcutKeymapPreset.active(in: snapshot(applied(.browser))) == .browser)

let back = ShortcutKeymapPreset.cmux.plan(from: snapshot(applied(.browser)))
#expect(Set(back.changes.map(\.action)) == Set(ShortcutKeymapPreset.browser.overrides.keys))
#expect(back.changes.allSatisfy { $0.write == nil })
}

/// Browser and iTerm2 write the same two numbered actions, so a file on one
/// of them satisfies the other's number-row check. Detection has to come
/// apart on the rest, in both directions, or picking a style in Settings
/// would show the wrong one as current.
@Test func browserAndITerm2AreNotMistakenForEachOther() {
#expect(ShortcutKeymapPreset.active(in: snapshot(applied(.browser))) == .browser)
#expect(ShortcutKeymapPreset.active(in: snapshot(applied(.iTerm2))) == .iTerm2)
}

/// Switching between the two presets that share the number row has to land
/// on exactly the target's override set, with nothing of the other left
/// behind and the shared rows not rewritten on the way through.
@Test func iTerm2RoundTripsThroughBrowser() throws {
let start = snapshot(applied(.iTerm2))

let toBrowser = ShortcutKeymapPreset.browser.plan(from: start)
// The two numbered actions already hold the right values, so the plan
// leaves them alone and only settles what the presets disagree about.
#expect(!toBrowser.changes.contains { $0.action == .selectSurfaceByNumber })
#expect(!toBrowser.changes.contains { $0.action == .selectWorkspaceByNumber })

let asBrowser = try replaying(toBrowser, onto: applied(.iTerm2))
#expect(asBrowser == applied(.browser))
#expect(ShortcutKeymapPreset.active(in: snapshot(asBrowser)) == .browser)

let back = ShortcutKeymapPreset.iTerm2.plan(from: snapshot(asBrowser))
let asITerm2 = try replaying(back, onto: asBrowser)
#expect(asITerm2 == applied(.iTerm2))
#expect(ShortcutKeymapPreset.active(in: snapshot(asITerm2)) == .iTerm2)
}

/// Applies a plan's writes to a bindings dictionary the way the JSON store
/// does, so the result can be compared against ``applied(_:)``.
///
/// A plan writes the hand-editable ``ShortcutKeymapBinding`` form, while
/// bindings compare as parsed ``StoredShortcut``, so each write is parsed
/// on the way in. A write that does not parse would drop the key and read
/// as "the preset does not override this action", which would make a round
/// trip pass for the wrong reason, so that fails here instead.
private func replaying(
_ plan: ShortcutKeymapPlan,
onto bindings: [ShortcutAction: StoredShortcut]
) throws -> [ShortcutAction: StoredShortcut] {
var result = bindings
for change in plan.changes {
guard let write = change.write else {
// No write means the override is removed and the cmux default
// applies again.
result[change.action] = nil
continue
}
result[change.action] = try #require(
write.shortcut,
"\(plan.preset) writes an unparseable binding for \(change.action)"
)
}
return result
}

/// Whether two bindings can fire on the same keystroke sequence. A chord
/// overlaps a single stroke equal to its prefix; numbered actions stand for
/// their `1…9` family.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -540,7 +540,7 @@ extension Array where Element == CuratedSettingEntry {
].joined(separator: " ")
),
.init(section: .keyboardShortcuts, id: "modifier-hold-hints", title: String(localized: "settings.shortcuts.showModifierHoldHints", defaultValue: "Show Shortcut Hints While Holding Modifier Keys"), synonyms: "Show Shortcut Hints While Holding Modifier Keys shortcuts.showModifierHoldHints shortcut hints hotkey hints command cmd modifier hold chips badges"),
.init(section: .keyboardShortcuts, id: "base-keymap", title: String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), synonyms: "Base Keymap preset keybindings iterm iterm2 terminal app terminal.app tmux prefix ctrl-b coming from switch shortcuts macos conflicts"),
.init(section: .keyboardShortcuts, id: "base-keymap", title: String(localized: "settings.shortcuts.baseKeymap", defaultValue: "Base Keymap"), synonyms: "Base Keymap preset keybindings iterm iterm2 terminal app terminal.app tmux prefix ctrl-b browser chrome safari firefox ctrl-tab coming from switch shortcuts macos conflicts"),
.init(section: .keyboardShortcuts, id: "shortcut-chords", title: String(localized: "settings.shortcuts.chords", defaultValue: "Shortcut Chords"), synonyms: "Shortcut Chords tmux prefix ctrl-b control-b multi key sequence chord cmux json"),
.init(section: .keyboardShortcuts, id: "reset-defaults", title: String(localized: "settings.shortcuts.resetDefaults", defaultValue: "Reset Default Shortcuts"), synonyms: "Reset Default Shortcuts reset restore default defaults built in builtin shortcuts hotkeys keybindings commands"),

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ extension ShortcutKeymapPreset {
return String(localized: "shortcut.keymap.preset.terminal", defaultValue: "Terminal.app")
case .tmux:
return String(localized: "shortcut.keymap.preset.tmux", defaultValue: "tmux-style (Ctrl-B Prefix)")
case .browser:
return String(localized: "shortcut.keymap.preset.browser", defaultValue: "Browser-style (Ctrl-Tab)")
}
}
}
Expand Down Expand Up @@ -97,12 +99,20 @@ extension ShortcutKeymapPlan {
defaultValue: "Kept your own shortcut for %@."
)
var lines: [String] = []
if preset == .iTerm2, changes.contains(where: { $0.action == .selectSurfaceByNumber }) {
// Both presets move the number row the same way, so both warn about it.
if preset == .iTerm2 || preset == .browser,
changes.contains(where: { $0.action == .selectSurfaceByNumber }) {
lines.append(String(
localized: "shortcut.keymap.summary.iterm2Numbers",
defaultValue: "⌘1…9 will select tabs in the focused pane instead of workspaces. Workspaces move to ⌥⌘1…9."
))
}
if preset == .browser, changes.contains(where: { $0.action == .nextSurface }) {
lines.append(String(
localized: "shortcut.keymap.summary.browserTabs",
defaultValue: "⌃Tab and ⌃⇧Tab already cycle tabs. Binding them here makes them visible and rebindable in Settings and works with Caps Lock on; ⌘⇧[ and ⌘⇧] go unbound in the trade. ⌘T, ⌘W, ⌘L, ⌘R, ⌘F and ⌘[ / ⌘] already match a browser and are left alone, as does ⌘⇧T, which reopens a closed browser panel."
))
}
lines += changes.map { change in
String.localizedStringWithFormat(
changeFormat,
Expand Down
Loading
Loading