1import * as Schema from "effect/Schema";
2import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts";
3
4export const 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.undo",
47 ...THREAD_JUMP_KEYBINDING_COMMANDS,
48] as const;
49export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
50
51const MODEL_PICKER_KEYBINDING_COMMANDS = [
52 "modelPicker.toggle",
53 "modelPicker.previousProvider",
54 "modelPicker.nextProvider",
55 ...MODEL_PICKER_JUMP_KEYBINDING_COMMANDS,
56] as const;
57export type ModelPickerKeybindingCommand = (typeof MODEL_PICKER_KEYBINDING_COMMANDS)[number];
58
59export const STATIC_KEYBINDING_COMMANDS = [
60 "sidebar.toggle",
61 "navigation.back",
62 "navigation.forward",
63 "terminal.toggle",
64 "terminal.split",
65 "terminal.splitVertical",
66 "terminal.new",
67 "terminal.close",
68 "rightPanel.toggle",
69 "rightPanel.new",
70 "threadPanel.toggle",
71 "rightPanel.toggleMaximized",
72 "rightPanel.close",
73 "view.reopenClosed",
74 "pullRequest.copyNumber",
75 "diff.toggle",
76 "preview.toggle",
77 "preview.refresh",
78 "preview.focusUrl",
79 "preview.zoomIn",
80 "preview.zoomOut",
81 "preview.resetZoom",
82 "commandPalette.toggle",
83 "filePicker.toggle",
84 "projectSearch.toggle",
85 "usage.open",
86 "theme.select",
87 "appearance.cycle",
88 "themeEditor.toggle",
89 "composer.stash",
90 "composer.sendAlternate",
91 "composer.sendBackground",
92 "composer.sendAndNewThread",
93 "composer.host",
94 "composer.cycleHost",
95 "composer.effort",
96 "composer.mode",
97 "composer.workspace",
98 "composer.previousWorktree",
99 "composer.branch",
100 "chat.new",
101 "chat.newLocal",
102 "chat.newWithoutProject",
103 "chat.find",
104 "editor.openFavorite",
105 "usage.cost",
106 "usage.tokens",
107 "usage.limits",
108 "usage.period.day",
109 "usage.period.week",
110 "usage.period.month",
111 "usage.period.quarter",
112 ...MODEL_PICKER_KEYBINDING_COMMANDS,
113 ...THREAD_KEYBINDING_COMMANDS,
114] as const;
115
116export const SCRIPT_RUN_COMMAND_PATTERN = Schema.TemplateLiteral([
117 Schema.Literal("script."),
118 Schema.NonEmptyString.check(
119 Schema.isMaxLength(MAX_SCRIPT_ID_LENGTH),
120 Schema.isPattern(/^[a-z0-9][a-z0-9-]*$/),
121 ),
122 Schema.Literal(".run"),
123]);
124
125export const KeybindingCommand = Schema.Union([
126 Schema.Literals(STATIC_KEYBINDING_COMMANDS),
127 SCRIPT_RUN_COMMAND_PATTERN,
128]);
129export type KeybindingCommand = typeof KeybindingCommand.Type;
130
131export const KeybindingValue = TrimmedString.check(
132 Schema.isMinLength(1),
133 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
134);
135
136export const KeybindingWhen = TrimmedString.check(
137 Schema.isMinLength(1),
138 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
139);
140export const KeybindingRule = Schema.Struct({
141 key: KeybindingValue,
142 command: KeybindingCommand,
143 when: Schema.optional(KeybindingWhen),
144});
145export type KeybindingRule = typeof KeybindingRule.Type;
146
147export const KeybindingsConfig = Schema.Array(KeybindingRule).check(
148 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
149);
150export type KeybindingsConfig = typeof KeybindingsConfig.Type;
151
152export const KeybindingShortcut = Schema.Struct({
153 key: KeybindingValue,
154 metaKey: Schema.Boolean,
155 ctrlKey: Schema.Boolean,
156 shiftKey: Schema.Boolean,
157 altKey: Schema.Boolean,
158 modKey: Schema.Boolean,
159});
160export type KeybindingShortcut = typeof KeybindingShortcut.Type;
161
162export const PreviewForwardedShortcut = Schema.Struct({
163 command: KeybindingCommand,
164 shortcut: KeybindingShortcut,
165});
166export type PreviewForwardedShortcut = typeof PreviewForwardedShortcut.Type;
167
168const KeybindingWhenNodeRef = Schema.suspend(
169 (): Schema.Codec<KeybindingWhenNode> => KeybindingWhenNode,
170);
171export const KeybindingWhenNode = Schema.Union([
172 Schema.Struct({
173 type: Schema.Literal("identifier"),
174 name: Schema.NonEmptyString,
175 }),
176 Schema.Struct({
177 type: Schema.Literal("not"),
178 node: KeybindingWhenNodeRef,
179 }),
180 Schema.Struct({
181 type: Schema.Literal("and"),
182 left: KeybindingWhenNodeRef,
183 right: KeybindingWhenNodeRef,
184 }),
185 Schema.Struct({
186 type: Schema.Literal("or"),
187 left: KeybindingWhenNodeRef,
188 right: KeybindingWhenNodeRef,
189 }),
190]);
191export type KeybindingWhenNode =
192 | { type: "identifier"; name: string }
193 | { type: "not"; node: KeybindingWhenNode }
194 | { type: "and"; left: KeybindingWhenNode; right: KeybindingWhenNode }
195 | { type: "or"; left: KeybindingWhenNode; right: KeybindingWhenNode };
196
197export const ResolvedKeybindingRule = Schema.Struct({
198 command: KeybindingCommand,
199 shortcut: KeybindingShortcut,
200 whenAst: Schema.optional(KeybindingWhenNode),
201}).annotate({ parseOptions: { onExcessProperty: "ignore" } });
202export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type;
203
204/**
205 * The command set grows over time, so a client may receive rules it cannot
206 * represent (a command or `when` node added after that client shipped).
207 * Decoding drops those rules instead of failing the whole payload —
208 * rejecting the config would take down the connection over a shortcut the
209 * client couldn't dispatch anyway.
210 */
211export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check(
212 Schema.isMaxLength(MAX_KEYBINDINGS_COUNT),
213);
214export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type;
215
216export class KeybindingsConfigError extends Schema.TaggedError<KeybindingsConfigError>()(
217 "KeybindingsConfigParseError",
218 {
219 configPath: Schema.String,
220 detail: Schema.String,
221 cause: Schema.optional(Schema.Defect()),
222 },
223) {
224 override get message(): string {
225 return `Unable to parse keybindings config at ${this.configPath}: ${this.detail}`;
226 }
227}