1import * as Schema from "effect/Schema";
2import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts";
3
4const MAX_KEYBINDING_VALUE_LENGTH = 64;
5const MAX_KEYBINDING_WHEN_LENGTH = 256;
6export const MAX_WHEN_EXPRESSION_DEPTH = 64;
7export const MAX_SCRIPT_ID_LENGTH = 24;
8export const MAX_KEYBINDINGS_COUNT = 256;
9
10export const THREAD_JUMP_KEYBINDING_COMMANDS = [
11 "thread.jump.1",
12 "thread.jump.2",
13 "thread.jump.3",
14 "thread.jump.4",
15 "thread.jump.5",
16 "thread.jump.6",
17 "thread.jump.7",
18 "thread.jump.8",
19 "thread.jump.9",
20] as const;
21export type ThreadJumpKeybindingCommand = (typeof THREAD_JUMP_KEYBINDING_COMMANDS)[number];
22
23export const MODEL_PICKER_JUMP_KEYBINDING_COMMANDS = [
24 "modelPicker.jump.1",
25 "modelPicker.jump.2",
26 "modelPicker.jump.3",
27 "modelPicker.jump.4",
28 "modelPicker.jump.5",
29 "modelPicker.jump.6",
30 "modelPicker.jump.7",
31 "modelPicker.jump.8",
32 "modelPicker.jump.9",
33] as const;
34export type ModelPickerJumpKeybindingCommand =
35 (typeof MODEL_PICKER_JUMP_KEYBINDING_COMMANDS)[number];
36
37const THREAD_KEYBINDING_COMMANDS = [
38 "thread.stop",
39 "thread.steerQueuedMessage",
40 "thread.editQueuedMessage",
41 "thread.previous",
42 "thread.next",
43 "thread.copyReference",
44 "thread.settle",
45 "thread.pin",
46 "thread.regenerateTitle",
47 "thread.undo",
48 ...THREAD_JUMP_KEYBINDING_COMMANDS,
49] as const;
50
51const MODEL_PICKER_KEYBINDING_COMMANDS = [
52 "modelPicker.toggle",
53 "modelPicker.previousProvider",
54 "modelPicker.nextProvider",
55 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
56] as const;
57
58/**
59 * The layout keyboard map: cmd makes (split), alt+cmd looks (focus, fill,
60 * widths), ctrl+alt+cmd moves. Same map as niri, with alt+cmd as the
61 * modifier.
62 */
63export const LAYOUT_KEYBINDING_COMMANDS = [
64 "layout.splitRight",
65 "layout.splitDown",
66 "layout.fill",
67 "layout.focusLeft",
68 "layout.focusRight",
69 "layout.focusUp",
70 "layout.focusDown",
71 "layout.moveLeft",
72 "layout.moveRight",
73 "layout.moveUp",
74 "layout.moveDown",
75 "layout.focusFirstColumn",
76 "layout.focusLastColumn",
77 "layout.cycleColumnWidth",
78 "layout.centerColumn",
79 "layout.pullLeft",
80 "layout.pullRight",
81 "layout.shortcutSheet",
82 "layout.focusWorkspaceDown",
83 "layout.focusWorkspaceUp",
84 "layout.moveToWorkspaceDown",
85 "layout.moveToWorkspaceUp",
86 "layout.moveWorkspaceDown",
87 "layout.moveWorkspaceUp",
88 "layout.overview",
89] as const;
90export type LayoutKeybindingCommand = (typeof LAYOUT_KEYBINDING_COMMANDS)[number];
91
92export const STATIC_KEYBINDING_COMMANDS = [
93 "workspace.focusNext",
94 "workspace.focusPrevious",
95 "sidebar.toggle",
96 "navigation.back",
97 "navigation.forward",
98 "terminal.toggle",
99 "terminal.split",
100 "terminal.splitVertical",
101 "terminal.new",
102 "terminal.close",
103 "rightPanel.toggle",
104 "threadPanel.toggle",
105 "rightPanel.toggleMaximized",
106 "rightPanel.close",
107 "view.reopenClosed",
108 "pullRequest.copyNumber",
109 "diff.toggle",
110 "pullRequests.toggle",
111 "preview.toggle",
112 "preview.refresh",
113 "preview.focusUrl",
114 "preview.zoomIn",
115 "preview.zoomOut",
116 "preview.resetZoom",
117 "preview.find",
118 "commandPalette.toggle",
119 "filePicker.toggle",
120 "projectSearch.toggle",
121 "usage.open",
122 "theme.select",
123 "appearance.cycle",
124 "themeEditor.toggle",
125 "composer.stash",
126 "composer.sendAlternate",
127 "composer.sendBackground",
128 "composer.sendAndNewThread",
129 "composer.dictation",
130 "composer.host",
131 "composer.cycleHost",
132 "composer.effort",
133 "composer.increaseEffort",
134 "composer.decreaseEffort",
135 "composer.mode",
136 "composer.workspace",
137 "composer.previousWorktree",
138 "composer.branch",
139 "chat.new",
140 "chat.newLocal",
141 "chat.newWithoutProject",
142 "chat.citeSelection",
143 "tab.new",
144 "tab.next",
145 "tab.previous",
146 "tab.close",
147 ...LAYOUT_KEYBINDING_COMMANDS,
148 "chat.find",
149 "editor.openFavorite",
150 "git.commitPush",
151 "usage.cost",
152 "usage.tokens",
153 "usage.limits",
154 "usage.period.day",
155 "usage.period.week",
156 "usage.period.month",
157 "usage.period.quarter",
158 ...MODEL_PICKER_KEYBINDING_COMMANDS,
159 ...THREAD_KEYBINDING_COMMANDS,
160] as const;
161
162export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([
163 Schema.Literal("script."),
164 Schema.NonEmptyString.check(
165 Schema.isMaxLength(MAX_SCRIPT_ID_LENGTH),
166 Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/),
167 ),
168 Schema.Literal(".run"),
169]);
170
171/**
172 * Commands that no longer do anything. They still decode, so a keybindings.json
173 * that names one stays valid; settings no longer offers them.
174 */
175const RETIRED_KEYBINDING_COMMANDS = ["rightPanel.new", "rightPanel.toggleMaximized"] as const;
176
177export const KeybindingCommand = Schema.Union([
178 Schema.Literals(STATIC_KEYBINDING_COMMANDS),
179 Schema.Literals(RETIRED_KEYBINDING_COMMANDS),
180 SCRIPT_RUN_COMMAND_PATTERN,
181]);
182export type KeybindingCommand = typeof KeybindingCommand.Type;
183
184export const KeybindingValue = TrimmedString.check(
185 Schema.isMinLength(1),
186 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
187);
188
189export const KeybindingWhen = TrimmedString.check(
190 Schema.isMinLength(1),
191 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
192);
193export const KeybindingRule = Schema.Struct({
194 key: KeybindingValue,
195 command: KeybindingCommand,
196 when: Schema.optional(KeybindingWhen),
197});
198export type KeybindingRule = typeof KeybindingRule.Type;
199
200export const KeybindingsConfig = Schema.Array(KeybindingRule).check(
201 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
202);
203export type KeybindingsConfig = typeof KeybindingsConfig.Type;
204
205// Untrimmed: the `space` token resolves to the key " ".
206const KeybindingShortcutKey = Schema.String.check(
207 Schema.isMinLength(1),
208 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
209);
210
211export const KeybindingShortcut = Schema.Struct({
212 key: KeybindingShortcutKey,
213 metaKey: Schema.Boolean,
214 ctrlKey: Schema.Boolean,
215 shiftKey: Schema.Boolean,
216 altKey: Schema.Boolean,
217 modKey: Schema.Boolean,
218});
219export type KeybindingShortcut = typeof KeybindingShortcut.Type;
220
221export const PreviewForwardedShortcut = Schema.Struct({
222 command: KeybindingCommand,
223 shortcut: KeybindingShortcut,
224});
225export type PreviewForwardedShortcut = typeof PreviewForwardedShortcut.Type;
226
227const KeybindingWhenNodeRef = Schema.suspend(
228 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
229);
230export const KeybindingWhenNode = Schema.Union([
231 Schema.Struct({
232 type: Schema.Literal("identifier"),
233 name: Schema.NonEmptyString,
234 }),
235 Schema.Struct({
236 type: Schema.Literal("not"),
237 node: KeybindingWhenNodeRef,
238 }),
239 Schema.Struct({
240 type: Schema.Literal("and"),
241 left: KeybindingWhenNodeRef,
242 right: KeybindingWhenNodeRef,
243 }),
244 Schema.Struct({
245 type: Schema.Literal("or"),
246 left: KeybindingWhenNodeRef,
247 right: KeybindingWhenNodeRef,
248 }),
249]);
250export type KeybindingWhenNode =
251 | { type: "identifier"; name: string }
252 | { type: "not"; node: KeybindingWhenNode }
253 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
254 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
255
256export const ResolvedKeybindingRule = Schema.Struct({
257 command: KeybindingCommand,
258 shortcut: KeybindingShortcut,
259 whenAst: Schema.optional(KeybindingWhenNode),
260}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
261export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
262
263/** Drops undecodable rules without failing the connection. */
264export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check(
265 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
266);
267export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
268
269export class KeybindingsConfigError extends Schema.TaggedError<KeybindingsConfigError>()(
270 "KeybindingsConfigParseError",
271 {
272 configPath: Schema.String,
273 detail: Schema.String,
274 cause: Schema.optional(Schema.Defect()),
275 },
276) {
277 override get message(): string {
278 return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
279 }
280}