From 823caf3a79eb47fa30cec5bec11fefa4c7c7addf Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 13:33:50 -0700 Subject: [PATCH 1/8] Add keybindings settings editor - add searchable keybinding management UI and route - extract shared keybinding parsing and formatting logic - coalesce duplicate keybinding update toasts --- .../components/KeybindingsToast.browser.tsx | 10 +- .../KeybindingsSettings.logic.test.ts | 158 ++++ .../settings/KeybindingsSettings.logic.ts | 269 ++++++ .../settings/KeybindingsSettings.tsx | 852 ++++++++++++++++++ .../components/settings/SettingsPanels.tsx | 42 +- .../settings/SettingsSidebarNav.tsx | 11 +- .../components/settings/settingsLayout.tsx | 12 +- apps/web/src/components/ui/select.tsx | 12 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 6 + apps/web/src/routes/settings.keybindings.tsx | 7 + packages/shared/src/keybindings.ts | 2 +- 12 files changed, 1354 insertions(+), 48 deletions(-) create mode 100644 apps/web/src/components/settings/KeybindingsSettings.logic.test.ts create mode 100644 apps/web/src/components/settings/KeybindingsSettings.logic.ts create mode 100644 apps/web/src/components/settings/KeybindingsSettings.tsx create mode 100644 apps/web/src/routes/settings.keybindings.tsx diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index b6df9712bb..6bb8be2c25 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -532,16 +532,20 @@ describe("Keybindings update toast", () => { document.body.innerHTML = ""; }); - it("shows a toast for each consecutive keybinding update with no issues", async () => { + it("coalesces rapid consecutive keybinding update toasts with no issues", async () => { const mounted = await mountApp(); try { sendServerConfigUpdatedPush([]); await waitForToast("Keybindings updated", 1); - // Each server push represents a distinct file change, so it should produce its own toast. + // A single edit can produce several reload notifications as the direct update and + // filesystem watcher settle, so avoid stacking identical success toasts. sendServerConfigUpdatedPush([]); - await waitForToast("Keybindings updated", 2); + await new Promise((resolve) => setTimeout(resolve, 250)); + + const titles = queryToastTitles(); + expect(titles.filter((title) => title === "Keybindings updated")).toHaveLength(1); } finally { await mounted.cleanup(); } diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts new file mode 100644 index 0000000000..30b1c3c33a --- /dev/null +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it } from "vitest"; +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; + +import { + buildKeybindingRows, + buildWhenVariableOptions, + commandLabel, + keybindingFromKeyboardEvent, + parseWhenExpressionDraft, + shortcutToKeybindingInput, + unknownWhenVariables, + whenAstToExpression, +} from "./KeybindingsSettings.logic"; + +describe("KeybindingsSettings.logic", () => { + it("builds searchable rows with readable key and when values", () => { + const rows = buildKeybindingRows( + [ + { + command: "terminal.toggle", + shortcut: { + key: "j", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ] satisfies ResolvedKeybindingsConfig, + "terminal", + ); + + expect(rows).toEqual([ + expect.objectContaining({ + command: "terminal.toggle", + key: "mod+j", + when: "!terminalFocus", + defaultKey: "mod+j", + defaultWhen: "", + source: "Custom", + }), + ]); + }); + + it("captures platform-specific mod shortcuts", () => { + expect( + keybindingFromKeyboardEvent( + { key: "K", metaKey: true, ctrlKey: false, altKey: false, shiftKey: true }, + "MacIntel", + ), + ).toBe("mod+shift+k"); + expect( + keybindingFromKeyboardEvent( + { key: "K", metaKey: false, ctrlKey: true, altKey: false, shiftKey: true }, + "Win32", + ), + ).toBe("mod+shift+k"); + }); + + it("serializes shortcuts and when expressions for upserts", () => { + expect( + shortcutToKeybindingInput({ + key: " ", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: true, + shiftKey: false, + }), + ).toBe("mod+alt+space"); + + expect( + whenAstToExpression({ + type: "and", + left: { type: "identifier", name: "editorFocus" }, + right: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }), + ).toBe("editorFocus && !terminalFocus"); + + expect(parseWhenExpressionDraft("editorFocus && (!terminalFocus || modelPickerOpen)")).toEqual({ + ok: true, + value: { + type: "and", + left: { type: "identifier", name: "editorFocus" }, + right: { + type: "or", + left: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + right: { type: "identifier", name: "modelPickerOpen" }, + }, + }, + }); + expect(parseWhenExpressionDraft("editorFocus &&")).toEqual({ + ok: false, + message: "Use variables with !, &&, ||, and parentheses.", + }); + + expect(parseWhenExpressionDraft("!(terminalFocus || modelPickerOpen)")).toEqual({ + ok: true, + value: { + type: "not", + node: { + type: "or", + left: { type: "identifier", name: "terminalFocus" }, + right: { type: "identifier", name: "modelPickerOpen" }, + }, + }, + }); + }); + + it("formats static and project script command labels", () => { + expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle"); + expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db"); + }); + + 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); + + expect(options).toEqual( + expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]), + ); + expect(options).not.toContain("customModeActive"); + }); + + it("reports unknown when variables without rejecting parseable expressions", () => { + const parsed = parseWhenExpressionDraft("!terminalFocus && terminalFoc"); + + expect(parsed.ok).toBe(true); + expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]); + }); +}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts new file mode 100644 index 0000000000..a597ae2a89 --- /dev/null +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -0,0 +1,269 @@ +import { + type KeybindingCommand, + type KeybindingShortcut, + type KeybindingWhenNode, + type ResolvedKeybindingRule, + type ResolvedKeybindingsConfig, +} from "@t3tools/contracts"; +import { + DEFAULT_RESOLVED_KEYBINDINGS, + parseKeybindingWhenExpression, +} from "@t3tools/shared/keybindings"; + +import { isMacPlatform } from "../../lib/utils"; + +export type KeybindingSource = "Default" | "Custom" | "Project"; + +export interface KeybindingRow { + readonly command: KeybindingCommand; + readonly key: string; + readonly when: string; + readonly source: KeybindingSource; + readonly defaultKey: string | null; + readonly defaultWhen: string; + readonly binding: ResolvedKeybindingRule; +} + +export type WhenVariableOption = string; + +const CORE_WHEN_VARIABLES = ["terminalFocus", "terminalOpen", "true", "false"] as const; + +const DEFAULT_WHEN_VARIABLES = new Set(CORE_WHEN_VARIABLES); +for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { + collectWhenIdentifiersFromNode(binding.whenAst, DEFAULT_WHEN_VARIABLES); +} + +export const DEFAULT_WHEN_VARIABLE = + [...DEFAULT_WHEN_VARIABLES].find( + (identifier) => identifier !== "true" && identifier !== "false", + ) ?? "terminalFocus"; +const KNOWN_WHEN_VARIABLES = new Set(DEFAULT_WHEN_VARIABLES); + +export function shortcutToKeybindingInput(shortcut: KeybindingShortcut): string { + const parts: string[] = []; + if (shortcut.modKey) parts.push("mod"); + if (shortcut.metaKey) parts.push("meta"); + 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); + return parts.join("+"); +} + +export function whenAstToExpression(node: KeybindingWhenNode | undefined): string { + if (!node) return ""; + switch (node.type) { + case "identifier": + return node.name; + case "not": + return `!${wrapWhenExpression(node.node)}`; + case "and": + return `${wrapWhenExpression(node.left)} && ${wrapWhenExpression(node.right)}`; + case "or": + return `${wrapWhenExpression(node.left)} || ${wrapWhenExpression(node.right)}`; + } +} + +function wrapWhenExpression(node: KeybindingWhenNode): string { + if (node.type === "identifier" || node.type === "not") return whenAstToExpression(node); + return `(${whenAstToExpression(node)})`; +} + +export function parseWhenExpressionDraft( + expression: string, +): { ok: true; value: KeybindingWhenNode | undefined } | { ok: false; message: string } { + const trimmed = expression.trim(); + if (trimmed.length === 0) return { ok: true, value: undefined }; + + const ast = parseKeybindingWhenExpression(trimmed); + if (!ast) { + return { + ok: false, + message: "Use variables with !, &&, ||, and parentheses.", + }; + } + + return { ok: true, value: ast }; +} + +function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource { + if (String(binding.command).startsWith("script.")) { + return "Project"; + } + + const defaultBinding = DEFAULT_RESOLVED_KEYBINDINGS.find( + (entry) => entry.command === binding.command, + ); + if (!defaultBinding) { + return "Custom"; + } + + return shortcutToKeybindingInput(defaultBinding.shortcut) === + shortcutToKeybindingInput(binding.shortcut) && + whenAstToExpression(defaultBinding.whenAst) === whenAstToExpression(binding.whenAst) + ? "Default" + : "Custom"; +} + +function defaultBindingForCommand(command: KeybindingCommand): ResolvedKeybindingRule | undefined { + return DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === command); +} + +export function buildKeybindingRows( + keybindings: ResolvedKeybindingsConfig, + query: string, +): ReadonlyArray { + const normalizedQuery = query.trim().toLowerCase(); + const rows = keybindings.map((binding) => { + const defaultBinding = defaultBindingForCommand(binding.command); + const key = shortcutToKeybindingInput(binding.shortcut); + const when = whenAstToExpression(binding.whenAst); + return { + command: binding.command, + key, + when, + source: sourceForBinding(binding), + defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, + defaultWhen: whenAstToExpression(defaultBinding?.whenAst), + binding, + } satisfies KeybindingRow; + }); + + rows.sort((left, right) => { + const commandCompare = left.command.localeCompare(right.command); + if (commandCompare !== 0) return commandCompare; + return left.key.localeCompare(right.key); + }); + + if (normalizedQuery.length === 0) { + return rows; + } + + return rows.filter((row) => { + return ( + row.command.toLowerCase().includes(normalizedQuery) || + row.key.toLowerCase().includes(normalizedQuery) || + row.when.toLowerCase().includes(normalizedQuery) || + row.source.toLowerCase().includes(normalizedQuery) + ); + }); +} + +function collectWhenIdentifiersFromNode( + node: KeybindingWhenNode | undefined, + identifiers: Set, +): void { + if (!node) return; + switch (node.type) { + case "identifier": + identifiers.add(node.name); + return; + case "not": + collectWhenIdentifiersFromNode(node.node, identifiers); + return; + case "and": + case "or": + collectWhenIdentifiersFromNode(node.left, identifiers); + collectWhenIdentifiersFromNode(node.right, identifiers); + return; + } +} + +export function isKnownWhenVariable(identifier: string): boolean { + return KNOWN_WHEN_VARIABLES.has(identifier); +} + +export function unknownWhenVariables(node: KeybindingWhenNode | undefined): ReadonlyArray { + const identifiers = new Set(); + collectWhenIdentifiersFromNode(node, identifiers); + return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted(); +} + +export function buildWhenVariableOptions( + _keybindings: ResolvedKeybindingsConfig, +): ReadonlyArray { + 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( + right as (typeof CORE_WHEN_VARIABLES)[number], + ); + if (leftCoreIndex !== -1 || rightCoreIndex !== -1) { + return ( + (leftCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : leftCoreIndex) - + (rightCoreIndex === -1 ? Number.MAX_SAFE_INTEGER : rightCoreIndex) + ); + } + return left.localeCompare(right); + }); +} + +export function commandLabel(command: KeybindingCommand): string { + const raw = String(command); + if (raw.startsWith("script.") && raw.endsWith(".run")) { + return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`; + } + return raw.split(".").map(titleCaseCommandSegment).join(": "); +} + +function titleCaseCommandSegment(segment: string): string { + return segment + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.slice(0, 1).toUpperCase() + part.slice(1)) + .join(" "); +} + +export 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 (/^f\d{1,2}$/.test(normalized)) 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; +} + +export function keybindingFromKeyboardEvent( + event: Pick, + platform: string, +): string | null { + const keyToken = normalizeShortcutKeyToken(event.key); + if (!keyToken) return null; + + const parts: string[] = []; + if (isMacPlatform(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("+"); +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx new file mode 100644 index 0000000000..40fe951b50 --- /dev/null +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -0,0 +1,852 @@ +import { + ChevronDownIcon, + CircleXIcon, + InfoIcon, + KeyboardIcon, + MinusIcon, + PlusIcon, + SearchIcon, + TriangleAlertIcon, +} from "lucide-react"; +import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { type KeybindingCommand, type KeybindingWhenNode } from "@t3tools/contracts"; + +import { isElectron } from "../../env"; +import { formatShortcutLabel } from "../../keybindings"; +import { cn } from "../../lib/utils"; +import { ensureLocalApi } from "../../localApi"; +import { useServerKeybindings } from "../../rpc/serverState"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Kbd, KbdGroup } from "../ui/kbd"; +import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { Toggle } from "../ui/toggle"; +import { toastManager } from "../ui/toast"; +import { + buildKeybindingRows, + buildWhenVariableOptions, + commandLabel, + DEFAULT_WHEN_VARIABLE, + isKnownWhenVariable, + keybindingFromKeyboardEvent, + parseWhenExpressionDraft, + type KeybindingRow, + type WhenVariableOption, + unknownWhenVariables, + whenAstToExpression, +} from "./KeybindingsSettings.logic"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +function KeybindingPill({ value }: { value: string }) { + const parts = value.split("+"); + return ( + + {parts.map((part) => ( + + {part === "mod" + ? navigator.platform.toLowerCase().includes("mac") + ? "⌘" + : "Ctrl" + : part === "shift" + ? "⇧" + : part === "alt" + ? navigator.platform.toLowerCase().includes("mac") + ? "⌥" + : "Alt" + : part === "ctrl" + ? "⌃" + : part.length === 1 + ? part.toUpperCase() + : part} + + ))} + + ); +} + +function StatusBadge({ source }: { source: KeybindingRow["source"] }) { + return ( + + {source} + + ); +} + +type BooleanOperator = "and" | "or"; + +function flattenWhenChildren( + node: KeybindingWhenNode, + operator: BooleanOperator, +): KeybindingWhenNode[] { + if (node.type !== operator) return [node]; + return [ + ...flattenWhenChildren(node.left, operator), + ...flattenWhenChildren(node.right, operator), + ]; +} + +function buildWhenExpressionGroup( + children: readonly KeybindingWhenNode[], + operator: BooleanOperator, +): KeybindingWhenNode | undefined { + const first = children[0]; + if (!first) return undefined; + return children.slice(1).reduce( + (left, right) => ({ + type: operator, + left, + right, + }), + first, + ); +} + +function conditionParts(node: KeybindingWhenNode): { identifier: string; negated: boolean } | null { + if (node.type === "identifier") return { identifier: node.name, negated: false }; + if (node.type === "not" && node.node.type === "identifier") { + return { identifier: node.node.name, negated: true }; + } + return null; +} + +function setConditionIdentifier(node: KeybindingWhenNode, identifier: string): KeybindingWhenNode { + const parts = conditionParts(node); + if (!parts) return node; + const next: KeybindingWhenNode = { type: "identifier", name: identifier }; + return parts.negated ? { type: "not", node: next } : next; +} + +function setConditionNegated(node: KeybindingWhenNode, negated: boolean): KeybindingWhenNode { + const parts = conditionParts(node); + if (!parts) return negated ? { type: "not", node } : node; + const identifier: KeybindingWhenNode = { type: "identifier", name: parts.identifier }; + return negated ? { type: "not", node: identifier } : identifier; +} + +function defaultWhenCondition(): KeybindingWhenNode { + return { type: "identifier", name: DEFAULT_WHEN_VARIABLE }; +} + +function defaultWhenGroup(operator: BooleanOperator = "and"): KeybindingWhenNode { + return { + type: operator, + left: defaultWhenCondition(), + right: { type: "not", node: defaultWhenCondition() }, + }; +} + +function UnknownWhenVariableWarning({ + identifiers, + focusable = true, +}: { + identifiers: ReadonlyArray; + focusable?: boolean; +}) { + if (identifiers.length === 0) return null; + const label = + identifiers.length === 1 + ? `Unknown condition: ${identifiers[0]}` + : `Unknown conditions: ${identifiers.join(", ")}`; + + return ( + + + + + } + /> + + T3 Code does not recognize this condition yet. It can still be saved, but it may not match + unless the runtime provides it. + + + ); +} + +function WhenVariableSelect({ + value, + variables, + unknownIdentifiers, + onChange, +}: { + value: string; + variables: ReadonlyArray; + unknownIdentifiers?: ReadonlyArray; + onChange: (value: string) => void; +}) { + const selected = variables.find((option) => option === value); + const options = + selected || variables.some((option) => option === value) ? variables : [value, ...variables]; + + return ( + + ); +} + +function WhenExpressionNodeEditor({ + node, + variables, + depth = 0, + onChange, + onRemove, +}: { + node: KeybindingWhenNode; + variables: ReadonlyArray; + depth?: number; + onChange: (node: KeybindingWhenNode) => void; + onRemove?: () => void; +}) { + const condition = conditionParts(node); + + if (condition) { + const unknownIdentifiers = isKnownWhenVariable(condition.identifier) + ? [] + : [condition.identifier]; + + return ( +
+ onChange(setConditionNegated(node, pressed))} + aria-label={`Negate ${condition.identifier}`} + variant="outline" + size="xs" + className="h-7 min-w-10 px-2 text-[11px] sm:h-7" + > + Not + + onChange(setConditionIdentifier(node, value))} + /> + {onRemove ? ( + + ) : null} +
+ ); + } + + if (node.type === "not") { + return ( +
0 && "border-border/50 bg-background/50", + )} + > +
+ onChange(pressed ? node : node.node)} + aria-label="Negate group" + variant="outline" + size="xs" + className="h-7 min-w-10 px-2 text-[11px] sm:h-7" + > + Not + + {onRemove ? ( + + ) : null} +
+
+ + + onChange({ type: "not", node: next })} + /> +
+
+ ); + } + + const operator: BooleanOperator = node.type === "or" ? "or" : "and"; + const children = flattenWhenChildren(node, operator); + const childKeyCounts = new Map(); + const childEntries = children.map((child) => { + const baseKey = `${child.type}-${whenAstToExpression(child)}`; + const count = childKeyCounts.get(baseKey) ?? 0; + childKeyCounts.set(baseKey, count + 1); + return { child, key: count === 0 ? baseKey : `${baseKey}-${count}` }; + }); + + const updateChild = (target: KeybindingWhenNode, next: KeybindingWhenNode) => { + let didUpdate = false; + const nextChildren = children.map((child) => { + if (!didUpdate && child === target) { + didUpdate = true; + return next; + } + return child; + }); + const nextNode = buildWhenExpressionGroup(nextChildren, operator); + if (nextNode) onChange(nextNode); + }; + + const removeChild = (target: KeybindingWhenNode) => { + let didRemove = false; + const nextChildren = children.filter((child) => { + if (!didRemove && child === target) { + didRemove = true; + return false; + } + return true; + }); + const nextNode = buildWhenExpressionGroup(nextChildren, operator); + if (nextNode) { + onChange(nextNode); + } else { + onChange(defaultWhenCondition()); + } + }; + + const setOperator = (nextOperator: BooleanOperator) => { + if (nextOperator === operator) return; + const nextNode = buildWhenExpressionGroup(children, nextOperator); + if (nextNode) onChange(nextNode); + }; + + const addCondition = () => { + const nextNode = buildWhenExpressionGroup([...children, defaultWhenCondition()], operator); + if (nextNode) onChange(nextNode); + }; + + const addGroup = () => { + const nestedOperator: BooleanOperator = operator === "and" ? "or" : "and"; + const group: KeybindingWhenNode = { + type: nestedOperator, + left: defaultWhenCondition(), + right: { type: "not", node: defaultWhenCondition() }, + }; + const nextNode = buildWhenExpressionGroup([...children, group], operator); + if (nextNode) onChange(nextNode); + }; + + return ( +
0 && "border-border/70 bg-background/55", + )} + > +
+ + + + {onRemove ? ( + + ) : null} +
+
+ {childEntries.map(({ child, key }) => ( +
+ + + updateChild(child, next)} + onRemove={() => removeChild(child)} + /> +
+ ))} +
+
+ ); +} + +function WhenExpressionBuilder({ + value, + variables, + onChange, + onValidityChange, +}: { + value: KeybindingWhenNode | undefined; + variables: ReadonlyArray; + onChange: (value: KeybindingWhenNode | undefined) => void; + onValidityChange?: (valid: boolean) => void; +}) { + const expression = whenAstToExpression(value); + const [expressionDraft, setExpressionDraft] = useState(expression); + const parseResult = useMemo(() => parseWhenExpressionDraft(expressionDraft), [expressionDraft]); + const parseError = parseResult.ok ? null : parseResult.message; + const unknownIdentifiers = parseResult.ok ? unknownWhenVariables(parseResult.value) : []; + + useEffect(() => { + setExpressionDraft(expression); + }, [expression]); + + useEffect(() => { + onValidityChange?.(parseResult.ok); + }, [onValidityChange, parseResult.ok]); + + const updateExpressionDraft = (nextExpression: string) => { + setExpressionDraft(nextExpression); + const nextResult = parseWhenExpressionDraft(nextExpression); + if (nextResult.ok) { + onChange(nextResult.value); + } + }; + + const addRootCondition = () => { + if (!value) { + onChange(defaultWhenCondition()); + return; + } + onChange({ type: "and", left: value, right: defaultWhenCondition() }); + }; + + const addRootGroup = () => { + const group = defaultWhenGroup("or"); + if (!value) { + onChange(group); + return; + } + onChange({ type: "and", left: value, right: group }); + }; + + return ( +
+
+
+
When
+
+
+ + +
+
+ +
+
+ updateExpressionDraft(event.currentTarget.value)} + placeholder="Always" + aria-invalid={Boolean(parseError)} + aria-label="When expression" + className={cn( + "h-7 rounded-md font-mono text-[12px] leading-7 sm:h-7 sm:leading-7", + unknownIdentifiers.length > 0 && "pr-9", + parseError && "border-destructive/70 focus-visible:border-destructive", + )} + /> + {unknownIdentifiers.length > 0 ? ( + + + + ) : null} +
+ {parseError ? ( +
+ + {parseError} +
+ ) : null} +
+ +
+ {value ? ( + onChange(undefined)} + /> + ) : ( +
+
+ + +
+
+ )} + {parseError ? ( +
+ Fix the expression above to continue editing visually. +
+ ) : null} +
+
+ ); +} + +function KeybindingTableRow({ + row, + variables, + isSaving, + onSave, + onReset, +}: { + row: KeybindingRow; + variables: ReadonlyArray; + isSaving: boolean; + onSave: (input: { command: KeybindingCommand; key: string; when: string }) => void; + onReset: (row: KeybindingRow) => void; +}) { + const [keyDraft, setKeyDraft] = useState(row.key); + const [whenDraft, setWhenDraft] = useState(row.binding.whenAst); + const [isRecording, setIsRecording] = useState(false); + const [isWhenDraftValid, setIsWhenDraftValid] = useState(true); + const whenDraftExpression = whenAstToExpression(whenDraft); + const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; + const displayShortcut = formatShortcutLabel(row.binding.shortcut); + const canReset = row.source === "Custom" && row.defaultKey !== null; + + useEffect(() => { + setKeyDraft(row.key); + setWhenDraft(row.binding.whenAst); + setIsWhenDraftValid(true); + }, [row.binding.whenAst, row.key]); + + const save = () => { + onSave({ command: row.command, key: keyDraft, when: whenDraftExpression }); + }; + + const captureKeybinding = (event: KeyboardEvent) => { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Escape") { + setKeyDraft(row.key); + setIsRecording(false); + return; + } + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next) return; + setKeyDraft(next); + setIsRecording(false); + }; + + return ( +
+
+
+ {commandLabel(row.command)} +
+
{row.command}
+
+
+ setIsRecording(true)} + onBlur={() => setIsRecording(false)} + onChange={(event) => setKeyDraft(event.currentTarget.value)} + onKeyDown={captureKeybinding} + /> + {keyDraft === row.key && row.key ? ( +
+ +
+ ) : null} +
+
+ + + {whenDraftExpression || "Always"} + + + + + + +
+
+
+ {isDirty ? ( + + ) : null} +
+ {canReset ? ( + + ) : ( + + )} + {displayShortcut} +
+
+ ); +} + +export function KeybindingsSettingsPanel() { + const keybindings = useServerKeybindings(); + const [query, setQuery] = useState(""); + const [savingCommand, setSavingCommand] = useState(null); + const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); + const whenVariables = useMemo(() => buildWhenVariableOptions(keybindings), [keybindings]); + + const saveKeybinding = useCallback( + (input: { command: KeybindingCommand; key: string; when: string }) => { + setSavingCommand(input.command); + void ensureLocalApi() + .server.upsertKeybinding({ + command: input.command, + key: input.key.trim(), + when: input.when.trim().length > 0 ? input.when.trim() : undefined, + }) + .catch((error: unknown) => { + toastManager.add({ + title: "Unable to save keybinding", + description: error instanceof Error ? error.message : "The keybinding was not saved.", + type: "error", + }); + }) + .finally(() => { + setSavingCommand(null); + }); + }, + [], + ); + + const resetKeybinding = useCallback( + (row: KeybindingRow) => { + if (!row.defaultKey) return; + saveKeybinding({ + command: row.command, + key: row.defaultKey, + when: row.defaultWhen, + }); + }, + [saveKeybinding], + ); + + return ( + + } + headerAction={ + + {rows.length} {rows.length === 1 ? "binding" : "bindings"} + + } + > + {!isElectron ? ( +
+ +

+ Some shortcuts may be claimed by the browser before T3 Code sees them. Keybindings are + most faithful in the desktop app, where built-in browser shortcuts stay out of the + way. +

+
+ ) : null} + +
+
+ + setQuery(event.currentTarget.value)} + placeholder="Search keybindings" + className="h-9 pl-9 text-sm" + aria-label="Search keybindings" + /> +
+
+ +
+
+
Command
+
Keybinding
+
When
+
Status
+
+
+ {rows.map((row) => ( + + ))} + {rows.length === 0 ? ( +
+ No keybindings match your search. +
+ ) : null} +
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8fc36d4a32..beca709243 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -69,7 +69,6 @@ import { import { ProjectFavicon } from "../ProjectFavicon"; import { useServerAvailableEditors, - useServerKeybindingsConfigPath, useServerObservability, useServerProviders, } from "../../rpc/serverState"; @@ -452,11 +451,10 @@ export function GeneralSettingsPanel() { const settings = useSettings(); const { updateSettings } = useUpdateSettings(); const [openingPathByTarget, setOpeningPathByTarget] = useState({ - keybindings: false, logsDirectory: false, }); const [openPathErrorByTarget, setOpenPathErrorByTarget] = useState< - Partial> + Partial> >({}); const [isRefreshingProviders, setIsRefreshingProviders] = useState(false); const [isAddInstanceDialogOpen, setIsAddInstanceDialogOpen] = useState(false); @@ -484,7 +482,6 @@ export function GeneralSettingsPanel() { }); }, []); - const keybindingsConfigPath = useServerKeybindingsConfigPath(); const availableEditors = useServerAvailableEditors(); const observability = useServerObservability(); const serverProviders = useServerProviders(); @@ -533,7 +530,7 @@ export function GeneralSettingsPanel() { ); const openInPreferredEditor = useCallback( - (target: "keybindings" | "logsDirectory", path: string | null, failureMessage: string) => { + (target: "logsDirectory", path: string | null, failureMessage: string) => { if (!path) return; setOpenPathErrorByTarget((existing) => ({ ...existing, [target]: null })); setOpeningPathByTarget((existing) => ({ ...existing, [target]: true })); @@ -563,17 +560,11 @@ export function GeneralSettingsPanel() { [availableEditors], ); - const openKeybindingsFile = useCallback(() => { - openInPreferredEditor("keybindings", keybindingsConfigPath, "Unable to open keybindings file."); - }, [keybindingsConfigPath, openInPreferredEditor]); - const openLogsDirectory = useCallback(() => { openInPreferredEditor("logsDirectory", logsDirectoryPath, "Unable to open logs folder."); }, [logsDirectoryPath, openInPreferredEditor]); - const openKeybindingsError = openPathErrorByTarget.keybindings ?? null; const openDiagnosticsError = openPathErrorByTarget.logsDirectory ?? null; - const isOpeningKeybindings = openingPathByTarget.keybindings; const isOpeningLogsDirectory = openingPathByTarget.logsDirectory; const lastCheckedAt = @@ -1297,35 +1288,6 @@ export function GeneralSettingsPanel() { onOpenChange={setIsAddInstanceDialogOpen} /> - - - - {keybindingsConfigPath ?? "Resolving keybindings path..."} - - {openKeybindingsError ? ( - {openKeybindingsError} - ) : ( - Opens in your preferred editor. - )} - - } - control={ - - } - /> - - {isElectron ? ( diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 88260355d9..5079db8611 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -1,5 +1,12 @@ import { useCallback, type ComponentType } from "react"; -import { ArchiveIcon, ArrowLeftIcon, GitBranchIcon, Link2Icon, Settings2Icon } from "lucide-react"; +import { + ArchiveIcon, + ArrowLeftIcon, + GitBranchIcon, + KeyboardIcon, + Link2Icon, + Settings2Icon, +} from "lucide-react"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import { @@ -15,6 +22,7 @@ import { export type SettingsSectionPath = | "/settings/general" + | "/settings/keybindings" | "/settings/source-control" | "/settings/connections" | "/settings/archived"; @@ -25,6 +33,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ icon: ComponentType<{ className?: string }>; }> = [ { label: "General", to: "/settings/general", icon: Settings2Icon }, + { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Source Control", to: "/settings/source-control", icon: GitBranchIcon }, { label: "Connections", to: "/settings/connections", icon: Link2Icon }, { label: "Archive", to: "/settings/archived", icon: ArchiveIcon }, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index c464165bb3..6959fb124c 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -113,10 +113,18 @@ export function SettingResetButton({ label, onClick }: { label: string; onClick: ); } -export function SettingsPageContainer({ children }: { children: ReactNode }) { +export function SettingsPageContainer({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { return (
-
{children}
+
+ {children} +
); } diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx index 245fa6dd78..3649a567f7 100644 --- a/apps/web/src/components/ui/select.tsx +++ b/apps/web/src/components/ui/select.tsx @@ -105,20 +105,24 @@ function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) { function SelectPopup({ className, + popupClassName, children, side = "bottom", sideOffset = 4, align = "start", alignOffset = 0, alignItemWithTrigger = true, + matchTriggerWidth = true, anchor, ...props }: SelectPrimitive.Popup.Props & { + popupClassName?: string; side?: SelectPrimitive.Positioner.Props["side"]; sideOffset?: SelectPrimitive.Positioner.Props["sideOffset"]; align?: SelectPrimitive.Positioner.Props["align"]; alignOffset?: SelectPrimitive.Positioner.Props["alignOffset"]; alignItemWithTrigger?: SelectPrimitive.Positioner.Props["alignItemWithTrigger"]; + matchTriggerWidth?: boolean; anchor?: SelectPrimitive.Positioner.Props["anchor"]; }) { return ( @@ -144,7 +148,13 @@ function SelectPopup({ > -
+
SettingsRoute, } as any) +const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ + id: '/keybindings', + path: '/keybindings', + getParentRoute: () => SettingsRoute, +} as any) const SettingsGeneralRoute = SettingsGeneralRouteImport.update({ id: '/general', path: '/general', @@ -78,6 +84,7 @@ export interface FileRoutesByFullPath { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -88,6 +95,7 @@ export interface FileRoutesByTo { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -101,6 +109,7 @@ export interface FileRoutesById { '/settings/archived': typeof SettingsArchivedRoute '/settings/connections': typeof SettingsConnectionsRoute '/settings/general': typeof SettingsGeneralRoute + '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -115,6 +124,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/keybindings' | '/settings/source-control' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -125,6 +135,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/keybindings' | '/settings/source-control' | '/' | '/$environmentId/$threadId' @@ -137,6 +148,7 @@ export interface FileRouteTypes { | '/settings/archived' | '/settings/connections' | '/settings/general' + | '/settings/keybindings' | '/settings/source-control' | '/_chat/' | '/_chat/$environmentId/$threadId' @@ -186,6 +198,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsSourceControlRouteImport parentRoute: typeof SettingsRoute } + '/settings/keybindings': { + id: '/settings/keybindings' + path: '/keybindings' + fullPath: '/settings/keybindings' + preLoaderRoute: typeof SettingsKeybindingsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/general': { id: '/settings/general' path: '/general' @@ -242,6 +261,7 @@ interface SettingsRouteChildren { SettingsArchivedRoute: typeof SettingsArchivedRoute SettingsConnectionsRoute: typeof SettingsConnectionsRoute SettingsGeneralRoute: typeof SettingsGeneralRoute + SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -249,6 +269,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsArchivedRoute: SettingsArchivedRoute, SettingsConnectionsRoute: SettingsConnectionsRoute, SettingsGeneralRoute: SettingsGeneralRoute, + SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 58617589df..e1a3a6c8d3 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -286,6 +286,7 @@ function EventRouter() { const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); const seenServerConfigUpdateIdRef = useRef(getServerConfigUpdatedNotification()?.id ?? 0); + const lastKeybindingsSuccessToastAtRef = useRef(0); const disposedRef = useRef(false); const serverConfig = useServerConfig(); @@ -352,6 +353,11 @@ function EventRouter() { const issue = payload.issues.find((entry) => entry.kind.startsWith("keybindings.")); if (!issue) { + const now = Date.now(); + if (now - lastKeybindingsSuccessToastAtRef.current < 2_000) { + return; + } + lastKeybindingsSuccessToastAtRef.current = now; toastManager.add({ type: "success", title: "Keybindings updated", diff --git a/apps/web/src/routes/settings.keybindings.tsx b/apps/web/src/routes/settings.keybindings.tsx new file mode 100644 index 0000000000..f447840786 --- /dev/null +++ b/apps/web/src/routes/settings.keybindings.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { KeybindingsSettingsPanel } from "../components/settings/KeybindingsSettings"; + +export const Route = createFileRoute("/settings/keybindings")({ + component: KeybindingsSettingsPanel, +}); diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index c67ad784fe..3cc2e91362 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -162,7 +162,7 @@ function tokenizeWhenExpression(expression: string): WhenToken[] | null { return tokens; } -function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { +export function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode | null { const tokens = tokenizeWhenExpression(expression); if (!tokens || tokens.length === 0) return null; let index = 0; From 3ba4daa15864fa34f820b7087cf6e58aa1a0557c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 13:55:47 -0700 Subject: [PATCH 2/8] Refactor keybinding drafts and tighten settings layout - 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 --- .../settings/KeybindingsSettings.tsx | 120 ++++++++++-------- 1 file changed, 65 insertions(+), 55 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 40fe951b50..e3fa7d482f 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -8,7 +8,7 @@ import { SearchIcon, TriangleAlertIcon, } from "lucide-react"; -import { type KeyboardEvent, useCallback, useEffect, useMemo, useState } from "react"; +import { type KeyboardEvent, useCallback, useMemo, useReducer, useState } from "react"; import { type KeybindingCommand, type KeybindingWhenNode } from "@t3tools/contracts"; import { isElectron } from "../../env"; @@ -488,37 +488,36 @@ function WhenExpressionBuilder({ const parseError = parseResult.ok ? null : parseResult.message; const unknownIdentifiers = parseResult.ok ? unknownWhenVariables(parseResult.value) : []; - useEffect(() => { - setExpressionDraft(expression); - }, [expression]); - - useEffect(() => { - onValidityChange?.(parseResult.ok); - }, [onValidityChange, parseResult.ok]); - const updateExpressionDraft = (nextExpression: string) => { setExpressionDraft(nextExpression); const nextResult = parseWhenExpressionDraft(nextExpression); + onValidityChange?.(nextResult.ok); if (nextResult.ok) { onChange(nextResult.value); } }; + const updateExpressionValue = (nextValue: KeybindingWhenNode | undefined) => { + setExpressionDraft(whenAstToExpression(nextValue)); + onValidityChange?.(true); + onChange(nextValue); + }; + const addRootCondition = () => { if (!value) { - onChange(defaultWhenCondition()); + updateExpressionValue(defaultWhenCondition()); return; } - onChange({ type: "and", left: value, right: defaultWhenCondition() }); + updateExpressionValue({ type: "and", left: value, right: defaultWhenCondition() }); }; const addRootGroup = () => { const group = defaultWhenGroup("or"); if (!value) { - onChange(group); + updateExpressionValue(group); return; } - onChange({ type: "and", left: value, right: group }); + updateExpressionValue({ type: "and", left: value, right: group }); }; return ( @@ -584,8 +583,8 @@ function WhenExpressionBuilder({ onChange(undefined)} + onChange={updateExpressionValue} + onRemove={() => updateExpressionValue(undefined)} /> ) : (
@@ -617,6 +616,29 @@ function WhenExpressionBuilder({ ); } +type KeybindingRowDraftState = { + keyDraft: string; + whenDraft: KeybindingWhenNode | undefined; + isRecording: boolean; + isWhenDraftValid: boolean; +}; + +function createKeybindingRowDraft(row: KeybindingRow): KeybindingRowDraftState { + return { + keyDraft: row.key, + whenDraft: row.binding.whenAst, + isRecording: false, + isWhenDraftValid: true, + }; +} + +function keybindingRowDraftReducer( + state: KeybindingRowDraftState, + patch: Partial, +): KeybindingRowDraftState { + return { ...state, ...patch }; +} + function KeybindingTableRow({ row, variables, @@ -630,21 +652,13 @@ function KeybindingTableRow({ onSave: (input: { command: KeybindingCommand; key: string; when: string }) => void; onReset: (row: KeybindingRow) => void; }) { - const [keyDraft, setKeyDraft] = useState(row.key); - const [whenDraft, setWhenDraft] = useState(row.binding.whenAst); - const [isRecording, setIsRecording] = useState(false); - const [isWhenDraftValid, setIsWhenDraftValid] = useState(true); + const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); + const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; const whenDraftExpression = whenAstToExpression(whenDraft); const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; const displayShortcut = formatShortcutLabel(row.binding.shortcut); const canReset = row.source === "Custom" && row.defaultKey !== null; - useEffect(() => { - setKeyDraft(row.key); - setWhenDraft(row.binding.whenAst); - setIsWhenDraftValid(true); - }, [row.binding.whenAst, row.key]); - const save = () => { onSave({ command: row.command, key: keyDraft, when: whenDraftExpression }); }; @@ -653,23 +667,20 @@ function KeybindingTableRow({ if (event.key === "Tab") return; event.preventDefault(); if (event.key === "Escape") { - setKeyDraft(row.key); - setIsRecording(false); + setDraft({ keyDraft: row.key, isRecording: false }); return; } const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); if (!next) return; - setKeyDraft(next); - setIsRecording(false); + setDraft({ keyDraft: next, isRecording: false }); }; return ( -
+
-
+
{commandLabel(row.command)}
-
{row.command}
setIsRecording(true)} - onBlur={() => setIsRecording(false)} - onChange={(event) => setKeyDraft(event.currentTarget.value)} + onFocus={() => setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} onKeyDown={captureKeybinding} /> {keyDraft === row.key && row.key ? ( @@ -695,7 +706,7 @@ function KeybindingTableRow({ setDraft({ whenDraft: nextWhenDraft })} + onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} />
-
- {isDirty ? ( - - ) : null} -
+ {isDirty ? ( + + ) : null} {canReset ? (
-
+
Command
Keybinding
When
Status
-
+
{rows.map((row) => ( Date: Tue, 5 May 2026 14:41:18 -0700 Subject: [PATCH 3/8] Improve keybindings settings header and editing flow - 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 --- .../settings/KeybindingsSettings.tsx | 221 +++++++++++++++--- 1 file changed, 185 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index e3fa7d482f..e1d637031a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1,6 +1,7 @@ import { ChevronDownIcon, CircleXIcon, + FileJsonIcon, InfoIcon, KeyboardIcon, MinusIcon, @@ -8,14 +9,25 @@ import { SearchIcon, TriangleAlertIcon, } from "lucide-react"; -import { type KeyboardEvent, useCallback, useMemo, useReducer, useState } from "react"; +import { + type KeyboardEvent, + type ReactNode, + type RefObject, + useCallback, + useEffect, + useMemo, + useReducer, + useRef, + useState, +} from "react"; import { type KeybindingCommand, type KeybindingWhenNode } from "@t3tools/contracts"; import { isElectron } from "../../env"; +import { openInPreferredEditor } from "../../editorPreferences"; import { formatShortcutLabel } from "../../keybindings"; import { cn } from "../../lib/utils"; import { ensureLocalApi } from "../../localApi"; -import { useServerKeybindings } from "../../rpc/serverState"; +import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/serverState"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; @@ -70,7 +82,7 @@ function StatusBadge({ source }: { source: KeybindingRow["source"] }) { return ( void; + isOpen: boolean; + onOpenChange: (next: boolean) => void; + inputRef?: RefObject; + collapsedAccessory?: ReactNode; +}) { + if (!isOpen) { + return ( + <> + {collapsedAccessory} + + onOpenChange(true)} + aria-label="Search keybindings" + > + + + } + /> + Search keybindings + + + ); + } + + return ( +
+ + onChange(event.currentTarget.value)} + onBlur={() => { + if (query.length === 0) onOpenChange(false); + }} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onChange(""); + onOpenChange(false); + } + }} + placeholder="Search keybindings" + aria-label="Search keybindings" + className="h-6 w-44 rounded-md border border-input bg-background pl-7 pr-2 text-[11px] text-foreground outline-none placeholder:text-muted-foreground/72 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/24" + /> +
+ ); +} + type BooleanOperator = "and" | "or"; function flattenWhenChildren( @@ -658,6 +737,7 @@ function KeybindingTableRow({ const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; const displayShortcut = formatShortcutLabel(row.binding.shortcut); const canReset = row.source === "Custom" && row.defaultKey !== null; + const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; const save = () => { onSave({ command: row.command, key: keyDraft, when: whenDraftExpression }); @@ -683,24 +763,33 @@ function KeybindingTableRow({
- setDraft({ isRecording: true })} - onBlur={() => setDraft({ isRecording: false })} - onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} - onKeyDown={captureKeybinding} - /> - {keyDraft === row.key && row.key ? ( -
+ {showPill ? ( +
- ) : null} + + Edit + + + ) : ( + setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={captureKeybinding} + /> + )}
@@ -757,11 +846,53 @@ function KeybindingTableRow({ export function KeybindingsSettingsPanel() { const keybindings = useServerKeybindings(); + const keybindingsConfigPath = useServerKeybindingsConfigPath(); const [query, setQuery] = useState(""); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const searchInputRef = useRef(null); const [savingCommand, setSavingCommand] = useState(null); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); const whenVariables = useMemo(() => buildWhenVariableOptions(keybindings), [keybindings]); + useEffect(() => { + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + const isMod = event.metaKey || event.ctrlKey; + if (!isMod || event.altKey || event.key.toLowerCase() !== "f") return; + + const target = event.target; + if ( + target !== searchInputRef.current && + target instanceof HTMLElement && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.isContentEditable) + ) { + return; + } + + event.preventDefault(); + setIsSearchOpen(true); + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, []); + + const openKeybindingsFile = useCallback(() => { + if (!keybindingsConfigPath) return; + void openInPreferredEditor(ensureLocalApi(), keybindingsConfigPath).catch((error: unknown) => { + toastManager.add({ + title: "Unable to open keybindings file", + description: + error instanceof Error ? error.message : "The keybindings file was not opened.", + type: "error", + }); + }); + }, [keybindingsConfigPath]); + const saveKeybinding = useCallback( (input: { command: KeybindingCommand; key: string; when: string }) => { setSavingCommand(input.command); @@ -797,15 +928,46 @@ export function KeybindingsSettingsPanel() { [saveKeybinding], ); + const bindingsCount = ( + + {rows.length} {rows.length === 1 ? "binding" : "bindings"} + + ); + return ( } headerAction={ - - {rows.length} {rows.length === 1 ? "binding" : "bindings"} - +
+ + + + + + } + /> + Open keybindings.json + +
} > {!isElectron ? ( @@ -818,19 +980,6 @@ export function KeybindingsSettingsPanel() {
) : null} -
-
- - setQuery(event.currentTarget.value)} - placeholder="Search keybindings" - className="h-9 pl-9 text-sm" - aria-label="Search keybindings" - /> -
-
-
Command
From 28240301d208804c004d7161301bfb695b978695 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 14:46:08 -0700 Subject: [PATCH 4/8] Simplify keybinding input target check - Keep keyboard shortcuts from firing while typing in inputs, textareas, and contenteditable fields --- apps/web/src/components/settings/KeybindingsSettings.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index e1d637031a..0b6685f1b0 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -863,9 +863,7 @@ export function KeybindingsSettingsPanel() { if ( target !== searchInputRef.current && target instanceof HTMLElement && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.isContentEditable) + (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) ) { return; } From 19e5d52bc79ecd384979b1b7a02f8b2c3f79e490 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 15:03:22 -0700 Subject: [PATCH 5/8] Enable vertical scroll chaining in keybindings settings - Wrap the keybindings table in `ScrollArea` - Add a `chainVerticalScroll` option to the shared scroll area --- .../src/components/settings/KeybindingsSettings.tsx | 10 ++++++++-- apps/web/src/components/ui/scroll-area.tsx | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 0b6685f1b0..efd8ef6071 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -32,6 +32,7 @@ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; +import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; @@ -978,7 +979,12 @@ export function KeybindingsSettingsPanel() {
) : null} -
+
Command
Keybinding
@@ -1002,7 +1008,7 @@ export function KeybindingsSettingsPanel() {
) : null}
-
+ ); diff --git a/apps/web/src/components/ui/scroll-area.tsx b/apps/web/src/components/ui/scroll-area.tsx index 37ffbb185a..f9a3638675 100644 --- a/apps/web/src/components/ui/scroll-area.tsx +++ b/apps/web/src/components/ui/scroll-area.tsx @@ -10,11 +10,13 @@ function ScrollArea({ scrollFade = false, scrollbarGutter = false, hideScrollbars = false, + chainVerticalScroll = false, ...props }: ScrollAreaPrimitive.Root.Props & { scrollFade?: boolean; scrollbarGutter?: boolean; hideScrollbars?: boolean; + chainVerticalScroll?: boolean; }) { return ( Date: Tue, 5 May 2026 16:13:54 -0700 Subject: [PATCH 6/8] Refine keybinding editing and conflict handling - 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 --- apps/server/src/keybindings.test.ts | 51 ++- apps/server/src/keybindings.ts | 62 ++- apps/server/src/server.test.ts | 36 ++ apps/server/src/ws.ts | 9 + .../src/components/ProjectScriptsControl.tsx | 55 +-- .../KeybindingsSettings.logic.test.ts | 114 +++++- .../settings/KeybindingsSettings.logic.ts | 108 ++++- .../settings/KeybindingsSettings.tsx | 380 ++++++++++++++++-- apps/web/src/localApi.ts | 4 + apps/web/src/rpc/wsRpcClient.ts | 3 + packages/contracts/src/ipc.ts | 4 +- packages/contracts/src/keybindings.ts | 6 +- packages/contracts/src/rpc.ts | 10 + packages/contracts/src/server.ts | 26 +- 14 files changed, 740 insertions(+), 128 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index bbb7e1b430..efada98bf5 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -354,7 +354,7 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("replaces existing custom keybinding for the same command", () => + it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig; yield* writeKeybindingsConfig(keybindingsConfigPath, [ @@ -368,6 +368,55 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); }); + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("replaces only the targeted custom keybinding", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.upsertKeybindingRule({ + key: "mod+alt+r", + command: "script.run-tests.run", + replace: { key: "mod+r", command: "script.run-tests.run" }, + }); + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + const persistedView = persisted.map(({ key, command }) => ({ key, command })); + assert.deepEqual(persistedView, [ + { key: "mod+shift+r", command: "script.run-tests.run" }, + { key: "mod+alt+r", command: "script.run-tests.run" }, + ]); + }).pipe(Effect.provide(makeKeybindingsLayer())), + ); + + it.effect("removes only the targeted custom keybinding", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+r", command: "script.run-tests.run" }, + { key: "mod+shift+r", command: "script.run-tests.run" }, + ]); + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings; + return yield* keybindings.removeKeybindingRule({ + key: "mod+r", + command: "script.run-tests.run", + }); + }); + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); const persistedView = persisted.map(({ key, command }) => ({ key, command })); assert.deepEqual(persistedView, [{ key: "mod+shift+r", command: "script.run-tests.run" }]); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 4d7ee887a9..95bf5c3d9a 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -15,6 +15,8 @@ import { MAX_KEYBINDINGS_COUNT, ResolvedKeybindingRule, ResolvedKeybindingsConfig, + type ServerRemoveKeybindingInput, + type ServerUpsertKeybindingInput, type ServerConfigIssue, } from "@t3tools/contracts"; import { @@ -124,6 +126,25 @@ function hasSameShortcutContext(left: KeybindingRule, right: KeybindingRule): bo return leftContext === rightContext; } +function keybindingRuleFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule { + return input.when === undefined + ? { key: input.key, command: input.command } + : { key: input.key, command: input.command, when: input.when }; +} + +function replaceTargetFromUpsertInput(input: ServerUpsertKeybindingInput): KeybindingRule | null { + if (!input.replace) return null; + return input.replace.when === undefined + ? { key: input.replace.key, command: input.replace.command } + : { key: input.replace.key, command: input.replace.command, when: input.replace.when }; +} + +function keybindingRuleFromRemoveInput(input: ServerRemoveKeybindingInput): KeybindingRule { + return input.when === undefined + ? { key: input.key, command: input.command } + : { key: input.key, command: input.command, when: input.when }; +} + function encodeShortcut(shortcut: KeybindingShortcut): string | null { const modifiers: string[] = []; if (shortcut.modKey) modifiers.push("mod"); @@ -259,7 +280,14 @@ export interface KeybindingsShape { * oldest entries when needed. */ readonly upsertKeybindingRule: ( - rule: KeybindingRule, + input: ServerUpsertKeybindingInput, + ) => Effect.Effect; + + /** + * Remove a single persisted keybinding rule by exact key/command/when match. + */ + readonly removeKeybindingRule: ( + input: ServerRemoveKeybindingInput, ) => Effect.Effect; } @@ -616,12 +644,19 @@ const makeKeybindings = Effect.gen(function* () { get streamChanges() { return Stream.fromPubSub(changesPubSub); }, - upsertKeybindingRule: (rule) => + upsertKeybindingRule: (input) => upsertSemaphore.withPermits(1)( Effect.gen(function* () { const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const rule = keybindingRuleFromUpsertInput(input); + const replaceTarget = replaceTargetFromUpsertInput(input); const nextConfig = [ - ...customConfig.filter((entry) => entry.command !== rule.command), + ...customConfig.filter((entry) => { + if (replaceTarget) { + return !isSameKeybindingRule(entry, replaceTarget); + } + return !isSameKeybindingRule(entry, rule); + }), rule, ]; const cappedConfig = @@ -649,6 +684,27 @@ const makeKeybindings = Effect.gen(function* () { return nextResolved; }), ), + removeKeybindingRule: (input) => + upsertSemaphore.withPermits(1)( + Effect.gen(function* () { + const customConfig = yield* loadWritableCustomKeybindingsConfig(); + const target = keybindingRuleFromRemoveInput(input); + const nextConfig = customConfig.filter((entry) => !isSameKeybindingRule(entry, target)); + yield* writeConfigAtomically(nextConfig); + const nextResolved = mergeWithDefaultKeybindings( + compileResolvedKeybindingsConfig(nextConfig), + ); + yield* Cache.set(resolvedConfigCache, resolvedConfigCacheKey, { + keybindings: nextResolved, + issues: [], + }); + yield* emitChange({ + keybindings: nextResolved, + issues: [], + }); + return nextResolved; + }), + ), } satisfies KeybindingsShape; }); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 152fba1ea2..a25ca1fc04 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1910,6 +1910,42 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.removeKeybinding", () => + Effect.gen(function* () { + const rule: KeybindingRule = { + command: "terminal.toggle", + key: "ctrl+k", + }; + const resolved: ResolvedKeybindingRule = { + command: "terminal.toggle", + shortcut: { + key: "j", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, + }, + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + removeKeybindingRule: () => Effect.succeed([resolved]), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const response = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => client[WS_METHODS.serverRemoveKeybinding](rule)), + ); + + assert.deepEqual(response.issues, []); + assert.deepEqual(response.keybindings, [resolved]); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("rejects websocket rpc handshake when session authentication is missing", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fbefe6eac6..20755dfe41 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -783,6 +783,15 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => }), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverRemoveKeybinding]: (rule) => + observeRpcEffect( + WS_METHODS.serverRemoveKeybinding, + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); + return { keybindings: keybindingsConfig, issues: [] }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverGetSettings]: (_input) => observeRpcEffect( WS_METHODS.serverGetSettings, diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 11b08cc2cf..4a9cda19d6 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -20,13 +20,13 @@ import { keybindingValueForCommand, decodeProjectScriptKeybindingRule, } from "~/lib/projectScriptKeybindings"; +import { keybindingFromKeyboardEvent } from "~/components/settings/KeybindingsSettings.logic"; import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; -import { isMacPlatform } from "~/lib/utils"; import { AlertDialog, AlertDialogClose, @@ -96,57 +96,6 @@ interface ProjectScriptsControlProps { onDeleteScript: (scriptId: string) => Promise | 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): 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 @@ export default function ProjectScriptsControl({ setKeybinding(""); return; } - const next = keybindingFromEvent(event); + const next = keybindingFromKeyboardEvent(event, navigator.platform); if (!next) return; setKeybinding(next); }; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 30b1c3c33a..6c52b9579c 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -3,8 +3,10 @@ import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; import { buildKeybindingRows, + buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, + keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, shortcutToKeybindingInput, @@ -124,29 +126,30 @@ describe("KeybindingsSettings.logic", () => { }); it("builds known when variable options from defaults without frontend labels", () => { - const options = buildWhenVariableOptions([ + const options = buildWhenVariableOptions(); + + expect(options).toEqual( + expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]), + ); + expect(options).not.toContain("customModeActive"); + }); + + it("builds command options from defaults and resolved project bindings", () => { + const options = buildKeybindingCommandOptions([ { - command: "terminal.toggle", + command: "script.setup-db.run", shortcut: { - key: "j", + key: "r", 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); - expect(options).toEqual( - expect.arrayContaining(["terminalFocus", "terminalOpen", "modelPickerOpen", "true", "false"]), - ); - expect(options).not.toContain("customModeActive"); + expect(options).toEqual(expect.arrayContaining(["chat.new", "script.setup-db.run"])); }); it("reports unknown when variables without rejecting parseable expressions", () => { @@ -155,4 +158,91 @@ describe("KeybindingsSettings.logic", () => { expect(parsed.ok).toBe(true); expect(unknownWhenVariables(parsed.ok ? parsed.value : undefined)).toEqual(["terminalFoc"]); }); + + it("marks each default shortcut for multi-binding commands as default", () => { + const rows = buildKeybindingRows( + [ + { + command: "chat.new", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "chat.new", + shortcut: { + key: "o", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: true, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ] satisfies ResolvedKeybindingsConfig, + "", + ); + + expect(rows.map((row) => row.source)).toEqual(["Default", "Default"]); + }); + + it("reports conflicting shortcuts that share an active when context", () => { + const rows = buildKeybindingRows( + [ + { + command: "chat.new", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + { + command: "chat.newLocal", + shortcut: { + key: "n", + modKey: true, + metaKey: false, + ctrlKey: false, + altKey: false, + shiftKey: false, + }, + whenAst: { + type: "not", + node: { type: "identifier", name: "terminalFocus" }, + }, + }, + ] satisfies ResolvedKeybindingsConfig, + "", + ); + + expect(rows[0]?.conflicts).toEqual(["Chat: New Local"]); + expect( + keybindingConflictLabels(rows, { + rowId: rows[0]?.id ?? "", + key: "mod+n", + when: "", + }), + ).toEqual(["Chat: New Local"]); + }); }); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index a597ae2a89..bb6b92048a 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -15,6 +15,7 @@ import { isMacPlatform } from "../../lib/utils"; export type KeybindingSource = "Default" | "Custom" | "Project"; export interface KeybindingRow { + readonly id: string; readonly command: KeybindingCommand; readonly key: string; readonly when: string; @@ -22,9 +23,11 @@ export interface KeybindingRow { readonly defaultKey: string | null; readonly defaultWhen: string; readonly binding: ResolvedKeybindingRule; + readonly conflicts: ReadonlyArray; } export type WhenVariableOption = string; +export type KeybindingCommandOption = KeybindingCommand; const CORE_WHEN_VARIABLES = ["terminalFocus", "terminalOpen", "true", "false"] as const; @@ -91,22 +94,61 @@ function sourceForBinding(binding: ResolvedKeybindingRule): KeybindingSource { 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 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) + ); +} + +function keybindingRowId(command: KeybindingCommand, key: string, when: string): string { + return `${command}\u0000${key}\u0000${when}`; +} + +function conflictsWithWhen(leftWhen: string, rightWhen: string): boolean { + return leftWhen.length === 0 || rightWhen.length === 0 || leftWhen === rightWhen; } -function defaultBindingForCommand(command: KeybindingCommand): ResolvedKeybindingRule | undefined { - return DEFAULT_RESOLVED_KEYBINDINGS.find((entry) => entry.command === command); +export function keybindingConflictLabels( + rows: ReadonlyArray, + input: { readonly rowId: string; readonly key: string; readonly when: string }, +): ReadonlyArray { + if (input.key.trim().length === 0) return []; + const conflicts = rows + .filter( + (candidate) => + candidate.id !== input.rowId && + candidate.key === input.key && + conflictsWithWhen(candidate.when, input.when), + ) + .map((candidate) => commandLabel(candidate.command)); + return [...new Set(conflicts)].toSorted(); } export function buildKeybindingRows( @@ -114,11 +156,12 @@ export function buildKeybindingRows( query: string, ): ReadonlyArray { const normalizedQuery = query.trim().toLowerCase(); - const rows = keybindings.map((binding) => { - const defaultBinding = defaultBindingForCommand(binding.command); + const rows = keybindings.map((binding, index) => { + const defaultBinding = defaultBindingForBinding(binding); const key = shortcutToKeybindingInput(binding.shortcut); const when = whenAstToExpression(binding.whenAst); return { + id: `${keybindingRowId(binding.command, key, when)}\u0000${index}`, command: binding.command, key, when, @@ -126,20 +169,32 @@ export function buildKeybindingRows( defaultKey: defaultBinding ? shortcutToKeybindingInput(defaultBinding.shortcut) : null, defaultWhen: whenAstToExpression(defaultBinding?.whenAst), binding, + conflicts: [], } satisfies KeybindingRow; }); - rows.sort((left, right) => { + const rowsWithConflicts = rows.map((row) => { + const conflicts = keybindingConflictLabels(rows, { + rowId: row.id, + key: row.key, + when: row.when, + }); + return conflicts.length > 0 + ? Object.assign({}, row, { conflicts: [...new Set(conflicts)].toSorted() }) + : row; + }); + + rowsWithConflicts.sort((left, right) => { const commandCompare = left.command.localeCompare(right.command); if (commandCompare !== 0) return commandCompare; return left.key.localeCompare(right.key); }); if (normalizedQuery.length === 0) { - return rows; + return rowsWithConflicts; } - return rows.filter((row) => { + return rowsWithConflicts.filter((row) => { return ( row.command.toLowerCase().includes(normalizedQuery) || row.key.toLowerCase().includes(normalizedQuery) || @@ -179,9 +234,7 @@ export function unknownWhenVariables(node: KeybindingWhenNode | undefined): Read return [...identifiers].filter((identifier) => !isKnownWhenVariable(identifier)).toSorted(); } -export function buildWhenVariableOptions( - _keybindings: ResolvedKeybindingsConfig, -): ReadonlyArray { +export function buildWhenVariableOptions(): ReadonlyArray { 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( @@ -197,6 +250,21 @@ export function buildWhenVariableOptions( }); } +export function buildKeybindingCommandOptions( + keybindings: ResolvedKeybindingsConfig, +): ReadonlyArray { + const commands = new Set(); + for (const binding of DEFAULT_RESOLVED_KEYBINDINGS) { + commands.add(binding.command); + } + for (const binding of keybindings) { + commands.add(binding.command); + } + return [...commands].toSorted((left, right) => + commandLabel(left).localeCompare(commandLabel(right)), + ); +} + export function commandLabel(command: KeybindingCommand): string { const raw = String(command); if (raw.startsWith("script.") && raw.endsWith(".run")) { diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index efd8ef6071..41a440efee 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1,4 +1,5 @@ import { + BanIcon, ChevronDownIcon, CircleXIcon, FileJsonIcon, @@ -7,6 +8,7 @@ import { MinusIcon, PlusIcon, SearchIcon, + Trash2Icon, TriangleAlertIcon, } from "lucide-react"; import { @@ -20,7 +22,12 @@ import { useRef, useState, } from "react"; -import { type KeybindingCommand, type KeybindingWhenNode } from "@t3tools/contracts"; +import { + type KeybindingCommand, + type KeybindingWhenNode, + type ServerRemoveKeybindingInput, + type ServerUpsertKeybindingInput, +} from "@t3tools/contracts"; import { isElectron } from "../../env"; import { openInPreferredEditor } from "../../editorPreferences"; @@ -38,12 +45,15 @@ import { Toggle } from "../ui/toggle"; import { toastManager } from "../ui/toast"; import { buildKeybindingRows, + buildKeybindingCommandOptions, buildWhenVariableOptions, commandLabel, DEFAULT_WHEN_VARIABLE, isKnownWhenVariable, + keybindingConflictLabels, keybindingFromKeyboardEvent, parseWhenExpressionDraft, + type KeybindingCommandOption, type KeybindingRow, type WhenVariableOption, unknownWhenVariables, @@ -259,6 +269,33 @@ function UnknownWhenVariableWarning({ ); } +function KeybindingConflictWarning({ labels }: { labels: ReadonlyArray }) { + if (labels.length === 0) return null; + const description = + labels.length === 1 + ? `Conflicts with ${labels[0]}.` + : `Conflicts with ${labels.slice(0, 3).join(", ")}${labels.length > 3 ? ", and more" : ""}.`; + + return ( + + + + + } + /> + + {description} The most recent matching binding wins when both conditions can apply. + + + ); +} + function WhenVariableSelect({ value, variables, @@ -719,18 +756,32 @@ function keybindingRowDraftReducer( return { ...state, ...patch }; } +function rowKeybindingTarget(row: KeybindingRow): ServerRemoveKeybindingInput { + return { + command: row.command, + key: row.key, + ...(row.when.trim().length > 0 ? { when: row.when } : {}), + }; +} + function KeybindingTableRow({ row, + allRows, variables, isSaving, onSave, onReset, + onRemove, + onDisable, }: { row: KeybindingRow; + allRows: ReadonlyArray; variables: ReadonlyArray; isSaving: boolean; - onSave: (input: { command: KeybindingCommand; key: string; when: string }) => void; + onSave: (input: ServerUpsertKeybindingInput) => void; onReset: (row: KeybindingRow) => void; + onRemove: (row: KeybindingRow) => void; + onDisable: (row: KeybindingRow) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; @@ -738,10 +789,22 @@ function KeybindingTableRow({ const isDirty = keyDraft !== row.key || whenDraftExpression !== row.when; const displayShortcut = formatShortcutLabel(row.binding.shortcut); const canReset = row.source === "Custom" && row.defaultKey !== null; + const canRemove = row.source !== "Default"; + const canDisable = row.when !== "false"; const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; + const conflictLabels = keybindingConflictLabels(allRows, { + rowId: row.id, + key: keyDraft, + when: whenDraftExpression, + }); const save = () => { - onSave({ command: row.command, key: keyDraft, when: whenDraftExpression }); + onSave({ + command: row.command, + key: keyDraft, + when: whenDraftExpression.trim().length > 0 ? whenDraftExpression : undefined, + replace: rowKeybindingTarget(row), + }); }; const captureKeybinding = (event: KeyboardEvent) => { @@ -757,10 +820,12 @@ function KeybindingTableRow({ }; return ( -
+
-
- {commandLabel(row.command)} +
+
+ {commandLabel(row.command)} +
@@ -814,7 +879,7 @@ function KeybindingTableRow({
-
+
{isDirty ? ( + } + /> + Disable binding + + ) : null} + {canRemove ? ( + + onRemove(row)} + > + + + } + /> + Remove binding + + ) : null} {displayShortcut} + +
+
+ ); +} + +function NewKeybindingTableRow({ + commandOptions, + allRows, + variables, + isSaving, + onSave, + onCancel, +}: { + commandOptions: ReadonlyArray; + allRows: ReadonlyArray; + variables: ReadonlyArray; + isSaving: boolean; + onSave: (input: ServerUpsertKeybindingInput) => void; + onCancel: () => void; +}) { + const [commandDraft, setCommandDraft] = useState(""); + const [draft, setDraft] = useReducer(keybindingRowDraftReducer, { + keyDraft: "", + whenDraft: undefined, + isRecording: false, + isWhenDraftValid: true, + }); + const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; + const whenDraftExpression = whenAstToExpression(whenDraft); + const conflictLabels = keybindingConflictLabels(allRows, { + rowId: "new", + key: keyDraft, + when: whenDraftExpression, + }); + const commandLabelText = commandDraft ? commandLabel(commandDraft) : "new keybinding"; + + const save = () => { + if (!commandDraft) return; + onSave({ + command: commandDraft, + key: keyDraft, + ...(whenDraftExpression.trim().length > 0 ? { when: whenDraftExpression } : {}), + }); + }; + + const captureKeybinding = (event: KeyboardEvent) => { + if (event.key === "Tab") return; + event.preventDefault(); + if (event.key === "Escape") { + setDraft({ keyDraft: "", isRecording: false }); + return; + } + const next = keybindingFromKeyboardEvent(event.nativeEvent, navigator.platform); + if (!next) return; + setDraft({ keyDraft: next, isRecording: false }); + }; + + return ( +
+
+ +
+
+ setDraft({ isRecording: true })} + onBlur={() => setDraft({ isRecording: false })} + onChange={(event) => setDraft({ keyDraft: event.currentTarget.value })} + onKeyDown={captureKeybinding} + /> +
+
+ + + {whenDraftExpression || "Always"} + + + + setDraft({ whenDraft: nextWhenDraft })} + onValidityChange={(nextIsValid) => setDraft({ isWhenDraftValid: nextIsValid })} + /> + + +
+
+ + +
); @@ -852,8 +1098,10 @@ export function KeybindingsSettingsPanel() { const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); const [savingCommand, setSavingCommand] = useState(null); + const [isAddingBinding, setIsAddingBinding] = useState(false); const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]); - const whenVariables = useMemo(() => buildWhenVariableOptions(keybindings), [keybindings]); + const commandOptions = useMemo(() => buildKeybindingCommandOptions(keybindings), [keybindings]); + const whenVariables = useMemo(() => buildWhenVariableOptions(), []); useEffect(() => { const handleKeyDown = (event: globalThis.KeyboardEvent) => { @@ -892,27 +1140,57 @@ export function KeybindingsSettingsPanel() { }); }, [keybindingsConfigPath]); - const saveKeybinding = useCallback( - (input: { command: KeybindingCommand; key: string; when: string }) => { - setSavingCommand(input.command); - void ensureLocalApi() - .server.upsertKeybinding({ - command: input.command, - key: input.key.trim(), - when: input.when.trim().length > 0 ? input.when.trim() : undefined, - }) - .catch((error: unknown) => { - toastManager.add({ - title: "Unable to save keybinding", - description: error instanceof Error ? error.message : "The keybinding was not saved.", - type: "error", - }); - }) - .finally(() => { - setSavingCommand(null); + const saveKeybinding = useCallback((input: ServerUpsertKeybindingInput) => { + setSavingCommand(input.command); + const payload: ServerUpsertKeybindingInput = { + command: input.command, + key: input.key.trim(), + ...(input.when?.trim() ? { when: input.when.trim() } : {}), + ...(input.replace ? { replace: input.replace } : {}), + }; + void ensureLocalApi() + .server.upsertKeybinding(payload) + .then(() => { + setIsAddingBinding(false); + }) + .catch((error: unknown) => { + toastManager.add({ + title: "Unable to save keybinding", + description: error instanceof Error ? error.message : "The keybinding was not saved.", + type: "error", + }); + }) + .finally(() => { + setSavingCommand(null); + }); + }, []); + + const removeKeybinding = useCallback((row: KeybindingRow) => { + setSavingCommand(row.command); + void ensureLocalApi() + .server.removeKeybinding(rowKeybindingTarget(row)) + .catch((error: unknown) => { + toastManager.add({ + title: "Unable to remove keybinding", + description: error instanceof Error ? error.message : "The keybinding was not removed.", + type: "error", }); + }) + .finally(() => { + setSavingCommand(null); + }); + }, []); + + const disableKeybinding = useCallback( + (row: KeybindingRow) => { + saveKeybinding({ + command: row.command, + key: row.key, + when: "false", + replace: rowKeybindingTarget(row), + }); }, - [], + [saveKeybinding], ); const resetKeybinding = useCallback( @@ -921,7 +1199,12 @@ export function KeybindingsSettingsPanel() { saveKeybinding({ command: row.command, key: row.defaultKey, - when: row.defaultWhen, + when: row.defaultWhen.trim().length > 0 ? row.defaultWhen : undefined, + replace: { + command: row.command, + key: row.key, + ...(row.when.trim().length > 0 ? { when: row.when } : {}), + }, }); }, [saveKeybinding], @@ -929,7 +1212,8 @@ export function KeybindingsSettingsPanel() { const bindingsCount = ( - {rows.length} {rows.length === 1 ? "binding" : "bindings"} + {rows.length + (isAddingBinding ? 1 : 0)}{" "} + {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"} ); @@ -948,6 +1232,23 @@ export function KeybindingsSettingsPanel() { inputRef={searchInputRef} collapsedAccessory={bindingsCount} /> + + setIsAddingBinding(true)} + aria-label="Add keybinding" + > + + + } + /> + Add keybinding + -
+
Command
Keybinding
When
Status
-
+
+ {isAddingBinding ? ( + setIsAddingBinding(false)} + /> + ) : null} {rows.map((row) => ( ))} - {rows.length === 0 ? ( + {rows.length === 0 && !isAddingBinding ? (
No keybindings match your search.
diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index c9120e53bb..da01bcfef3 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -129,6 +129,10 @@ function createBrowserLocalApi(rpcClient?: WsRpcClient): LocalApi { rpcClient ? rpcClient.server.upsertKeybinding(input) : Promise.reject(unavailableLocalBackendError()), + removeKeybinding: (input) => + rpcClient + ? rpcClient.server.removeKeybinding(input) + : Promise.reject(unavailableLocalBackendError()), getSettings: () => rpcClient ? rpcClient.server.getSettings() : Promise.reject(unavailableLocalBackendError()), updateSettings: (patch) => diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index 8e5819032d..40680fc28e 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -120,6 +120,7 @@ export interface WsRpcClient { input?: RpcInput, ) => ReturnType>; readonly upsertKeybinding: RpcUnaryMethod; + readonly removeKeybinding: RpcUnaryMethod; readonly getSettings: RpcUnaryNoArgMethod; readonly updateSettings: ( patch: ServerSettingsPatch, @@ -236,6 +237,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.serverRefreshProviders](input ?? {})), upsertKeybinding: (input) => transport.request((client) => client[WS_METHODS.serverUpsertKeybinding](input)), + removeKeybinding: (input) => + transport.request((client) => client[WS_METHODS.serverRemoveKeybinding](input)), getSettings: () => transport.request((client) => client[WS_METHODS.serverGetSettings]({})), updateSettings: (patch) => transport.request((client) => client[WS_METHODS.serverUpdateSettings]({ patch })), diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index f480912920..a8ea06910c 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -29,6 +29,7 @@ import type { ProviderInstanceId } from "./providerInstance.ts"; import type { ServerConfig, ServerProviderUpdatedPayload, + ServerRemoveKeybindingResult, ServerUpsertKeybindingResult, } from "./server.ts"; import type { @@ -41,7 +42,7 @@ import type { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; -import type { ServerUpsertKeybindingInput } from "./server.ts"; +import type { ServerRemoveKeybindingInput, ServerUpsertKeybindingInput } from "./server.ts"; import type { ClientOrchestrationCommand, OrchestrationGetFullThreadDiffInput, @@ -297,6 +298,7 @@ export interface LocalApi { readonly instanceId?: ProviderInstanceId; }) => Promise; upsertKeybinding: (input: ServerUpsertKeybindingInput) => Promise; + removeKeybinding: (input: ServerRemoveKeybindingInput) => Promise; getSettings: () => Promise; updateSettings: (patch: ServerSettingsPatch) => Promise; discoverSourceControl: () => Promise; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index d3b85d1cda..01587c2a64 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -2,7 +2,7 @@ import { Schema } from "effect"; import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; -const MAX_KEYBINDING_WHEN_LENGTH = 256; +export const MAX_KEYBINDING_WHEN_LENGTH = 256; export const MAX_WHEN_EXPRESSION_DEPTH = 64; export const MAX_SCRIPT_ID_LENGTH = 24; export const MAX_KEYBINDINGS_COUNT = 256; @@ -76,12 +76,12 @@ export const KeybindingCommand = Schema.Union([ ]); export type KeybindingCommand = typeof KeybindingCommand.Type; -const KeybindingValue = TrimmedString.check( +export const KeybindingValue = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), ); -const KeybindingWhen = TrimmedString.check( +export const KeybindingWhen = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), ); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 4167bd0a76..a40d82b843 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -72,6 +72,8 @@ import { ServerConfigStreamEvent, ServerConfig, ServerLifecycleStreamEvent, + ServerRemoveKeybindingInput, + ServerRemoveKeybindingResult, ServerProviderUpdatedPayload, ServerUpsertKeybindingInput, ServerUpsertKeybindingResult, @@ -130,6 +132,7 @@ export const WS_METHODS = { serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", serverUpsertKeybinding: "server.upsertKeybinding", + serverRemoveKeybinding: "server.removeKeybinding", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -153,6 +156,12 @@ export const WsServerUpsertKeybindingRpc = Rpc.make(WS_METHODS.serverUpsertKeybi error: KeybindingsConfigError, }); +export const WsServerRemoveKeybindingRpc = Rpc.make(WS_METHODS.serverRemoveKeybinding, { + payload: ServerRemoveKeybindingInput, + success: ServerRemoveKeybindingResult, + error: KeybindingsConfigError, +}); + export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { payload: Schema.Struct({}), success: ServerConfig, @@ -416,6 +425,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetConfigRpc, WsServerRefreshProvidersRpc, WsServerUpsertKeybindingRpc, + WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index a3d07ea69c..fffc9a2c8a 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -8,7 +8,12 @@ import { ThreadId, TrimmedNonEmptyString, } from "./baseSchemas.ts"; -import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings.ts"; +import { + KeybindingCommand, + KeybindingValue, + KeybindingWhen, + ResolvedKeybindingsConfig, +} from "./keybindings.ts"; import { EditorId } from "./editor.ts"; import { ModelCapabilities } from "./model.ts"; import { ProviderDriverKind, ProviderInstanceId } from "./providerInstance.ts"; @@ -181,15 +186,32 @@ export const ServerConfig = Schema.Struct({ }); export type ServerConfig = typeof ServerConfig.Type; -export const ServerUpsertKeybindingInput = KeybindingRule; +const ServerUpsertKeybindingReplaceTarget = Schema.Struct({ + key: KeybindingValue, + command: KeybindingCommand, + when: Schema.optional(KeybindingWhen), +}); + +export const ServerUpsertKeybindingInput = Schema.Struct({ + key: KeybindingValue, + command: KeybindingCommand, + when: Schema.optional(KeybindingWhen), + replace: Schema.optional(ServerUpsertKeybindingReplaceTarget), +}); export type ServerUpsertKeybindingInput = typeof ServerUpsertKeybindingInput.Type; +export const ServerRemoveKeybindingInput = ServerUpsertKeybindingReplaceTarget; +export type ServerRemoveKeybindingInput = typeof ServerRemoveKeybindingInput.Type; + export const ServerUpsertKeybindingResult = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, }); export type ServerUpsertKeybindingResult = typeof ServerUpsertKeybindingResult.Type; +export const ServerRemoveKeybindingResult = ServerUpsertKeybindingResult; +export type ServerRemoveKeybindingResult = typeof ServerRemoveKeybindingResult.Type; + export const ServerConfigUpdatedPayload = Schema.Struct({ issues: ServerConfigIssues, providers: ServerProviders, From bb3722910d650165ea5e4d6818a62a4b52bdf2fc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 5 May 2026 16:50:04 -0700 Subject: [PATCH 7/8] Refine keybinding row actions - 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 --- .../settings/KeybindingsSettings.tsx | 185 +++++++----------- 1 file changed, 72 insertions(+), 113 deletions(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index 41a440efee..a7694b3f6f 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1,15 +1,15 @@ import { - BanIcon, ChevronDownIcon, CircleXIcon, + EllipsisIcon, FileJsonIcon, InfoIcon, KeyboardIcon, MinusIcon, PlusIcon, SearchIcon, - Trash2Icon, TriangleAlertIcon, + XIcon, } from "lucide-react"; import { type KeyboardEvent, @@ -38,6 +38,7 @@ import { useServerKeybindings, useServerKeybindingsConfigPath } from "../../rpc/ import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Kbd, KbdGroup } from "../ui/kbd"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { ScrollArea } from "../ui/scroll-area"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; @@ -89,22 +90,6 @@ function KeybindingPill({ value }: { value: string }) { ); } -function StatusBadge({ source }: { source: KeybindingRow["source"] }) { - return ( - - {source} - - ); -} - function ExpandableHeaderSearch({ query, onChange, @@ -772,7 +757,6 @@ function KeybindingTableRow({ onSave, onReset, onRemove, - onDisable, }: { row: KeybindingRow; allRows: ReadonlyArray; @@ -781,7 +765,6 @@ function KeybindingTableRow({ onSave: (input: ServerUpsertKeybindingInput) => void; onReset: (row: KeybindingRow) => void; onRemove: (row: KeybindingRow) => void; - onDisable: (row: KeybindingRow) => void; }) { const [draft, setDraft] = useReducer(keybindingRowDraftReducer, row, createKeybindingRowDraft); const { keyDraft, whenDraft, isRecording, isWhenDraftValid } = draft; @@ -790,7 +773,7 @@ function KeybindingTableRow({ const displayShortcut = formatShortcutLabel(row.binding.shortcut); const canReset = row.source === "Custom" && row.defaultKey !== null; const canRemove = row.source !== "Default"; - const canDisable = row.when !== "false"; + const hasRowActions = canReset || canRemove; const showPill = !isRecording && keyDraft === row.key && row.key.length > 0 && !isDirty; const conflictLabels = keybindingConflictLabels(allRows, { rowId: row.id, @@ -820,7 +803,7 @@ function KeybindingTableRow({ }; return ( -
+
@@ -843,6 +826,7 @@ function KeybindingTableRow({ ) : ( )} + {isDirty ? ( + + ) : null}
-
- {isDirty ? ( - - ) : null} - {canReset ? ( - - ) : ( - - )} - {canDisable ? ( - - + + {hasRowActions ? ( + + onDisable(row)} - > - - + aria-label={`Actions for ${commandLabel(row.command)}`} + /> } - /> - Disable binding - - ) : null} - {canRemove ? ( - - + + + + {canReset ? ( + onReset(row)}> + Reset to default + + ) : null} + {canRemove ? ( + onRemove(row)} > - - - } - /> - Remove binding - + Remove + + ) : null} + + ) : null} {displayShortcut} -
); @@ -1004,7 +968,7 @@ function NewKeybindingTableRow({ }; return ( -
+