@oh-my-pi/pi-coding-agent 13.14.0 → 13.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/package.json +10 -8
  3. package/src/autoresearch/command-initialize.md +34 -0
  4. package/src/autoresearch/command-resume.md +17 -0
  5. package/src/autoresearch/contract.ts +332 -0
  6. package/src/autoresearch/dashboard.ts +447 -0
  7. package/src/autoresearch/git.ts +243 -0
  8. package/src/autoresearch/helpers.ts +458 -0
  9. package/src/autoresearch/index.ts +693 -0
  10. package/src/autoresearch/prompt.md +227 -0
  11. package/src/autoresearch/resume-message.md +16 -0
  12. package/src/autoresearch/state.ts +386 -0
  13. package/src/autoresearch/tools/init-experiment.ts +310 -0
  14. package/src/autoresearch/tools/log-experiment.ts +833 -0
  15. package/src/autoresearch/tools/run-experiment.ts +640 -0
  16. package/src/autoresearch/types.ts +218 -0
  17. package/src/cli/args.ts +8 -2
  18. package/src/cli/initial-message.ts +58 -0
  19. package/src/config/keybindings.ts +417 -212
  20. package/src/config/model-registry.ts +1 -0
  21. package/src/config/model-resolver.ts +57 -9
  22. package/src/config/settings-schema.ts +38 -10
  23. package/src/config/settings.ts +1 -4
  24. package/src/exec/bash-executor.ts +7 -5
  25. package/src/export/html/template.css +43 -13
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.html +1 -0
  28. package/src/export/html/template.js +107 -0
  29. package/src/extensibility/extensions/types.ts +31 -8
  30. package/src/internal-urls/docs-index.generated.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/main.ts +44 -44
  33. package/src/mcp/oauth-discovery.ts +1 -1
  34. package/src/modes/acp/acp-agent.ts +957 -0
  35. package/src/modes/acp/acp-event-mapper.ts +531 -0
  36. package/src/modes/acp/acp-mode.ts +13 -0
  37. package/src/modes/acp/index.ts +2 -0
  38. package/src/modes/components/agent-dashboard.ts +5 -4
  39. package/src/modes/components/bash-execution.ts +40 -11
  40. package/src/modes/components/custom-editor.ts +47 -47
  41. package/src/modes/components/extensions/extension-dashboard.ts +2 -1
  42. package/src/modes/components/history-search.ts +2 -1
  43. package/src/modes/components/hook-editor.ts +2 -1
  44. package/src/modes/components/hook-input.ts +8 -7
  45. package/src/modes/components/hook-selector.ts +15 -10
  46. package/src/modes/components/keybinding-hints.ts +9 -9
  47. package/src/modes/components/login-dialog.ts +3 -3
  48. package/src/modes/components/mcp-add-wizard.ts +2 -1
  49. package/src/modes/components/model-selector.ts +14 -3
  50. package/src/modes/components/oauth-selector.ts +2 -1
  51. package/src/modes/components/python-execution.ts +2 -3
  52. package/src/modes/components/session-selector.ts +2 -1
  53. package/src/modes/components/settings-selector.ts +2 -1
  54. package/src/modes/components/status-line-segment-editor.ts +2 -1
  55. package/src/modes/components/tool-execution.ts +4 -5
  56. package/src/modes/components/tree-selector.ts +3 -2
  57. package/src/modes/components/user-message-selector.ts +3 -8
  58. package/src/modes/components/user-message.ts +16 -0
  59. package/src/modes/controllers/command-controller.ts +0 -2
  60. package/src/modes/controllers/extension-ui-controller.ts +89 -4
  61. package/src/modes/controllers/input-controller.ts +29 -23
  62. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  63. package/src/modes/index.ts +1 -0
  64. package/src/modes/interactive-mode.ts +17 -5
  65. package/src/modes/print-mode.ts +1 -1
  66. package/src/modes/prompt-action-autocomplete.ts +7 -7
  67. package/src/modes/rpc/rpc-mode.ts +7 -2
  68. package/src/modes/rpc/rpc-types.ts +1 -0
  69. package/src/modes/theme/theme.ts +53 -44
  70. package/src/modes/types.ts +9 -2
  71. package/src/modes/utils/hotkeys-markdown.ts +19 -19
  72. package/src/modes/utils/keybinding-matchers.ts +21 -0
  73. package/src/modes/utils/ui-helpers.ts +1 -1
  74. package/src/patch/hashline.ts +139 -127
  75. package/src/patch/index.ts +77 -59
  76. package/src/patch/shared.ts +19 -11
  77. package/src/prompts/tools/hashline.md +43 -116
  78. package/src/sdk.ts +34 -17
  79. package/src/session/agent-session.ts +123 -30
  80. package/src/session/session-manager.ts +32 -31
  81. package/src/session/streaming-output.ts +87 -37
  82. package/src/tools/ask.ts +56 -30
  83. package/src/tools/bash-interactive.ts +2 -6
  84. package/src/tools/bash-interceptor.ts +1 -39
  85. package/src/tools/bash-skill-urls.ts +1 -1
  86. package/src/tools/browser.ts +1 -1
  87. package/src/tools/gemini-image.ts +1 -1
  88. package/src/tools/python.ts +2 -2
  89. package/src/tools/resolve.ts +1 -1
  90. package/src/utils/child-process.ts +88 -0
