Skip to content

Add keybindings settings editor#2533

Merged
juliusmarminge merged 9 commits intomainfrom
t3code/0f3965e5
May 6, 2026
Merged

Add keybindings settings editor#2533
juliusmarminge merged 9 commits intomainfrom
t3code/0f3965e5

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 5, 2026

Summary

  • Adds a full keybindings settings page for viewing, searching, editing, and resetting shortcuts.
  • Introduces a visual and text-based when clause editor with validation and unknown-condition warnings.
  • Extracts keybinding parsing/formatting logic into a shared frontend module with dedicated unit tests.
  • Updates settings navigation, routing, and toast behavior to support the new editor flow.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test (includes KeybindingsSettings.logic.test.ts)
  • Not run: browser/manual verification of the new settings page in this session

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/keybindings panel with searchable keybindings table, inline shortcut capture, add/reset/remove actions, conflict warnings, and a visual+text when clause editor with validation and unknown-condition warnings.

Updates keybinding handling end-to-end: upsertKeybindingRule now appends bindings by default (instead of replacing all rules for a command), supports targeted replacement via an optional replace field, and introduces removeKeybindingRule plus a new server.removeKeybinding websocket/local API contract and routing.

Refactors shared frontend logic into KeybindingsSettings.logic.ts (with new unit tests), reuses keybindingFromKeyboardEvent in ProjectScriptsControl, 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

  • Adds a new /settings/keybindings route 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.
  • Adds logic module (KeybindingsSettings.logic.ts) for building keybinding rows, parsing/serializing when-expressions, detecting conflicts, labeling commands, and converting keyboard events to shortcut strings cross-platform.
  • Extends the server keybindings API (keybindings.ts) with a removeKeybindingRule method and changes upsertKeybindingRule to append (rather than replace all same-command bindings) unless an explicit replace target is provided.
  • Wires server.removeKeybinding end-to-end through the RPC contracts, WebSocket layer, and browser local API.
  • Moves the keybindings file path link out of the General settings panel, which previously housed it.
  • Behavioral Change: upsert no longer removes all keybindings for the same command; it only removes an exact duplicate or the specified replace target.

Macroscope summarized 8bebfdc.

- add searchable keybinding management UI and route
- extract shared keybinding parsing and formatting logic
- coalesce duplicate keybinding update toasts
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 5, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: c82c5c6f-3b14-4e6c-903a-9d89ccab6fce

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/0f3965e5

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. size:XXL 1,000+ changed lines (additions + deletions). labels May 5, 2026
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread apps/web/src/components/settings/KeybindingsSettings.logic.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 5, 2026

Approvability

Verdict: 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
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 sourceForBinding to use Array.some() matching by command, shortcut, and when clause, and replaced defaultBindingForCommand with defaultBindingForBinding that matches the exact default entry with cascading fallback.
  • ✅ Fixed: Unused parameter makes useMemo dependency misleading
    • Removed the unused _keybindings parameter from buildWhenVariableOptions and updated the caller to use an empty useMemo dependency array, eliminating pointless re-execution.

Create PR

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.

Comment thread apps/web/src/components/settings/KeybindingsSettings.logic.ts Outdated
Comment thread apps/web/src/components/settings/KeybindingsSettings.logic.ts
- Wrap the keybindings table in `ScrollArea`
- Add a `chainVerticalScroll` option to the shared scroll area
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-auto and added an opt-in overflowX prop that only the keybindings table uses, preventing unwanted horizontal scrollbars in all other ScrollArea consumers.

Create PR

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.

Comment thread apps/web/src/components/ui/scroll-area.tsx
- 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) => {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Create PR

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.

Comment thread apps/web/src/components/settings/KeybindingsSettings.tsx
- 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>
@juliusmarminge juliusmarminge enabled auto-merge (squash) May 6, 2026 00:06
@juliusmarminge juliusmarminge merged commit 0e38870 into main May 6, 2026
12 checks passed
@juliusmarminge juliusmarminge deleted the t3code/0f3965e5 branch May 6, 2026 00:08
Comment on lines +769 to +770
const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft;
const whenDraftExpression = whenAstToExpression(whenDraft);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 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.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 8bebfdc. Configure here.

juliusmarminge added a commit that referenced this pull request May 6, 2026
Co-authored-by: codex <codex@users.noreply.github.com>
juliusmarminge added a commit that referenced this pull request May 6, 2026
Co-authored-by: codex <codex@users.noreply.github.com>
imabdulazeez added a commit to imabdulazeez/t3code that referenced this pull request May 6, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant