authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-06 20:53:52-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-06 20:53:52-03:00
log72d5c32ba67953805feb6fe9ad3b70b632a64c47
tree34068fae2b880f72c991ad7477f4c6cc8279df9f
parent5055de3ae483d5ece45695ad682b9766ff0cf423
signature Signed by PGP key B5690EEEBB952194

feat(web): group keybindings settings by area with a page toolbar (#12822)


3 files changed, 182 insertions(+), 157 deletions(-)

apps/web/src/components/settings/KeybindingsSettings.logic.test.ts+20
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7 buildKeybindingCommandOptions,7 buildKeybindingCommandOptions,
8 buildWhenVariableOptions,8 buildWhenVariableOptions,
9 commandLabel,9 commandLabel,
10 groupKeybindingRows,
10 keybindingConflictLabels,11 keybindingConflictLabels,
11 keybindingFromKeyboardEvent,12 keybindingFromKeyboardEvent,
12 parseWhenExpressionDraft,13 parseWhenExpressionDraft,
...@@ -58,6 +59,25 @@ describe("KeybindingsSettings.logic", () => {...@@ -58,6 +59,25 @@ describe("KeybindingsSettings.logic", () => {
58 );59 );
59 },60 },
60 );61 );
62 it("groups rows by command area in page order and drops empty groups", () => {
63 const groups = groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, ""));
64 expect(groups.map((group) => group.title)).toEqual([
65 "Navigation",
66 "Threads",
67 "Composer",
68 "Terminal",
69 "Preview & diff",
70 "Appearance",
71 "Other",
72 ]);
73 const composer = groups.find((group) => group.id === "composer");
74 expect(composer?.rows.map((row) => row.command)).toEqual(
75 expect.arrayContaining(["composer.host", "modelPicker.toggle"]),
76 );
77 expect(groupKeybindingRows(buildKeybindingRows(DEFAULT_RESOLVED_KEYBINDINGS, "split"))).toEqual(
78 [expect.objectContaining({ id: "terminal" })],
79 );
80 });
61 it("orders Usage bindings and command choices like the page", () => {81 it("orders Usage bindings and command choices like the page", () => {
62 const expected = [82 const expected = [
63 "usage.open",83 "usage.open",
apps/web/src/components/settings/KeybindingsSettings.logic.ts+47
...@@ -201,6 +201,53 @@ export function keybindingConflictLabels(...@@ -201,6 +201,53 @@ export function keybindingConflictLabels(
201 return [...new Set(conflicts)].toSorted();201 return [...new Set(conflicts)].toSorted();
202}202}
203203
204export interface KeybindingGroup {
205 readonly id: string;
206 readonly title: string;
207 readonly rows: ReadonlyArray<KeybindingRow>;
208}
209
210/** Page sections in display order; a command joins the first group listing its prefix. */
211const KEYBINDING_GROUPS = [
212 {
213 id: "navigation",
214 title: "Navigation",
215 prefixes: ["sidebar", "rightPanel", "commandPalette", "filePicker", "projectSearch", "editor"],
216 },
217 { id: "threads", title: "Threads", prefixes: ["thread", "chat", "pullRequest"] },
218 { id: "composer", title: "Composer", prefixes: ["composer", "modelPicker"] },
219 { id: "terminal", title: "Terminal", prefixes: ["terminal"] },
220 { id: "preview", title: "Preview & diff", prefixes: ["preview", "diff"] },
221 { id: "appearance", title: "Appearance", prefixes: ["theme", "appearance", "themeEditor"] },
222 { id: "scripts", title: "Project scripts", prefixes: ["script"] },
223] as const;
224const OTHER_KEYBINDING_GROUP = { id: "other", title: "Other" } as const;
225
226function keybindingGroupFor(command: KeybindingCommand): { id: string; title: string } {
227 const prefix = String(command).split(".")[0] ?? "";
228 return (
229 KEYBINDING_GROUPS.find((group) => (group.prefixes as ReadonlyArray<string>).includes(prefix)) ??
230 OTHER_KEYBINDING_GROUP
231 );
232}
233
234/** Splits sorted rows into the page's sections, dropping sections with no rows. */
235export function groupKeybindingRows(
236 rows: ReadonlyArray<KeybindingRow>,
237): ReadonlyArray<KeybindingGroup> {
238 const rowsByGroup = new Map<string, Array<KeybindingRow>>();
239 for (const row of rows) {
240 const group = keybindingGroupFor(row.command);
241 const bucket = rowsByGroup.get(group.id);
242 if (bucket) bucket.push(row);
243 else rowsByGroup.set(group.id, [row]);
244 }
245 return [...KEYBINDING_GROUPS, OTHER_KEYBINDING_GROUP].flatMap((group) => {
246 const groupRows = rowsByGroup.get(group.id);
247 return groupRows ? [{ id: group.id, title: group.title, rows: groupRows }] : [];
248 });
249}
250
204export function buildKeybindingRows(251export function buildKeybindingRows(
205 keybindings: ResolvedKeybindingsConfig,252 keybindings: ResolvedKeybindingsConfig,
206 query: string,253 query: string,
apps/web/src/components/settings/KeybindingsSettings.tsx+115-157
...@@ -59,13 +59,16 @@ import {...@@ -59,13 +59,16 @@ import {
59 keybindingConflictLabels,59 keybindingConflictLabels,
60 keybindingFromKeyboardEvent,60 keybindingFromKeyboardEvent,
61 parseWhenExpressionDraft,61 parseWhenExpressionDraft,
62 groupKeybindingRows,
62 type KeybindingCommandOption,63 type KeybindingCommandOption,
64 type KeybindingGroup,
63 type KeybindingRow,65 type KeybindingRow,
64 type WhenVariableOption,66 type WhenVariableOption,
65 unknownWhenVariables,67 unknownWhenVariables,
66 whenAstToExpression,68 whenAstToExpression,
67 whenNodeRemoveLabel,69 whenNodeRemoveLabel,
68} from "./KeybindingsSettings.logic";70} from "./KeybindingsSettings.logic";
71import { SettingsGroup } from "./SettingsGroup";
69import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";72import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
70import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";73import { keybindingSearchAnchorId, searchableSetting } from "./settingsSearch";
71import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";74import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
...@@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) {...@@ -104,65 +107,33 @@ function KeybindingPill({ value }: { value: string }) {
104 );107 );
105}108}
106109
107function ExpandableHeaderSearch({110/** Filter box in the page toolbar; Mod+F focuses it from anywhere on the page. */
111function KeybindingsSearchInput({
108 query,112 query,
109 onChange,113 onChange,
110 isOpen,
111 onOpenChange,
112 inputRef,114 inputRef,
113 collapsedAccessory,
114}: {115}: {
115 query: string;116 query: string;
116 onChange: (next: string) => void;117 onChange: (next: string) => void;
117 isOpen: boolean;118 inputRef: RefObject<HTMLInputElement | null>;
118 onOpenChange: (next: boolean) => void;
119 inputRef?: RefObject<HTMLInputElement | null>;
120 collapsedAccessory?: ReactNode;
121}) {119}) {
122 if (!isOpen) {
123 return (
124 <>
125 {collapsedAccessory}
126 <Tooltip>
127 <TooltipTrigger
128 render={
129 <Button
130 type="button"
131 size="icon-xs"
132 variant="ghost-muted"
133 onClick={() => onOpenChange(true)}
134 aria-label="Search keybindings"
135 >
136 <SearchIcon />
137 </Button>
138 }
139 />
140 <TooltipPopup side="top">Search keybindings</TooltipPopup>
141 </Tooltip>
142 </>
143 );
144 }
145
146 return (120 return (
147 <InputGroup className="w-44">121 <InputGroup className="min-w-0 basis-full **:[input]:h-9 sm:basis-0 sm:flex-1 sm:**:[input]:h-8">
148 <InputGroupAddon>122 <InputGroupAddon>
149 <SearchIcon aria-hidden className="size-3" />123 <SearchIcon aria-hidden className="size-3.5" />
150 </InputGroupAddon>124 </InputGroupAddon>
151 <InputGroupInput125 <InputGroupInput
152 ref={inputRef}126 ref={inputRef}
153 autoFocus
154 type="search"127 type="search"
155 value={query}128 value={query}
156 onChange={(event) => onChange(event.currentTarget.value)}129 onChange={(event) => onChange(event.currentTarget.value)}
157 onBlur={() => {
158 if (query.length === 0) onOpenChange(false);
159 }}
160 onKeyDown={(event) => {130 onKeyDown={(event) => {
161 if (event.key === "Escape") {131 // The settings route treats an unhandled Escape as "go back";
162 event.preventDefault();132 // inside the search box it clears, then leaves the field.
163 onChange("");133 if (event.key !== "Escape") return;
164 onOpenChange(false);134 event.preventDefault();
165 }135 if (query.length > 0) onChange("");
136 else event.currentTarget.blur();
166 }}137 }}
167 placeholder="Search keybindings"138 placeholder="Search keybindings"
168 aria-label="Search keybindings"139 aria-label="Search keybindings"
...@@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {...@@ -1232,7 +1203,7 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {
12321203
1233 return (1204 return (
1234 <SettingsRow1205 <SettingsRow
1235 className="rounded-none bg-muted/15"1206 className="bg-muted/15"
1236 title="New keybinding"1207 title="New keybinding"
1237 description={1208 description={
1238 <span className="flex h-6 items-center gap-1.5">1209 <span className="flex h-6 items-center gap-1.5">
...@@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {...@@ -1259,41 +1230,18 @@ function NewKeybindingSettingsRow(props: NewKeybindingProps) {
1259 );1230 );
1260}1231}
12611232
1262interface KeybindingsListProps extends KeybindingRowActions {1233interface KeybindingsGroupsProps extends KeybindingRowActions {
1263 rows: ReadonlyArray<KeybindingRow>;1234 groups: ReadonlyArray<KeybindingGroup>;
1264 commandOptions: ReadonlyArray<KeybindingCommandOption>;1235 anchorIds: ReadonlyMap<string, string>;
1265 savingCommand: KeybindingCommand | null;1236 savingCommand: KeybindingCommand | null;
1266 isAddingBinding: boolean;
1267 onCancelAdd: () => void;
1268}1237}
12691238
1270/** The add-binding row, one settings row per binding, and the empty state. */1239/** One titled section per command area, each holding its binding rows. */
1271function KeybindingsList(props: KeybindingsListProps) {1240function KeybindingsGroups(props: KeybindingsGroupsProps) {
1272 const { rows, commandOptions, savingCommand, isAddingBinding, onCancelAdd, ...rowActions } =1241 const { groups, anchorIds, savingCommand, ...rowActions } = props;
1273 props;1242 return groups.map((group) => (
1274 const newProps: NewKeybindingProps = {1243 <SettingsSection key={group.id} id={`keybindings-${group.id}`} title={group.title}>
1275 commandOptions,1244 {group.rows.map((row) => (
1276 allRows: rows,
1277 variables: rowActions.variables,
1278 isSaving: savingCommand !== null,
1279 onSave: rowActions.onSave,
1280 onCancel: onCancelAdd,
1281 };
1282 // Settings search jumps to a command, so only its first row anchors.
1283 const anchorIds = useMemo(() => {
1284 const ids = new Map<string, string>();
1285 const seen = new Set<KeybindingCommand>();
1286 for (const row of rows) {
1287 if (seen.has(row.command)) continue;
1288 seen.add(row.command);
1289 ids.set(row.id, keybindingSearchAnchorId(row.command));
1290 }
1291 return ids;
1292 }, [rows]);
1293 return (
1294 <div>
1295 {isAddingBinding ? <NewKeybindingSettingsRow {...newProps} /> : null}
1296 {rows.map((row) => (
1297 <KeybindingSettingsRow1245 <KeybindingSettingsRow
1298 key={row.id}1246 key={row.id}
1299 row={row}1247 row={row}
...@@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) {...@@ -1302,25 +1250,29 @@ function KeybindingsList(props: KeybindingsListProps) {
1302 {...rowActions}1250 {...rowActions}
1303 />1251 />
1304 ))}1252 ))}
1305 {rows.length === 0 && !isAddingBinding ? (1253 </SettingsSection>
1306 <div className="px-4 py-12 text-center text-sm text-muted-foreground">1254 ));
1307 No keybindings match your search.
1308 </div>
1309 ) : null}
1310 </div>
1311 );
1312}1255}
13131256
1314/** Shown in the browser build only; the desktop app receives every shortcut. */1257/** Shown in the browser build only; the desktop app receives every shortcut. */
1315function BrowserKeybindingNotice() {1258function BrowserKeybindingNotice() {
1259 // The label carries the whole sentence so assistive tech reads it without
1260 // opening the tooltip.
1261 const message = "The browser may claim some shortcuts first. The desktop app receives them all.";
1316 return (1262 return (
1317 <div className="flex items-center gap-2 px-3 py-2.5 text-xs leading-normal text-muted-foreground sm:px-4">1263 <Tooltip>
1318 <TriangleAlertIcon className="size-3.5 shrink-0 text-warning" aria-hidden />1264 <TooltipTrigger
1319 <span>1265 delay={200}
1320 Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app1266 render={
1321 for better keybinding support.1267 <Button size="icon-micro" variant="ghost-muted" aria-label={message}>
1322 </span>1268 <TriangleAlertIcon className="size-3.5 text-warning" />
1323 </div>1269 </Button>
1270 }
1271 />
1272 <TooltipPopup side="top" className="max-w-72">
1273 {message}
1274 </TooltipPopup>
1275 </Tooltip>
1324 );1276 );
1325}1277}
13261278
...@@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() {...@@ -1347,11 +1299,22 @@ export function KeybindingsSettingsPanel() {
1347 availableEditors,1299 availableEditors,
1348 );1300 );
1349 const [query, setQuery] = useState("");1301 const [query, setQuery] = useState("");
1350 const [isSearchOpen, setIsSearchOpen] = useState(false);
1351 const searchInputRef = useRef<HTMLInputElement>(null);1302 const searchInputRef = useRef<HTMLInputElement>(null);
1352 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);1303 const [savingCommand, setSavingCommand] = useState<KeybindingCommand | null>(null);
1353 const [isAddingBinding, setIsAddingBinding] = useState(false);1304 const [isAddingBinding, setIsAddingBinding] = useState(false);
1354 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);1305 const rows = useMemo(() => buildKeybindingRows(keybindings, query), [keybindings, query]);
1306 const groups = useMemo(() => groupKeybindingRows(rows), [rows]);
1307 // Settings search jumps to a command, so only its first row anchors.
1308 const anchorIds = useMemo(() => {
1309 const ids = new Map<string, string>();
1310 const seen = new Set<KeybindingCommand>();
1311 for (const row of rows) {
1312 if (seen.has(row.command)) continue;
1313 seen.add(row.command);
1314 ids.set(row.id, keybindingSearchAnchorId(row.command));
1315 }
1316 return ids;
1317 }, [rows]);
1355 // The search-target context is provided by this panel's own page container,1318 // The search-target context is provided by this panel's own page container,
1356 // so the jump target is read from the route hash here.1319 // so the jump target is read from the route hash here.
1357 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });1320 const searchTargetId = useLocation({ select: (location) => location.hash.replace(/^#/, "") });
...@@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() {...@@ -1380,11 +1343,8 @@ export function KeybindingsSettingsPanel() {
1380 }1343 }
13811344
1382 event.preventDefault();1345 event.preventDefault();
1383 setIsSearchOpen(true);1346 searchInputRef.current?.focus();
1384 requestAnimationFrame(() => {1347 searchInputRef.current?.select();
1385 searchInputRef.current?.focus();
1386 searchInputRef.current?.select();
1387 });
1388 };1348 };
1389 window.addEventListener("keydown", handleKeyDown);1349 window.addEventListener("keydown", handleKeyDown);
1390 return () => window.removeEventListener("keydown", handleKeyDown);1350 return () => window.removeEventListener("keydown", handleKeyDown);
...@@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() {...@@ -1489,21 +1449,9 @@ export function KeybindingsSettingsPanel() {
14891449
1490 const cancelAdd = useCallback(() => setIsAddingBinding(false), []);1450 const cancelAdd = useCallback(() => setIsAddingBinding(false), []);
14911451
1492 const bindingsCount = (1452 const rowActions: KeybindingRowActions = {
1493 <span className="text-2xs text-muted-foreground">
1494 {rows.length + (isAddingBinding ? 1 : 0)}{" "}
1495 {rows.length + (isAddingBinding ? 1 : 0) === 1 ? "binding" : "bindings"}
1496 </span>
1497 );
1498
1499 const listProps: KeybindingsListProps = {
1500 rows,
1501 allRows: rows,1453 allRows: rows,
1502 commandOptions,
1503 variables: whenVariables,1454 variables: whenVariables,
1504 savingCommand,
1505 isAddingBinding,
1506 onCancelAdd: cancelAdd,
1507 onSave: saveKeybinding,1455 onSave: saveKeybinding,
1508 onReset: resetKeybinding,1456 onReset: resetKeybinding,
1509 onRemove: removeKeybinding,1457 onRemove: removeKeybinding,
...@@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() {...@@ -1513,56 +1461,66 @@ export function KeybindingsSettingsPanel() {
1513 <SettingsPageContainer>1461 <SettingsPageContainer>
1514 <SettingsSection1462 <SettingsSection
1515 {...searchableSetting("keybindings")}1463 {...searchableSetting("keybindings")}
1516 headerAction={1464 headerAction={!isElectron ? <BrowserKeybindingNotice /> : null}
1517 <div className="flex items-center gap-1.5">
1518 <ExpandableHeaderSearch
1519 query={query}
1520 onChange={setQuery}
1521 isOpen={isSearchOpen}
1522 onOpenChange={setIsSearchOpen}
1523 inputRef={searchInputRef}
1524 collapsedAccessory={bindingsCount}
1525 />
1526 <Tooltip>
1527 <TooltipTrigger
1528 render={
1529 <Button
1530 type="button"
1531 size="icon-xs"
1532 variant="ghost-muted"
1533 onClick={() => setIsAddingBinding(true)}
1534 aria-label="Add keybinding"
1535 >
1536 <PlusIcon />
1537 </Button>
1538 }
1539 />
1540 <TooltipPopup side="top">Add keybinding</TooltipPopup>
1541 </Tooltip>
1542 <Tooltip>
1543 <TooltipTrigger
1544 render={
1545 <Button
1546 type="button"
1547 size="icon-xs"
1548 variant="ghost-muted"
1549 disabled={!keybindingsConfigPath}
1550 onClick={openKeybindingsFile}
1551 aria-label="Open keybindings.json"
1552 >
1553 <FileJsonIcon />
1554 </Button>
1555 }
1556 />
1557 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1558 </Tooltip>
1559 </div>
1560 }
1561 >1465 >
1562 {!isElectron ? <BrowserKeybindingNotice /> : null}1466 <div className="flex flex-wrap items-center gap-2 px-3 py-3 sm:px-4">
15631467 <KeybindingsSearchInput query={query} onChange={setQuery} inputRef={searchInputRef} />
1564 <KeybindingsList {...listProps} />1468 <Button
1469 type="button"
1470 variant="outline"
1471 disabled={isAddingBinding}
1472 onClick={() => setIsAddingBinding(true)}
1473 >
1474 <PlusIcon aria-hidden className="size-4" />
1475 Add keybinding
1476 </Button>
1477 <Tooltip>
1478 <TooltipTrigger
1479 render={
1480 <Button
1481 type="button"
1482 variant="outline"
1483 size="icon"
1484 disabled={!keybindingsConfigPath}
1485 onClick={openKeybindingsFile}
1486 aria-label="Open keybindings.json"
1487 >
1488 <FileJsonIcon aria-hidden className="size-4" />
1489 </Button>
1490 }
1491 />
1492 <TooltipPopup side="top">Open keybindings.json</TooltipPopup>
1493 </Tooltip>
1494 </div>
1565 </SettingsSection>1495 </SettingsSection>
1496
1497 {isAddingBinding ? (
1498 <SettingsGroup>
1499 <NewKeybindingSettingsRow
1500 commandOptions={commandOptions}
1501 allRows={rows}
1502 variables={whenVariables}
1503 isSaving={savingCommand !== null}
1504 onSave={saveKeybinding}
1505 onCancel={cancelAdd}
1506 />
1507 </SettingsGroup>
1508 ) : null}
1509
1510 {groups.length > 0 ? (
1511 <KeybindingsGroups
1512 groups={groups}
1513 anchorIds={anchorIds}
1514 savingCommand={savingCommand}
1515 {...rowActions}
1516 />
1517 ) : (
1518 <SettingsGroup>
1519 <div className="px-4 py-12 text-center text-sm text-muted-foreground">
1520 No keybindings match your search.
1521 </div>
1522 </SettingsGroup>
1523 )}
1566 </SettingsPageContainer>1524 </SettingsPageContainer>
1567 );1525 );
1568}1526}