Skip to main content

oino_resources/
keybindings.rs

1//! `<agent>/keybindings.json`: load, migrate legacy
2//! names, validate key ids. Semantics (defaults, conflicts, dispatch) belong to frontends.
3
4use 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/// A binding value: one key id or a list.
15#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16#[serde(untagged)]
17pub enum KeyBinding {
18    /// Single key id, e.g. `ctrl+p`.
19    One(String),
20    /// Several key ids (an empty list unbinds).
21    Many(Vec<String>),
22}
23
24impl KeyBinding {
25    /// The key ids.
26    #[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    /// The `KeybindingValueSchema` check.
35    #[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/// The loaded keybindings file.
54#[derive(Clone, Debug, Default, PartialEq, Eq)]
55pub struct KeybindingsFile {
56    /// File path.
57    pub path: PathBuf,
58    /// Valid user bindings, migrated and ordered (known ids first, extras sorted).
59    pub bindings: IndexMap<String, KeyBinding>,
60    /// Whether legacy names were migrated (the file is not rewritten).
61    pub migrated: bool,
62    /// Unreadable file or dropped invalid entries (dropped silently).
63    pub diagnostics: Vec<ResourceDiagnostic>,
64}
65
66impl KeybindingsFile {
67    /// Load `<agent_dir>/keybindings.json`; a missing or
68    /// unreadable file yields no bindings.
69    #[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/// Rename legacy names (an existing new name wins), then order
121/// known ids first and extras sorted.
122#[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/// Up to four distinct `ctrl+`/`shift+`/`alt+`/`super+` modifiers, then one
156/// base key (`[a-z0-9]` or a named key). Lowercase only.
157#[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
183/// Known keybinding ids in declaration order (`KEYBINDINGS`: tui then app), used to order files.
184pub 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
277/// Named base keys accepted after modifiers (`Key` values; plus single `[a-z0-9]`).
278const 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
342/// Legacy name → id.
343pub 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}