Skip to content

Commit d2a51e1

Browse files
author
SqlRush
committed
Unwrap keybinding scalar fields
1 parent bae47d3 commit d2a51e1

3 files changed

Lines changed: 172 additions & 23 deletions

File tree

docs/first-second-parity-audit.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ M7 progress now includes:
425425
- `internal/tui`: Ctrl-S prompt stash now preserves and restores prompt text, cursor position, and pasted-content metadata.
426426
- `internal/tui`: prompt submitted events now retain display text and pasted-content metadata, so downstream runtime code can build text/image content-block messages instead of receiving only the expanded prompt string.
427427
- `internal/tui`: keybinding JSON loading now accepts wrapper object maps, `shortcuts`/`shortcutBindings`, object action fields such as `commandName`/`commandId`, key fields such as `accelerator`/`keystroke`/`hotKey`/`keyCombo`/`keyChord`, string-array key sequences/chords, and `null`/`false` unbind entries.
428+
- `internal/tui`: keybinding JSON loading now also recursively unwraps direct key/action scalar fields, so `key`, `keys`, `shortcut`, `action`, `command`, and `commandName` can carry `{value}`, JSON:API/resource, or GraphQL node-style wrapper payloads while preserving wrapped `false` unbinds.
428429
- `internal/tui`: keybinding JSON loading now recurses through outer wrappers such as `data`, `payload`, `settings`, `config`, `keyboard`, and `keymap`, so nested official or third-party preference exports can expose `bindings`/`shortcuts` without manual flattening.
429430
- `internal/tui`: keybinding JSON loading now also recurses through JSON:API/resource-style `resource`, `attributes`, `properties`, and `attrs` wrappers so API/preference envelopes can expose `keybindings` or `keymap` without manual flattening.
430431
- `internal/tui`: keybinding JSON loading now accepts API/GraphQL collection arrays under `data`, `payload`, `body`, `result`, `response`, `resources`, `included`, `collection`, `list`, `children`, `values`, `nodes`, and `items`, with resource-style binding items unwrapped before parsing.

internal/tui/keybinding_loader.go