@@ -1,120 +1,438 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
1
2
  import * as path from "node:path";
2
3
  import {
3
- DEFAULT_EDITOR_KEYBINDINGS,
4
- type EditorAction,
5
- type EditorKeybindingsConfig,
6
- EditorKeybindingsManager,
4
+ type Keybinding,
5
+ type KeybindingDefinitions,
6
+ type KeybindingsConfig,
7
7
  type KeyId,
8
- matchesKey,
9
- setEditorKeybindings,
8
+ setKeybindings,
9
+ TUI_KEYBINDINGS,
10
+ KeybindingsManager as TuiKeybindingsManager,
10
11
  } from "@oh-my-pi/pi-tui";
11
12
  import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
12
13
 
13
14
  /**
14
- * Application-level actions (coding agent specific).
15
+ * Application-level keybindings (coding agent specific).
16
+ * Values are always `true` — used for declaration merging.
15
17
  */
16
- export type AppAction =
17
- | "interrupt"
18
- | "clear"
19
- | "exit"
20
- | "suspend"
21
- | "cycleThinkingLevel"
22
- | "cycleModelForward"
23
- | "cycleModelBackward"
24
- | "selectModel"
25
- | "togglePlanMode"
26
- | "expandTools"
27
- | "toggleThinking"
28
- | "externalEditor"
29
- | "historySearch"
30
- | "followUp"
31
- | "dequeue"
32
- | "pasteImage"
33
- | "copyLine"
34
- | "copyPrompt"
35
- | "newSession"
36
- | "tree"
37
- | "fork"
38
- | "resume"
39
- | "toggleSTT";
18
+ interface AppKeybindings {
19
+ "app.interrupt": true;
20
+ "app.clear": true;
21
+ "app.exit": true;
22
+ "app.suspend": true;
23
+ "app.thinking.cycle": true;
24
+ "app.thinking.toggle": true;
25
+ "app.model.cycleForward": true;
26
+ "app.model.cycleBackward": true;
27
+ "app.model.select": true;
28
+ "app.tools.expand": true;
29
+ "app.editor.external": true;
30
+ "app.message.followUp": true;
31
+ "app.message.dequeue": true;
32
+ "app.clipboard.pasteImage": true;
33
+ "app.clipboard.copyLine": true;
34
+ "app.clipboard.copyPrompt": true;
35
+ "app.session.new": true;
36
+ "app.session.tree": true;
37
+ "app.session.fork": true;
38
+ "app.session.resume": true;
39
+ "app.session.togglePath": true;
40
+ "app.session.toggleSort": true;
41
+ "app.session.rename": true;
42
+ "app.session.delete": true;
43
+ "app.session.deleteNoninvasive": true;
44
+ "app.tree.foldOrUp": true;
45
+ "app.tree.unfoldOrDown": true;
46
+ "app.plan.toggle": true;
47
+ "app.history.search": true;
48
+ "app.stt.toggle": true;
49
+ }
50
+
51
+ export type AppKeybinding = keyof AppKeybindings;
52
+
53
+ declare module "@oh-my-pi/pi-tui" {
54
+ interface Keybindings extends AppKeybindings {}
55
+ }
40
56
 
