1use std::path::{Path, PathBuf};
5
6use indexmap::IndexMap;
7use oino_settings::JsonObject;
8use oino_settings::paths::KEYBINDINGS_FILE;
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::diagnostics::ResourceDiagnostic;
13
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum KeyBinding {
18 One(String),
20 Many(Vec<String>),
22}
23
24impl KeyBinding {
25 #[must_use]
27 pub fn keys(&self) -> Vec<&str> {
28 match self {
29 Self::One(key) => vec![key.as_str()],
30 Self::Many(keys) => keys.iter().map(String::as_str).collect(),
31 }
32 }
33
34 #[must_use]
36 pub fn from_value(value: &Value) -> Option<Self> {
37 match value {
38 Value::String(key) if is_valid_key_id(key) => Some(Self::One(key.clone())),
39 Value::Array(items) => items
40 .iter()
41 .map(|item| {
42 item.as_str()
43 .filter(|key| is_valid_key_id(key))
44 .map(str::to_owned)
45 })
46 .collect::<Option<Vec<_>>>()
47 .map(Self::Many),
48 _ => None,
49 }
50 }
51}
52
53#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct KeybindingsFile {
56 pub path: PathBuf,
58 pub bindings: IndexMap<String, KeyBinding>,
60 pub migrated: bool,
62 pub diagnostics: Vec<ResourceDiagnostic>,
64}
65
66impl KeybindingsFile {
67 #[must_use]
70 pub fn load(agent_dir: &Path) -> Self {
71 let path = agent_dir.join(KEYBINDINGS_FILE);
72 let mut file = Self {
73 path: path.clone(),
74 ..Self::default()
75 };
76 let content = match std::fs::read_to_string(&path) {
77 Ok(content) => content,
78 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return file,
79 Err(error) => {
80 file.diagnostics
81 .push(ResourceDiagnostic::warning(error.to_string(), &path));
82 return file;
83 }
84 };
85 let raw = match serde_json::from_str(oino_settings::json::strip_bom(&content)) {
86 Ok(Value::Object(raw)) => raw,
87 Ok(_) => {
88 file.diagnostics.push(ResourceDiagnostic::warning(
89 "keybindings must be a JSON object",
90 &path,
91 ));
92 return file;
93 }
94 Err(error) => {
95 file.diagnostics
96 .push(ResourceDiagnostic::warning(error.to_string(), &path));
97 return file;
98 }
99 };
100 let (config, migrated) = migrate_keybindings_config(&raw);
101 file.migrated = migrated;
102 for (id, value) in config {
103 if id == "$schema" {
104 continue;
105 }
106 match KeyBinding::from_value(&value) {
107 Some(binding) => {
108 file.bindings.insert(id, binding);
109 }
110 None => file.diagnostics.push(ResourceDiagnostic::warning(
111 format!("invalid key binding for \"{id}\""),
112 &path,
113 )),
114 }
115 }
116 file
117 }
118}
119
120#[must_use]
123pub fn migrate_keybindings_config(raw: &JsonObject) -> (JsonObject, bool) {
124 let mut config = JsonObject::new();
125 let mut migrated = false;
126 for (key, value) in raw {
127 let next = LEGACY_KEYBINDING_NAMES
128 .iter()
129 .find(|(legacy, _)| legacy == key)
130 .map_or(key.as_str(), |(_, id)| id);
131 if next != key {
132 migrated = true;
133 if raw.contains_key(next) {
134 continue;
135 }
136 }
137 config.insert(next.to_owned(), value.clone());
138 }
139 (order_keybindings_config(config), migrated)
140}
141
142fn order_keybindings_config(mut config: JsonObject) -> JsonObject {
143 let mut ordered = JsonObject::new();
144 for id in KEYBINDING_IDS {
145 if let Some(value) = config.shift_remove(id) {
146 ordered.insert(id.to_owned(), value);
147 }
148 }
149 let mut extras: Vec<(String, Value)> = config.into_iter().collect();
150 extras.sort_by(|a, b| a.0.encode_utf16().cmp(b.0.encode_utf16()));
151 ordered.extend(extras);
152 ordered
153}
154
155#[must_use]
158pub fn is_valid_key_id(key: &str) -> bool {
159 const MODIFIERS: [&str; 4] = ["ctrl+", "shift+", "alt+", "super+"];
160 if MODIFIERS.iter().any(|modifier| {
161 key.find(modifier)
162 .is_some_and(|i| key[i + modifier.len()..].contains(modifier))
163 }) {
164 return false;
165 }
166 let mut rest = key;
167 for _ in 0..4 {
168 match MODIFIERS
169 .iter()
170 .find_map(|modifier| rest.strip_prefix(modifier))
171 {
172 Some(stripped) => rest = stripped,
173 None => break,
174 }
175 }
176 let single = rest.len() == 1
177 && rest
178 .bytes()
179 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit());
180 single || NAMED_KEYS.contains(&rest)
181}
182
183pub const KEYBINDING_IDS: [&str; 90] = [
185 "tui.editor.cursorUp",
186 "tui.editor.cursorDown",
187 "tui.editor.historyPrevious",
188 "tui.editor.historyNext",
189 "tui.editor.cursorLeft",
190 "tui.editor.cursorRight",
191 "tui.editor.cursorWordLeft",
192 "tui.editor.cursorWordRight",
193 "tui.editor.cursorLineStart",
194 "tui.editor.cursorLineEnd",
195 "tui.editor.jumpForward",
196 "tui.editor.jumpBackward",
197 "tui.editor.pageUp",
198 "tui.editor.pageDown",
199 "tui.editor.deleteCharBackward",
200 "tui.editor.deleteCharForward",
201 "tui.editor.deleteWordBackward",
202 "tui.editor.deleteWordForward",
203 "tui.editor.deleteToLineStart",
204 "tui.editor.deleteToLineEnd",
205 "tui.editor.yank",
206 "tui.editor.yankPop",
207 "tui.editor.undo",
208 "tui.input.newLine",
209 "tui.input.submit",
210 "tui.input.tab",
211 "tui.input.copy",
212 "tui.select.up",
213 "tui.select.down",
214 "tui.select.pageUp",
215 "tui.select.pageDown",
216 "tui.select.confirm",
217 "tui.select.cancel",
218 "tui.altScreen.pageUp",
219 "tui.altScreen.pageDown",
220 "tui.altScreen.halfPageUp",
221 "tui.altScreen.halfPageDown",
222 "tui.altScreen.lineUp",
223 "tui.altScreen.lineDown",
224 "tui.altScreen.previousPrompt",
225 "tui.altScreen.nextPrompt",
226 "tui.altScreen.search",
227 "tui.altScreen.searchNext",
228 "tui.altScreen.searchPrevious",
229 "tui.altScreen.searchClose",
230 "tui.altScreen.top",
231 "tui.altScreen.bottom",
232 "app.interrupt",
233 "app.clear",
234 "app.exit",
235 "app.suspend",
236 "app.thinking.cycle",
237 "app.thinking.save",
238 "app.model.cycleForward",
239 "app.model.cycleBackward",
240 "app.model.select",
241 "app.tools.expand",
242 "app.thinking.toggle",
243 "app.session.toggleNamedFilter",
244 "app.editor.external",
245 "app.message.copy",
246 "app.message.followUp",
247 "app.message.dequeue",
248 "app.clipboard.pasteImage",
249 "app.session.new",
250 "app.session.tree",
251 "app.session.fork",
252 "app.session.resume",
253 "app.tree.foldOrUp",
254 "app.tree.unfoldOrDown",
255 "app.tree.editLabel",
256 "app.tree.toggleLabelTimestamp",
257 "app.session.togglePath",
258 "app.session.toggleSort",
259 "app.session.rename",
260 "app.session.delete",
261 "app.session.deleteNoninvasive",
262 "app.models.save",
263 "app.models.enableAll",
264 "app.models.clearAll",
265 "app.models.toggleProvider",
266 "app.models.reorderUp",
267 "app.models.reorderDown",
268 "app.tree.filter.default",
269 "app.tree.filter.noTools",
270 "app.tree.filter.userOnly",
271 "app.tree.filter.labeledOnly",
272 "app.tree.filter.all",
273 "app.tree.filter.cycleForward",
274 "app.tree.filter.cycleBackward",
275];
276
277const NAMED_KEYS: [&str; 61] = [
279 "escape",
280 "esc",
281 "enter",
282 "return",
283 "tab",
284 "space",
285 "backspace",
286 "delete",
287 "insert",
288 "clear",
289 "home",
290 "end",
291 "pageUp",
292 "pageDown",
293 "up",
294 "down",
295 "left",
296 "right",
297 "f1",
298 "f2",
299 "f3",
300 "f4",
301 "f5",
302 "f6",
303 "f7",
304 "f8",
305 "f9",
306 "f10",
307 "f11",
308 "f12",
309 "`",
310 "-",
311 "=",
312 "[",
313 "]",
314 "\\",
315 ";",
316 "'",
317 ",",
318 ".",
319 "/",
320 "!",
321 "@",
322 "#",
323 "$",
324 "%",
325 "^",
326 "&",
327 "*",
328 "(",
329 ")",
330 "_",
331 "+",
332 "|",
333 "~",
334 "{",
335 "}",
336 ":",
337 "<",
338 ">",
339 "?",
340];
341
342pub const LEGACY_KEYBINDING_NAMES: [(&str, &str); 59] = [
344 ("cursorUp", "tui.editor.cursorUp"),
345 ("cursorDown", "tui.editor.cursorDown"),
346 ("cursorLeft", "tui.editor.cursorLeft"),
347 ("cursorRight", "tui.editor.cursorRight"),
348 ("cursorWordLeft", "tui.editor.cursorWordLeft"),
349 ("cursorWordRight", "tui.editor.cursorWordRight"),
350 ("cursorLineStart", "tui.editor.cursorLineStart"),
351 ("cursorLineEnd", "tui.editor.cursorLineEnd"),
352 ("jumpForward", "tui.editor.jumpForward"),
353 ("jumpBackward", "tui.editor.jumpBackward"),
354 ("pageUp", "tui.editor.pageUp"),
355 ("pageDown", "tui.editor.pageDown"),
356 ("deleteCharBackward", "tui.editor.deleteCharBackward"),
357 ("deleteCharForward", "tui.editor.deleteCharForward"),
358 ("deleteWordBackward", "tui.editor.deleteWordBackward"),
359 ("deleteWordForward", "tui.editor.deleteWordForward"),
360 ("deleteToLineStart", "tui.editor.deleteToLineStart"),
361 ("deleteToLineEnd", "tui.editor.deleteToLineEnd"),
362 ("yank", "tui.editor.yank"),
363 ("yankPop", "tui.editor.yankPop"),
364 ("undo", "tui.editor.undo"),
365 ("newLine", "tui.input.newLine"),
366 ("submit", "tui.input.submit"),
367 ("tab", "tui.input.tab"),
368 ("copy", "tui.input.copy"),
369 ("selectUp", "tui.select.up"),
370 ("selectDown", "tui.select.down"),
371 ("selectPageUp", "tui.select.pageUp"),
372 ("selectPageDown", "tui.select.pageDown"),
373 ("selectConfirm", "tui.select.confirm"),
374 ("selectCancel", "tui.select.cancel"),
375 ("interrupt", "app.interrupt"),
376 ("clear", "app.clear"),
377 ("exit", "app.exit"),
378 ("suspend", "app.suspend"),
379 ("cycleThinkingLevel", "app.thinking.cycle"),
380 ("cycleModelForward", "app.model.cycleForward"),
381 ("cycleModelBackward", "app.model.cycleBackward"),
382 ("selectModel", "app.model.select"),
383 ("expandTools", "app.tools.expand"),
384 ("toggleThinking", "app.thinking.toggle"),
385 ("toggleSessionNamedFilter", "app.session.toggleNamedFilter"),
386 ("externalEditor", "app.editor.external"),
387 ("followUp", "app.message.followUp"),
388 ("dequeue", "app.message.dequeue"),
389 ("pasteImage", "app.clipboard.pasteImage"),
390 ("newSession", "app.session.new"),
391 ("tree", "app.session.tree"),
392 ("fork", "app.session.fork"),
393 ("resume", "app.session.resume"),
394 ("treeFoldOrUp", "app.tree.foldOrUp"),
395 ("treeUnfoldOrDown", "app.tree.unfoldOrDown"),
396 ("treeEditLabel", "app.tree.editLabel"),
397 ("treeToggleLabelTimestamp", "app.tree.toggleLabelTimestamp"),
398 ("toggleSessionPath", "app.session.togglePath"),
399 ("toggleSessionSort", "app.session.toggleSort"),
400 ("renameSession", "app.session.rename"),
401 ("deleteSession", "app.session.delete"),
402 ("deleteSessionNoninvasive", "app.session.deleteNoninvasive"),
403];
404
405#[cfg(test)]
406mod tests {
407 use serde_json::json;
408
409 use super::*;
410
411 #[test]
412 fn key_ids_validate_like_pi() {
413 for ok in [
414 "a",
415 "ctrl+p",
416 "shift+ctrl+o",
417 "ctrl+shift+alt+super+x",
418 "alt++",
419 "ctrl+-",
420 "pageUp",
421 "f12",
422 ] {
423 assert!(is_valid_key_id(ok), "{ok}");
424 }
425 for bad in [
426 "",
427 "A",
428 "ctrl+ctrl+a",
429 "ctrl+",
430 "ab",
431 "ctrl+shift+alt+super+ctrl+a",
432 "Ctrl+a",
433 "pageup",
434 ] {
435 assert!(!is_valid_key_id(bad), "{bad}");
436 }
437 }
438
439 #[test]
440 fn migration_renames_orders_and_prefers_new_names() {
441 let raw = json!({"zzz.custom": "a", "cursorUp": "up", "app.interrupt": "escape", "interrupt": "x", "aaa": "b"});
442 let (config, migrated) = migrate_keybindings_config(raw.as_object().unwrap());
443 assert!(migrated);
444 assert_eq!(
445 config.keys().map(String::as_str).collect::<Vec<_>>(),
446 ["tui.editor.cursorUp", "app.interrupt", "aaa", "zzz.custom"]
447 );
448 assert_eq!(config["app.interrupt"], json!("escape"));
449 }
450}