Lines changed: 127 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -349,23 +349,13 @@ func bindingKeyField(fields map[string]json.RawMessage, names ...string) (string
349349
continue
350350
}
351351
data = bytes.TrimSpace(data)
352-
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
353-
return "", true, nil
352+
key, ok, err := bindingKeyValue(data, 0)
353+
if err != nil {
354+
return "", false, fmt.Errorf("%s: %w", name, err)
354355
}
355-
if data[0] == '"' {
356-
var key string
357-
if err := json.Unmarshal(data, &key); err != nil {
358-
return "", false, fmt.Errorf("%s: %w", name, err)
359-
}
356+
if ok {
360357
return key, true, nil
361358
}
362-
if data[0] == '[' {
363-
var keys []string
364-
if err := json.Unmarshal(data, &keys); err != nil {
365-
return "", false, fmt.Errorf("%s: %w", name, err)
366-
}
367-
return strings.Join(keys, " "), true, nil
368-
}
369359
return "", false, fmt.Errorf("%s must be a string, string array, or null", name)
370360
}
371361
return "", false, nil
@@ -378,24 +368,138 @@ func bindingActionField(fields map[string]json.RawMessage, names ...string) (Act
378368
continue
379369
}
380370
data = bytes.TrimSpace(data)
381-
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
382-
return ActionNone, true, nil
371+
action, ok, err := bindingActionValue(data, 0)
372+
if err != nil {
373+
return "", false, fmt.Errorf("%s: %w", name, err)
383374
}
384-
if data[0] == '"' {
385-
var action Action
386-
if err := json.Unmarshal(data, &action); err != nil {
387-
return "", false, fmt.Errorf("%s: %w", name, err)
388-
}
375+
if ok {
389376
return action, true, nil
390377
}
378+
return "", false, fmt.Errorf("%s must be an action string, null, or false", name)
379+
}
380+
return "", false, nil
381+
}
382+
383+
func bindingKeyValue(data json.RawMessage, depth int) (string, bool, error) {
384+
data = bytes.TrimSpace(data)
385+
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
386+
return "", true, nil
387+
}
388+
switch data[0] {
389+
case '"':
390+
var key string
391+
if err := json.Unmarshal(data, &key); err != nil {
392+
return "", false, err
393+
}
394+
return key, true, nil
395+
case '[':
396+
var items []json.RawMessage
397+
if err := json.Unmarshal(data, &items); err != nil {
398+
return "", false, err
399+
}
400+
keys := make([]string, 0, len(items))
401+
for _, item := range items {
402+
key, ok, err := bindingKeyValue(item, depth+1)
403+
if err != nil {
404+
return "", false, err
405+
}
406+
if !ok {
407+
return "", false, fmt.Errorf("array entries must be strings or wrapped strings")
408+
}
409+
if key != "" {
410+
keys = append(keys, key)
411+
}
412+
}
413+
return strings.Join(keys, " "), true, nil
414+
case '{':
415+
return bindingWrappedKeyValue(data, depth)
416+
default:
417+
return "", false, nil
418+
}
419+
}
420+
421+
func bindingActionValue(data json.RawMessage, depth int) (Action, bool, error) {
422+
data = bytes.TrimSpace(data)
423+
if len(data) == 0 || bytes.Equal(data, []byte("null")) {
424+
return ActionNone, true, nil
425+
}
426+
switch data[0] {
427+
case '"':
428+
var action Action
429+
if err := json.Unmarshal(data, &action); err != nil {
430+
return "", false, err
431+
}
432+
return action, true, nil
433+
case '{':
434+
return bindingWrappedActionValue(data, depth)
435+
default:
391436
var enabled bool
392437
if err := json.Unmarshal(data, &enabled); err == nil {
393438
if !enabled {
394439
return ActionNone, true, nil
395440
}
396-
return "", false, fmt.Errorf("%s boolean true must use an action name", name)
441+
return "", false, fmt.Errorf("boolean true must use an action name")
442+
}
443+
return "", false, nil
444+
}
445+
}
446+
447+
func bindingWrappedKeyValue(data json.RawMessage, depth int) (string, bool, error) {
448+
if depth >= 8 {
449+
return "", false, nil
450+
}
451+
var fields map[string]json.RawMessage
452+
if err := json.Unmarshal(data, &fields); err != nil {
453+
return "", false, err
454+
}
455+
if key, ok, err := bindingKeyField(fields, bindingKeyValueFields()...); ok || err != nil {
456+
return key, ok, err
457+
}
458+
for _, name := range bindingScalarWrapperFields() {
459+
raw, ok := fields[name]
460+
if !ok {
461+
continue
462+
}
463+
key, ok, err := bindingKeyValue(raw, depth+1)
464+
if ok || err != nil {
465+
return key, ok, err
466+
}
467+
}
468+
return "", false, nil
469+
}
470+
471+
func bindingWrappedActionValue(data json.RawMessage, depth int) (Action, bool, error) {
472+
if depth >= 8 {
473+
return "", false, nil
474+
}
475+
var fields map[string]json.RawMessage
476+
if err := json.Unmarshal(data, &fields); err != nil {
477+
return "", false, err
478+
}
479+
if action, ok, err := bindingActionField(fields, bindingActionValueFields()...); ok || err != nil {
480+
return action, ok, err
481+
}
482+
for _, name := range bindingScalarWrapperFields() {
483+
raw, ok := fields[name]
484+
if !ok {
485+
continue
486+
}
487+
action, ok, err := bindingActionValue(raw, depth+1)
488+
if ok || err != nil {
489+
return action, ok, err
397490
}
398-
return "", false, fmt.Errorf("%s must be an action string, null, or false", name)
399491
}
400492
return "", false, nil
401493
}
494+
495+
func bindingKeyValueFields() []string {
496+
return []string{"value", "key", "keys", "key_sequence", "keySequence", "shortcut", "shortcut_key", "shortcutKey", "shortcut_keys", "shortcutKeys", "sequence", "accelerator", "accelerators", "keystroke", "keyStroke", "hotkey", "hotKey", "key_combo", "keyCombo", "chord", "keyChord"}
497+
}
498+
499+
func bindingActionValueFields() []string {
500+
return []string{"value", "action", "command", "action_name", "actionName", "command_name", "commandName", "command_id", "commandId"}
501+
}
502+
503+
func bindingScalarWrapperFields() []string {
504+
return []string{"payload", "data", "body", "result", "response", "output", "resource", "attributes", "properties", "attrs", "node", "edge", "record", "entry", "item"}
505+
}