41
57
  /**
42
- * All configurable actions.
58
+ * All keybindings definitions: TUI + app-specific.
43
59
  */
44
- export type KeyAction = AppAction | EditorAction;
60
+ export const KEYBINDINGS = {
61
+ ...TUI_KEYBINDINGS,
62
+ "app.interrupt": {
63
+ defaultKeys: "escape",
64
+ description: "Interrupt current operation",
65
+ },
66
+ "app.clear": {
67
+ defaultKeys: "ctrl+c",
68
+ description: "Clear screen or cancel",
69
+ },
70
+ "app.exit": {
71
+ defaultKeys: "ctrl+d",
72
+ description: "Exit application",
73
+ },
74
+ "app.suspend": {
75
+ defaultKeys: "ctrl+z",
76
+ description: "Suspend application",
77
+ },
78
+ "app.thinking.cycle": {
79
+ defaultKeys: "shift+tab",
80
+ description: "Cycle thinking level",
81
+ },
82
+ "app.thinking.toggle": {
83
+ defaultKeys: "ctrl+t",
84
+ description: "Toggle thinking mode",
85
+ },
86
+ "app.model.cycleForward": {
87
+ defaultKeys: "ctrl+p",
88
+ description: "Cycle to next model",
89
+ },
90
+ "app.model.cycleBackward": {
91
+ defaultKeys: "shift+ctrl+p",
92
+ description: "Cycle to previous model",
93
+ },
94
+ "app.model.select": {
95
+ defaultKeys: "ctrl+l",
96
+ description: "Select model",
97
+ },
98
+ "app.tools.expand": {
99
+ defaultKeys: "ctrl+o",
100
+ description: "Expand tools",
101
+ },
102
+ "app.editor.external": {
103
+ defaultKeys: "ctrl+g",
104
+ description: "Open external editor",
105
+ },
106
+ "app.message.followUp": {
107
+ defaultKeys: "ctrl+enter",
108
+ description: "Send follow-up message",
109
+ },
110
+ "app.message.dequeue": {
111
+ defaultKeys: "alt+up",
112
+ description: "Dequeue message",
113
+ },
114
+ "app.clipboard.pasteImage": {
115
+ defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
116
+ description: "Paste image from clipboard",
117
+ },
118
+ "app.clipboard.copyLine": {
119
+ defaultKeys: "alt+shift+l",
120
+ description: "Copy current line",
121
+ },
122
+ "app.clipboard.copyPrompt": {
123
+ defaultKeys: "alt+shift+c",
124
+ description: "Copy prompt",
125
+ },
126
+ "app.session.new": {
127
+ defaultKeys: [],
128
+ description: "Create new session",
129
+ },
130
+ "app.session.tree": {
131
+ defaultKeys: [],
132
+ description: "Show session tree",
133
+ },
134
+ "app.session.fork": {
135
+ defaultKeys: [],
136
+ description: "Fork session",
137
+ },
138
+ "app.session.resume": {
139
+ defaultKeys: [],
140
+ description: "Resume session",
141
+ },
142
+ "app.session.togglePath": {
143
+ defaultKeys: "ctrl+p",
144
+ description: "Toggle session path display",
145
+ },
146
+ "app.session.toggleSort": {
147
+ defaultKeys: "ctrl+s",
148
+ description: "Toggle session sort order",
149
+ },
150
+ "app.session.rename": {
151
+ defaultKeys: "ctrl+r",
152
+ description: "Rename session",
153
+ },
154
+ "app.session.delete": {
155
+ defaultKeys: "ctrl+d",
156
+ description: "Delete session",
157
+ },
158
+ "app.session.deleteNoninvasive": {
159
+ defaultKeys: "ctrl+backspace",
160
+ description: "Delete session (non-invasive)",
161
+ },
162
+ "app.tree.foldOrUp": {
163
+ defaultKeys: ["ctrl+left", "alt+left"],
164
+ description: "Fold or move up",
165
+ },
166
+ "app.tree.unfoldOrDown": {
167
+ defaultKeys: ["ctrl+right", "alt+right"],
168
+ description: "Unfold or move down",
169
+ },
170
+ "app.plan.toggle": {
171
+ defaultKeys: "alt+shift+p",
172
+ description: "Toggle plan mode",
173
+ },
174
+ "app.history.search": {
175
+ defaultKeys: "ctrl+r",
176
+ description: "Search history",
177
+ },
178
+ "app.stt.toggle": {
179
+ defaultKeys: "alt+h",
180
+ description: "Toggle speech-to-text",
181
+ },
182
+ } as const satisfies KeybindingDefinitions;
45
183
 
46
184
  /**
47
- * Full keybindings configuration (app + editor actions).
185
+ * Migration map from old keybinding names to new namespaced IDs.
48
186
  */
49
- export type KeybindingsConfig = {
50
- [K in KeyAction]?: KeyId | KeyId[];
51
- };
187
+ const KEYBINDING_NAME_MIGRATIONS = {
188
+ // App-specific (old names)
189
+ interrupt: "app.interrupt",
190
+ clear: "app.clear",
191
+ exit: "app.exit",
192
+ suspend: "app.suspend",
193
+ cycleThinkingLevel: "app.thinking.cycle",
194
+ cycleModelForward: "app.model.cycleForward",
195
+ cycleModelBackward: "app.model.cycleBackward",
196
+ selectModel: "app.model.select",
197
+ togglePlanMode: "app.plan.toggle",
198
+ historySearch: "app.history.search",
199
+ expandTools: "app.tools.expand",
200
+ toggleThinking: "app.thinking.toggle",
201
+ externalEditor: "app.editor.external",
202
+ followUp: "app.message.followUp",
203
+ dequeue: "app.message.dequeue",
204
+ pasteImage: "app.clipboard.pasteImage",
205
+ copyLine: "app.clipboard.copyLine",
206
+ copyPrompt: "app.clipboard.copyPrompt",
207
+ newSession: "app.session.new",
208
+ tree: "app.session.tree",
209
+ fork: "app.session.fork",
210
+ resume: "app.session.resume",
211
+ toggleSTT: "app.stt.toggle",
212
+ // TUI editor (old names for backward compatibility)
213
+ cursorUp: "tui.editor.cursorUp",
214
+ cursorDown: "tui.editor.cursorDown",
215
+ cursorLeft: "tui.editor.cursorLeft",
216
+ cursorRight: "tui.editor.cursorRight",
217
+ cursorWordLeft: "tui.editor.cursorWordLeft",
218
+ cursorWordRight: "tui.editor.cursorWordRight",
219
+ cursorLineStart: "tui.editor.cursorLineStart",
220
+ cursorLineEnd: "tui.editor.cursorLineEnd",
221
+ jumpForward: "tui.editor.jumpForward",
222
+ jumpBackward: "tui.editor.jumpBackward",
223
+ pageUp: "tui.editor.pageUp",
224
+ pageDown: "tui.editor.pageDown",
225
+ deleteCharBackward: "tui.editor.deleteCharBackward",
226
+ deleteCharForward: "tui.editor.deleteCharForward",
227
+ deleteWordBackward: "tui.editor.deleteWordBackward",
228
+ deleteWordForward: "tui.editor.deleteWordForward",
229
+ deleteToLineStart: "tui.editor.deleteToLineStart",
230
+ deleteToLineEnd: "tui.editor.deleteToLineEnd",
231
+ yank: "tui.editor.yank",
232
+ yankPop: "tui.editor.yankPop",
233
+ undo: "tui.editor.undo",
234
+ // TUI input (old names for backward compatibility)
235
+ newLine: "tui.input.newLine",
236
+ submit: "tui.input.submit",
237
+ tab: "tui.input.tab",
238
+ copy: "tui.input.copy",
239
+ // TUI select (old names for backward compatibility)
240
+ selectUp: "tui.select.up",
241
+ selectDown: "tui.select.down",
242
+ selectPageUp: "tui.select.pageUp",
243
+ selectPageDown: "tui.select.pageDown",
244
+ selectConfirm: "tui.select.confirm",
245
+ selectCancel: "tui.select.cancel",
246
+ // Upstream additional migrations
247
+ toggleSessionNamedFilter: "app.session.togglePath",
248
+ } as const satisfies Record<string, Keybinding>;
52
249
 
53
250
  /**
54
- * Default application keybindings.
251
+ * Check if a key is a legacy keybinding name.
55
252
  */
56
- export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
57
- interrupt: "escape",
58
- clear: "ctrl+c",
59
- exit: "ctrl+d",
60
- suspend: "ctrl+z",
61
- cycleThinkingLevel: "shift+tab",
62
- cycleModelForward: "ctrl+p",
63
- cycleModelBackward: "shift+ctrl+p",
64
- selectModel: "ctrl+l",
65
- togglePlanMode: "alt+shift+p",
66
- historySearch: "ctrl+r",
67
- expandTools: "ctrl+o",
68
- toggleThinking: "ctrl+t",
69
- externalEditor: "ctrl+g",
70
- followUp: "ctrl+enter",
71
- dequeue: "alt+up",
72
- pasteImage: "ctrl+v",
73
- copyLine: "alt+shift+l",
74
- copyPrompt: "alt+shift+c",
75
- newSession: [],
76
- tree: [],
77
- fork: [],
78
- resume: [],
79
- toggleSTT: "alt+h",
80
- };
253
+ function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
254
+ return key in KEYBINDING_NAME_MIGRATIONS;
255
+ }
256
+
81
257
  /**
82
- * All default keybindings (app + editor).
258
+ * Normalize input to KeybindingsConfig, validating types.
83
259
  */
84
- export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
85
- ...DEFAULT_EDITOR_KEYBINDINGS,
86
- ...DEFAULT_APP_KEYBINDINGS,
87
- };
260
+ function toKeybindingsConfig(value: unknown): KeybindingsConfig {
261
+ if (typeof value !== "object" || value === null) {
262
+ return {};
263
+ }
88
264
 
89
- // App actions list for type checking
90
- const APP_ACTIONS: AppAction[] = [
91
- "interrupt",
92
- "clear",
93
- "exit",
94
- "suspend",
95
- "cycleThinkingLevel",
96
- "cycleModelForward",
97
- "cycleModelBackward",
98
- "selectModel",
99
- "togglePlanMode",
100
- "historySearch",
101
- "expandTools",
102
- "toggleThinking",
103
- "externalEditor",
104
- "followUp",
105
- "dequeue",
106
- "pasteImage",
107
- "copyLine",
108
- "copyPrompt",
109
- "newSession",
110
- "tree",
111
- "fork",
112
- "resume",
113
- "toggleSTT",
114
- ];
115
-
116
- function isAppAction(action: string): action is AppAction {
117
- return APP_ACTIONS.includes(action as AppAction);
265
+ const config: KeybindingsConfig = {};
266
+ for (const [key, val] of Object.entries(value)) {
267
+ // Allow undefined, string (KeyId), or array of strings
268
+ if (val === undefined) {
269
+ config[key] = undefined;
270
+ } else if (typeof val === "string") {
271
+ config[key] = val as KeyId;
272
+ } else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
273
+ config[key] = val as string[] as KeyId[];
274
+ }
275
+ // Silently skip invalid entries
276
+ }
277
+ return config;
278
+ }
279
+
280
+ /**
281
+ * Migrate old keybinding names to new namespaced IDs.
282
+ * Returns both the migrated config and a flag indicating if migration occurred.
283
+ */
284
+ function migrateKeybindingNames(rawConfig: unknown): {
285
+ config: KeybindingsConfig;
286
+ migrated: boolean;
287
+ } {
288
+ const config = toKeybindingsConfig(rawConfig);
289
+ const migrated: KeybindingsConfig = {};
290
+ let didMigrate = false;
291
+
292
+ for (const [key, value] of Object.entries(config)) {
293
+ if (isLegacyKeybindingName(key)) {
294
+ const newKey = KEYBINDING_NAME_MIGRATIONS[key];
295
+ migrated[newKey] = value;
296
+ didMigrate = true;
297
+ } else {
298
+ // Already a new-style key
299
+ migrated[key] = value;
300
+ }
301
+ }
302
+
303
+ return { config: migrated, migrated: didMigrate };
304
+ }
305
+
306
+ /**
307
+ * Order keybindings config to match KEYBINDINGS key order.
308
+ */
309
+ function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
310
+ const ordered: KeybindingsConfig = {};
311
+ for (const key of Object.keys(KEYBINDINGS)) {
312
+ const value = config[key];
313
+ if (value !== undefined) {
314
+ ordered[key] = value;
315
+ }
316
+ }
317
+ // Add any remaining keys that aren't in KEYBINDINGS
318
+ for (const key of Object.keys(config)) {
319
+ if (!(key in ordered)) {
320
+ ordered[key] = config[key];
321
+ }
322
+ }
323
+ return ordered;
324
+ }
325
+
326
+ /**
327
+ * Load raw config from a file synchronously.
328
+ * Returns parsed JSON or null if file doesn't exist or is invalid.
329
+ */
330
+ function loadRawConfig(filePath: string): unknown {
331
+ try {
332
+ if (!existsSync(filePath)) {
333
+ return null;
334
+ }
335
+ const content = readFileSync(filePath, "utf-8");
336
+ return JSON.parse(content);
337
+ } catch (error) {
338
+ if (isEnoent(error)) {
339
+ return null;
340
+ }
341
+ logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) });
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Migrate keybindings config file from old format to new.
348
+ * Reads from agentDir/keybindings.json, migrates old names, and writes back.
349
+ */
350
+ function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig {
351
+ const rawConfig = loadRawConfig(filePath);
352
+
353
+ if (rawConfig === null) {
354
+ return {};
355
+ }
356
+
357
+ const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
358
+ if (writeBack && migrated) {
359
+ const ordered = orderKeybindingsConfig(migratedConfig);
360
+ try {
361
+ writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
362
+ logger.debug("Migrated keybindings config", { path: filePath });
363
+ } catch (error) {
364
+ logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
365
+ }
366
+ }
367
+
368
+ return migratedConfig;
369
+ }
370
+
371
+ function migrateKeybindingsConfigFile(agentDir: string): void {
372
+ const configPath = path.join(agentDir, "keybindings.json");
373
+ loadKeybindingsConfig(configPath, true);
374
+ }
375
+
376
+ /**
377
+ * Manages all keybindings (app + TUI).
378
+ * Extends the TUI KeybindingsManager with app-specific functionality.
379
+ */
380
+ export class KeybindingsManager extends TuiKeybindingsManager {
381
+ #configPath: string | undefined;
382
+
383
+ constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
384
+ super(KEYBINDINGS, userBindings);
385
+ this.#configPath = configPath;
386
+ }
387
+
388
+ /**
389
+ * Create from config file at agentDir/keybindings.json.
390
+ */
391
+ static create(agentDir: string = getAgentDir()): KeybindingsManager {
392
+ const configPath = path.join(agentDir, "keybindings.json");
393
+ const userBindings = KeybindingsManager.#loadFromFile(configPath);
394
+ const manager = new KeybindingsManager(userBindings, configPath);
395
+ // Set globally so getKeybindings() returns this manager
396
+ setKeybindings(manager);
397
+ return manager;
398
+ }
399
+
400
+ /**
401
+ * Create an in-memory keybindings manager without file persistence.
402
+ */
403
+ static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager {
404
+ return new KeybindingsManager(userBindings);
405
+ }
406
+
407
+ /**
408
+ * Reload keybindings from the config file.
409
+ */
410
+ reload(): void {
411
+ if (!this.#configPath) return;
412
+ this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath));
413
+ }
414
+
415
+ /**
416
+ * Get the effective resolved bindings (defaults + user overrides).
417
+ */
418
+ getEffectiveConfig(): KeybindingsConfig {
419
+ return this.getResolvedBindings();
420
+ }
421
+
422
+ /**
423
+ * Get display string for a keybinding (e.g., "ctrl+c/escape").
424
+ */
425
+ getDisplayString(keybinding: Keybinding): string {
426
+ const keys = this.getKeys(keybinding);
427
+ return formatKeyHints(keys.length === 0 ? [] : keys);
428
+ }
429
+
430
+ /**
431
+ * Load user bindings from a file, migrating old names if needed.
432
+ */
433
+ static #loadFromFile(filePath: string): KeybindingsConfig {
434
+ return loadKeybindingsConfig(filePath, true);
435
+ }
118
436
  }
