Add keybindings settings editor#2533
Conversation
- add searchable keybinding management UI and route - extract shared keybinding parsing and formatting logic - coalesce duplicate keybinding update toasts
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Duplicated keybinding capture logic not consolidated into shared module
- Removed the duplicated normalizeShortcutKeyToken and keybindingFromEvent from ProjectScriptsControl.tsx and replaced them with an import of the shared keybindingFromKeyboardEvent from KeybindingsSettings.logic.ts, consolidating the logic into a single module.
Or push these changes by commenting:
@cursor push 7ba999972e
Preview (7ba999972e)
diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx
--- a/apps/web/src/components/ProjectScriptsControl.tsx
+++ b/apps/web/src/components/ProjectScriptsControl.tsx
@@ -16,6 +16,7 @@
} from "lucide-react";
import React, { type FormEvent, type KeyboardEvent, useCallback, useMemo, useState } from "react";
+import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic";
import {
keybindingValueForCommand,
decodeProjectScriptKeybindingRule,
@@ -26,7 +27,6 @@
primaryProjectScript,
} from "~/projectScripts";
import { shortcutLabelForCommand } from "~/keybindings";
-import { isMacPlatform } from "~/lib/utils";
import {
AlertDialog,
AlertDialogClose,
@@ -96,57 +96,6 @@
onDeleteScript: (scriptId: string) => Promise<void> | void;
}
-function normalizeShortcutKeyToken(key: string): string | null {
- const normalized = key.toLowerCase();
- if (
- normalized === "meta" ||
- normalized === "control" ||
- normalized === "ctrl" ||
- normalized === "shift" ||
- normalized === "alt" ||
- normalized === "option"
- ) {
- return null;
- }
- if (normalized === " ") return "space";
- if (normalized === "escape") return "esc";
- if (normalized === "arrowup") return "arrowup";
- if (normalized === "arrowdown") return "arrowdown";
- if (normalized === "arrowleft") return "arrowleft";
- if (normalized === "arrowright") return "arrowright";
- if (normalized.length === 1) return normalized;
- if (normalized.startsWith("f") && normalized.length <= 3) return normalized;
- if (normalized === "enter" || normalized === "tab" || normalized === "backspace") {
- return normalized;
- }
- if (normalized === "delete" || normalized === "home" || normalized === "end") {
- return normalized;
- }
- if (normalized === "pageup" || normalized === "pagedown") return normalized;
- return null;
-}
-
-function keybindingFromEvent(event: KeyboardEvent<HTMLInputElement>): string | null {
- const keyToken = normalizeShortcutKeyToken(event.key);
- if (!keyToken) return null;
-
- const parts: string[] = [];
- if (isMacPlatform(navigator.platform)) {
- if (event.metaKey) parts.push("mod");
- if (event.ctrlKey) parts.push("ctrl");
- } else {
- if (event.ctrlKey) parts.push("mod");
- if (event.metaKey) parts.push("meta");
- }
- if (event.altKey) parts.push("alt");
- if (event.shiftKey) parts.push("shift");
- if (parts.length === 0) {
- return null;
- }
- parts.push(keyToken);
- return parts.join("+");
-}
-
export default function ProjectScriptsControl({
scripts,
keybindings,
@@ -186,7 +135,7 @@
setKeybinding("");
return;
}
- const next = keybindingFromEvent(event);
+ const next = keybindingFromKeyboardEvent(event, navigator.platform);
if (!next) return;
setKeybinding(next);
};You can send follow-ups to the cloud agent here.
ApprovabilityVerdict: Needs human review 2 blocking correctness issues found. This PR introduces a substantial new keybindings settings editor feature with ~1600+ lines of new code, new UI workflows, and server-side behavior changes. Additionally, there are 3 unresolved high-severity review comments identifying potential bugs in duplicate handling, state synchronization, and key encoding mismatches that warrant attention. You can customize Macroscope's approvability policy. Learn more. |
- Keep keybinding and when-clause edits in a single row draft state - Simplify when-expression validity updates and save/reset handling - Compact the settings table for a tighter, more usable layout
- Add expandable header search with keyboard shortcut focus - Add quick-open button for `keybindings.json` - Replace read-only shortcut pills with click-to-edit controls
- Keep keyboard shortcuts from firing while typing in inputs, textareas, and contenteditable fields
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Wrong source/default for commands with multiple bindings
- Changed
sourceForBindingto useArray.some()matching by command, shortcut, and when clause, and replaceddefaultBindingForCommandwithdefaultBindingForBindingthat matches the exact default entry with cascading fallback.
- Changed
- ✅ Fixed: Unused parameter makes
useMemodependency misleading- Removed the unused
_keybindingsparameter frombuildWhenVariableOptionsand updated the caller to use an emptyuseMemodependency array, eliminating pointless re-execution.
- Removed the unused
Or push these changes by commenting:
@cursor push 7bdfa6a3fe
Preview (7bdfa6a3fe)
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts
@@ -124,24 +124,7 @@
});
it("builds known when variable options from defaults without frontend labels", () => {
- const options = buildWhenVariableOptions([
- {
- command: "terminal.toggle",
- shortcut: {
- key: "j",
- modKey: true,
- metaKey: false,
- ctrlKey: false,
- altKey: false,
- shiftKey: false,
- },
- whenAst: {
- type: "and",
- left: { type: "identifier", name: "terminalOpen" },
- right: { type: "identifier", name: "customModeActive" },
- },
- },
- ] satisfies ResolvedKeybindingsConfig);
+ const options = buildWhenVariableOptions();
expect(options).toEqual(
expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]),
diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
--- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts
+++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts
@@ -91,22 +91,38 @@
return "Project";
}
- const defaultBinding = DEFAULT_RESOLVED_KEYBINDINGS.find(
- (entry) => entry.command === binding.command,
+ const bindingKey = shortcutToKeybindingInput(binding.shortcut);
+ const bindingWhen = whenAstToExpression(binding.whenAst);
+
+ const isDefault = DEFAULT_RESOLVED_KEYBINDINGS.some(
+ (entry) =>
+ entry.command === binding.command &&
+ shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
+ whenAstToExpression(entry.whenAst) === bindingWhen,
);
- if (!defaultBinding) {
- return "Custom";
- }
- return shortcutToKeybindingInput(defaultBinding.shortcut) ===
- shortcutToKeybindingInput(binding.shortcut) &&
- whenAstToExpression(defaultBinding.whenAst) === whenAstToExpression(binding.whenAst)
- ? "Default"
- : "Custom";
+ return isDefault ? "Default" : "Custom";
}
-function defaultBindingForCommand(command: KeybindingCommand): ResolvedKeybindingRule | undefined {
- return DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === command);
+function defaultBindingForBinding(
+ binding: ResolvedKeybindingRule,
+): ResolvedKeybindingRule | undefined {
+ const bindingKey = shortcutToKeybindingInput(binding.shortcut);
+ const bindingWhen = whenAstToExpression(binding.whenAst);
+
+ return (
+ DEFAULT_RESOLVED_KEYBINDINGS.find(
+ (entry) =>
+ entry.command === binding.command &&
+ shortcutToKeybindingInput(entry.shortcut) === bindingKey &&
+ whenAstToExpression(entry.whenAst) === bindingWhen,
+ ) ??
+ DEFAULT_RESOLVED_KEYBINDINGS.find(
+ (entry) =>
+ entry.command === binding.command && whenAstToExpression(entry.whenAst) === bindingWhen,
+ ) ??
+ DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === binding.command)
+ );
}
export function buildKeybindingRows(
@@ -115,7 +131,7 @@
): ReadonlyArray<KeybindingRow> {
const normalizedQuery = query.trim().toLowerCase();
const rows = keybindings.map((binding) => {
- const defaultBinding = defaultBindingForCommand(binding.command);
+ const defaultBinding = defaultBindingForBinding(binding);
const key = shortcutToKeybindingInput(binding.shortcut);
const when = whenAstToExpression(binding.whenAst);
return {
@@ -179,9 +195,7 @@
return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted();
}
-export function buildWhenVariableOptions(
- _keybindings: ResolvedKeybindingsConfig,
-): ReadonlyArray<WhenVariableOption> {
+export function buildWhenVariableOptions(): ReadonlyArray<WhenVariableOption> {
return [...KNOWN_WHEN_VARIABLES].toSorted((left, right) => {
const leftCoreIndex = CORE_WHEN_VARIABLES.indexOf(left as (typeof CORE_WHEN_VARIABLES)[number]);
const rightCoreIndex = CORE_WHEN_VARIABLES.indexOf(
diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx
--- a/apps/web/src/components/settings/KeybindingsSettings.tsx
+++ b/apps/web/src/components/settings/KeybindingsSettings.tsx
@@ -852,7 +852,7 @@
const searchInputRef = useRef<HTMLInputElement>(null);
const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
- const whenVariables = useMemo(() => buildWhenVariableOptions(keybindings), [keybindings]);
+ const whenVariables = useMemo(() => buildWhenVariableOptions(), []);
useEffect(() => {
const handleKeyDown = (event: globalThis.KeyboardEvent) => {You can send follow-ups to the cloud agent here.
- Wrap the keybindings table in `ScrollArea` - Add a `chainVerticalScroll` option to the shared scroll area
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 4 total unresolved issues (including 3 from previous reviews).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Global scroll area overflow change affects all components
- Reverted the default viewport overflow back to
overflow-y-autoand added an opt-inoverflowXprop that only the keybindings table uses, preventing unwanted horizontal scrollbars in all other ScrollArea consumers.
- Reverted the default viewport overflow back to
Or push these changes by commenting:
@cursor push 5b5fefc25c
Preview (5b5fefc25c)
diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx
--- a/apps/web/src/components/settings/KeybindingsSettings.tsx
+++ b/apps/web/src/components/settings/KeybindingsSettings.tsx
@@ -981,6 +981,7 @@
<ScrollArea
chainVerticalScroll
+ overflowX
scrollFade
hideScrollbars
className="w-full max-w-full rounded-none"
diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx
--- a/apps/web/src/components/ui/scroll-area.tsx
+++ b/apps/web/src/components/ui/scroll-area.tsx
@@ -11,12 +11,14 @@
scrollbarGutter = false,
hideScrollbars = false,
chainVerticalScroll = false,
+ overflowX = false,
...props
}: ScrollAreaPrimitive.Root.Props & {
scrollFade?: boolean;
scrollbarGutter?: boolean;
hideScrollbars?: boolean;
chainVerticalScroll?: boolean;
+ overflowX?: boolean;
}) {
return (
<ScrollAreaPrimitive.Root
@@ -25,7 +27,8 @@
>
<ScrollAreaPrimitive.Viewport
className={cn(
- "h-full max-h-[inherit] overflow-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
+ "h-full max-h-[inherit] overflow-y-auto overscroll-contain rounded-[inherit] outline-none transition-shadows focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background data-has-overflow-x:overscroll-x-contain",
+ overflowX && "overflow-x-auto",
chainVerticalScroll && "overscroll-y-auto",
scrollFade &&
"mask-t-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-start)))] mask-b-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-y-end)))] mask-l-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-start)))] mask-r-from-[calc(100%-min(var(--fade-size),var(--scroll-area-overflow-x-end)))] [--fade-size:1.5rem]",You can send follow-ups to the cloud agent here.
- Let users add, replace, disable, and remove individual keybindings - Surface shortcut conflicts and broaden command selection in settings - Add server RPC support for targeted keybinding removal
- Replace per-row disable/remove buttons with an overflow actions menu - Move save controls inline for edited rows and simplify new-binding cancel UI - Tighten the table layout to fit the updated controls
| const replaceTarget = replaceTargetFromUpsertInput(input); | ||
| const nextConfig = [ | ||
| ...customConfig.filter((entry) => entry.command !== rule.command), | ||
| ...customConfig.filter((entry) => { |
There was a problem hiding this comment.
🟠 High src/keybindings.ts:654
In upsertKeybindingRule, when replaceTarget is provided, the filter only removes entries matching replaceTarget but does not remove entries that also match the new rule. This leaves duplicate entries in the config. For example, replacing {key: "a", command: "cmd1"} with {key: "b", command: "cmd2"} when {key: "b", command: "cmd2"} already exists results in two identical entries. The filter should also exclude entries matching the new rule to prevent duplicates.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/server/src/keybindings.ts around line 654:
In `upsertKeybindingRule`, when `replaceTarget` is provided, the filter only removes entries matching `replaceTarget` but does not remove entries that also match the new `rule`. This leaves duplicate entries in the config. For example, replacing `{key: "a", command: "cmd1"}` with `{key: "b", command: "cmd2"}` when `{key: "b", command: "cmd2"}` already exists results in two identical entries. The filter should also exclude entries matching the new `rule` to prevent duplicates.
Evidence trail:
apps/server/src/keybindings.ts lines 654-660 (filter logic in upsertKeybindingRule); apps/server/src/keybindings.ts lines 129-133 (keybindingRuleFromUpsertInput); apps/server/src/keybindings.ts lines 135-140 (replaceTargetFromUpsertInput). The `if (replaceTarget)` branch only filters by replaceTarget and skips filtering by rule.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
There are 2 total unresolved issues (including 1 from previous review).
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: KeybindingPill naively splits on "+" breaking plus-key display
- Added a splitKeybinding helper that detects trailing empty tokens from split("+") and collapses them into a single "+" part, matching the same approach used by parseKeybindingShortcut in the shared package.
Or push these changes by commenting:
@cursor push ae03a957c5
Preview (ae03a957c5)
diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx
--- a/apps/web/src/components/settings/KeybindingsSettings.tsx
+++ b/apps/web/src/components/settings/KeybindingsSettings.tsx
@@ -63,8 +63,22 @@
import { SettingsPageContainer, SettingsSection } from "./settingsLayout";
import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
+function splitKeybinding(value: string): string[] {
+ const raw = value.split("+");
+ const parts = [...raw];
+ let trailingEmpty = 0;
+ while (parts[parts.length - 1] === "") {
+ trailingEmpty += 1;
+ parts.pop();
+ }
+ if (trailingEmpty > 0) {
+ parts.push("+");
+ }
+ return parts;
+}
+
function KeybindingPill({ value }: { value: string }) {
- const parts = value.split("+");
+ const parts = splitKeybinding(value);
return (
<KbdGroup className="bg-transparent p-0 shadow-none">
{parts.map((part) => (
@@ -898,11 +912,7 @@
</MenuItem>
) : null}
{canRemove ? (
- <MenuItem
- variant="destructive"
- disabled={isSaving}
- onClick={() => onRemove(row)}
- >
+ <MenuItem variant="destructive" disabled={isSaving} onClick={() => onRemove(row)}>
Remove
</MenuItem>
) : null}You can send follow-ups to the cloud agent here.
- Track provider update state across server and web - Add launch/dismiss UI and settings support for provider updates - Include OpenCode in supported providers Co-authored-by: codex <codex@users.noreply.github.com>
1390d26 to
4717c40
Compare
| const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; | ||
| const whenDraftExpression = whenAstToExpression(whenDraft); |
There was a problem hiding this comment.
🟠 High settings/KeybindingsSettings.tsx:769
When the row prop updates from server state (e.g., after a keybinding is modified elsewhere), the draft state remains stale because useReducer doesn't reinitialize. This causes keyDraft and whenDraft to drift from row.key/row.when, making showPill false, isDirty true, and displaying stale values in the input. If the user clicks Save, they overwrite the newer server value with stale data.
const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft);
+ useEffect(() => {
+ setDraft(createKeybindingRowDraft(row));
+ }, [row.id, row.key, row.when]);
const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/web/src/components/settings/KeybindingsSettings.tsx around lines 769-770:
When the `row` prop updates from server state (e.g., after a keybinding is modified elsewhere), the `draft` state remains stale because `useReducer` doesn't reinitialize. This causes `keyDraft` and `whenDraft` to drift from `row.key`/`row.when`, making `showPill` false, `isDirty` true, and displaying stale values in the input. If the user clicks Save, they overwrite the newer server value with stale data.
Evidence trail:
KeybindingsSettings.tsx line 769: `useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft)` — initializer only runs on mount.
KeybindingsSettings.tsx line 1264: `key={row.id}` — React reuses the component instance when row.id stays the same.
KeybindingsSettings.tsx line 772: `isDirty = keyDraft !== row.key || whenDraftExpression !== row.when` — compares stale draft against updated row.
KeybindingsSettings.tsx line 1070: `rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query])` — rows recalculate when keybindings change.
rpc/serverState.ts lines 98-110: `applyServerConfigEvent` handles `keybindingsUpdated` events, updating the atom and causing rows to refresh.
No `useEffect` or other sync mechanism exists in `KeybindingTableRow` to update draft when `row` prop changes.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is ON, but it could not run because the branch was deleted or merged before autofix could start.
Reviewed by Cursor Bugbot for commit 8bebfdc. Configure here.
| if (shortcut.ctrlKey) parts.push("ctrl"); | ||
| if (shortcut.altKey) parts.push("alt"); | ||
| if (shortcut.shiftKey) parts.push("shift"); | ||
| parts.push(shortcut.key === " " ? "space" : shortcut.key === "escape" ? "esc" : shortcut.key); |
There was a problem hiding this comment.
Frontend encodes "escape" key differently from server
Medium Severity
shortcutToKeybindingInput maps "escape" → "esc" when encoding a shortcut, but the server's encodeShortcut leaves "escape" as-is. Since row.key is derived from shortcutToKeybindingInput and then sent to the server as a replace or remove target, and isSameKeybindingRule uses raw string equality on the key field, any config entry originally stored with "escape" (e.g. manually edited or written by the server) won't match the frontend's "esc" encoding. This causes replace and remove operations to silently fail, leaving the old binding in place and appending a duplicate.
Additional Locations (1)
Reviewed by Cursor Bugbot for commit 8bebfdc. Configure here.
Co-authored-by: codex <codex@users.noreply.github.com>
Co-authored-by: codex <codex@users.noreply.github.com>
Brings in: server CLI submodule split (pingdotgg#2545), process/trace diagnostics views (pingdotgg#2532), JetBrains editor support (pingdotgg#2475), MessagesTimeline render optimizations (pingdotgg#2527, pingdotgg#2498), git/terminal test stabilization (pingdotgg#2540), keybindings settings editor (pingdotgg#2533), and provider update advisories (pingdotgg#2312). Conflict resolutions: - packages/contracts/src/settings.ts: kept aa's diffFontFamily and terminalFontFamily alongside upstream's dismissedProviderUpdateNotificationKeys. - apps/desktop/src/clientPersistence.test.ts: same shape, fixture mirrors the schema. - apps/web/src/components/settings/SettingsPanels.tsx: kept both import groups (FontPicker from aa, ProviderUpdateLaunchNotification.logic from upstream). - apps/web/src/localApi.test.ts: extended both fixtures with diffFontFamily and terminalFontFamily so the merged ClientSettings shape typechecks against the strict desktop bridge contract. Pre-existing aa typecheck issues fixed at the root so the merge commit is green: - apps/desktop/src/electron.d.ts: declaration-merge "local-fonts" into Electron's Session.setPermissionRequestHandler permission union (the Electron 40 typings omit it even though the runtime supports it). - apps/web/src/components/DiffPanel.tsx: conditionally spread style on Virtualizer instead of passing undefined, satisfying exactOptionalPropertyTypes.



Summary
whenclause editor with validation and unknown-condition warnings.Testing
bun fmtbun lintbun typecheckbun run test(includesKeybindingsSettings.logic.test.ts)Note
Medium Risk
Touches keybinding persistence semantics and adds a new RPC (
server.removeKeybinding) plus a large new settings UI, so regressions could affect shortcut customization and config writes. Changes are scoped to keybindings/settings paths and covered by new/updated unit tests.Overview
Adds a new
/settings/keybindingspanel with searchable keybindings table, inline shortcut capture, add/reset/remove actions, conflict warnings, and a visual+textwhenclause editor with validation and unknown-condition warnings.Updates keybinding handling end-to-end:
upsertKeybindingRulenow appends bindings by default (instead of replacing all rules for a command), supports targeted replacement via an optionalreplacefield, and introducesremoveKeybindingRuleplus a newserver.removeKeybindingwebsocket/local API contract and routing.Refactors shared frontend logic into
KeybindingsSettings.logic.ts(with new unit tests), reuseskeybindingFromKeyboardEventinProjectScriptsControl, moves “open keybindings.json” out of General settings into the new panel, and coalesces rapid “Keybindings updated” success toasts.Reviewed by Cursor Bugbot for commit 8bebfdc. Bugbot is set up for automated code reviews on this repo. Configure here.
Note
Add keybindings settings editor with add, edit, remove, and conflict detection
/settings/keybindingsroute and sidebar nav item (KeybindingsSettings.tsx) with a full settings panel: searchable list, inline shortcut capture, when-expression builder, conflict indicators, and open-in-editor support.removeKeybindingRulemethod and changesupsertKeybindingRuleto append (rather than replace all same-command bindings) unless an explicitreplacetarget is provided.server.removeKeybindingend-to-end through the RPC contracts, WebSocket layer, and browser local API.replacetarget.Macroscope summarized 8bebfdc.