internal/tui/tui_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1855,6 +1855,50 @@ func TestParseKeyBindingSpecsAcceptsNestedWrappers(t *testing.T) {
18551855
}
18561856
}
18571857

1858+
func TestParseKeyBindingSpecsAcceptsWrappedScalarFields(t *testing.T) {
1859+
specs, err := ParseKeyBindingSpecs([]byte(`{
1860+
"bindings": [
1861+
{
1862+
"key": {"payload": {"value": "ctrl-r"}},
1863+
"action": {"data": {"commandName": "editor.action.deleteWordLeft"}}
1864+
},
1865+
{
1866+
"keys": [{"value": "ctrl-x"}, {"resource": {"attributes": {"value": "ctrl-k"}}}],
1867+
"command": {"node": {"value": false}}
1868+
},
1869+
{
1870+
"shortcut": {"resource": {"attributes": {"shortcutKey": "shiftEnter"}}},
1871+
"commandName": {"resource": {"attributes": {"value": "insertNewline"}}}
1872+
}
1873+
]
1874+
}`))
1875+
if err != nil {
1876+
t.Fatal(err)
1877+
}
1878+
if len(specs) != 3 || specs[0].Key != "ctrl-r" || specs[0].Action != Action("editor.action.deleteWordLeft") || specs[1].Key != "ctrl-x ctrl-k" || specs[1].Action != ActionNone || specs[2].Key != "shiftEnter" || specs[2].Action != Action("insertNewline") {
1879+
t.Fatalf("specs = %#v", specs)
1880+
}
1881+
keymap, err := KeymapFromSpecs(DefaultKeymap(), specs)
1882+
if err != nil {
1883+
t.Fatal(err)
1884+
}
1885+
if action := keymap.Resolve(ParseKey("\x12")); action != ActionDeleteWordBack {
1886+
t.Fatalf("wrapped ctrl-r action = %q", action)
1887+
}
1888+
if action := keymap.Resolve(ParseKey("\x18")); action != ActionNone {
1889+
t.Fatalf("wrapped ctrl-x prefix action = %q", action)
1890+
}
1891+
if action := keymap.Resolve(ParseKey("\x0b")); action != ActionNone {
1892+
t.Fatalf("wrapped ctrl-x ctrl-k action = %q", action)
1893+
}
1894+
if action := keymap.Resolve(ParseKey("\x0b")); action != ActionDeleteToEnd {
1895+
t.Fatalf("wrapped ctrl-k after pending action = %q", action)
1896+
}
1897+
if action := keymap.Resolve(ParseKey("\x1b[13;2u")); action != ActionInsertNewline {
1898+
t.Fatalf("wrapped shift-enter action = %q", action)
1899+
}
1900+
}
1901+
18581902
func TestParseKeyBindingSpecsAcceptsProviderResponseWrappers(t *testing.T) {
18591903
specArray := []map[string]any{
18601904
{"key": "ctrl-r", "action": "pageDown"},

0 commit comments

Comments
 (0)