119
437
 
120
438
  /**
@@ -145,8 +463,6 @@ const KEY_LABELS: Record<string, string> = {
145
463
  right: "Right",
146
464
  };
147
465
 
148
- const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
149
-
150
466
  function formatKeyPart(part: string): string {
151
467
  const lower = part.toLowerCase();
152
468
  const modifier = MODIFIER_LABELS[lower];
@@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string {
166
482
  return list.map(formatKeyHint).join("/");
167
483
  }
168
484
 
169
- /**
170
- * Manages all keybindings (app + editor).
171
- */
172
- export class KeybindingsManager {
173
- #appActionToKeys: Map<AppAction, KeyId[]>;
174
-
175
- private constructor(private readonly config: KeybindingsConfig) {
176
- this.#appActionToKeys = new Map();
177
- this.#buildMaps();
178
- }
179
-
180
- /**
181
- * Create from config file and set up editor keybindings.
182
- */
183
- static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
184
- const configPath = path.join(agentDir, "keybindings.json");
185
- const config = await KeybindingsManager.#loadFromFile(configPath);
186
- const manager = new KeybindingsManager(config);
187
-
188
- // Set up editor keybindings globally
189
- const editorConfig: EditorKeybindingsConfig = {};
190
- for (const [action, keys] of Object.entries(config)) {
191
- if (!isAppAction(action)) {
192
- editorConfig[action as EditorAction] = keys;
193
- }
194
- }
195
- setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
196
-
197
- return manager;
198
- }
199
-
200
- /**
201
- * Create in-memory.
202
- */
203
- static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
204
- return new KeybindingsManager(config);
205
- }
206
-
207
- static async #loadFromFile(path: string): Promise<KeybindingsConfig> {
208
- try {
209
- return await Bun.file(path).json();
210
- } catch (error) {
211
- if (isEnoent(error)) return {};
212
- logger.warn("Failed to parse keybindings config", { path, error: String(error) });
213
- return {};
214
- }
215
- }
216
-
217
- #buildMaps(): void {
218
- this.#appActionToKeys.clear();
219
-
220
- // Set defaults for app actions
221
- for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
222
- const keyArray = Array.isArray(keys) ? keys : [keys];
223
- this.#appActionToKeys.set(
224
- action as AppAction,
225
- keyArray.map(key => normalizeKeyId(key as KeyId)),
226
- );
227
- }
228
-
229
- // Override with user config (app actions only)
230
- for (const [action, keys] of Object.entries(this.config)) {
231
- if (keys === undefined || !isAppAction(action)) continue;
232
- const keyArray = Array.isArray(keys) ? keys : [keys];
233
- this.#appActionToKeys.set(
234
- action,
235
- keyArray.map(key => normalizeKeyId(key as KeyId)),
236
- );
237
- }
238
- }
239
-
240
- /**
241
- * Check if input matches an app action.
242
- */
243
- matches(data: string, action: AppAction): boolean {
244
- const keys = this.#appActionToKeys.get(action);
245
- if (!keys) return false;
246
- for (const key of keys) {
247
- if (matchesKey(data, key)) return true;
248
- }
249
- return false;
250
- }
251
-
252
- /**
253
- * Get keys bound to an app action.
254
- */
255
- getKeys(action: AppAction): KeyId[] {
256
- return this.#appActionToKeys.get(action) ?? [];
257
- }
258
-
259
- /**
260
- * Get display string for an action.
261
- */
262
- getDisplayString(action: AppAction): string {
263
- return formatKeyHints(this.getKeys(action));
264
- }
265
-
266
- /**
267
- * Get the full effective config.
268
- */
269
- getEffectiveConfig(): Required<KeybindingsConfig> {
270
- const result = { ...DEFAULT_KEYBINDINGS };
271
- for (const [action, keys] of Object.entries(this.config)) {
272
- if (keys !== undefined) {
273
- (result as KeybindingsConfig)[action as KeyAction] = keys;
274
- }
275
- }
276
- return result;
277
- }
278
- }
279
-
280
- // Re-export for convenience
281
- export type { EditorAction, KeyId };
485
+ export type { Keybinding, KeybindingsConfig, KeyId };
486
+ export { migrateKeybindingsConfigFile };
@@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({
90
90
  thinkingFormat: Type.Optional(
91
91
  Type.Union([
92
92
  Type.Literal("openai"),
93
+ Type.Literal("openrouter"),
93
94
  Type.Literal("zai"),
94
95
  Type.Literal("qwen"),
95
96
  Type.Literal("qwen-chat-template"),