synabun 1.3.0

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 (187) hide show
  1. package/CHANGELOG.md +665 -0
  2. package/LICENSE +191 -0
  3. package/README.md +853 -0
  4. package/hooks/claude-code/post-plan.mjs +391 -0
  5. package/hooks/claude-code/post-remember.mjs +239 -0
  6. package/hooks/claude-code/pre-compact.mjs +241 -0
  7. package/hooks/claude-code/pre-websearch.mjs +90 -0
  8. package/hooks/claude-code/prompt-submit.mjs +861 -0
  9. package/hooks/claude-code/session-start.mjs +494 -0
  10. package/hooks/claude-code/shared.mjs +368 -0
  11. package/hooks/claude-code/stop.mjs +646 -0
  12. package/hooks/claude-code/test-user-learning.mjs +531 -0
  13. package/mcp-server/package.json +38 -0
  14. package/mcp-server/run.mjs +10 -0
  15. package/mcp-server/src/config.ts +70 -0
  16. package/mcp-server/src/http.ts +93 -0
  17. package/mcp-server/src/index.ts +203 -0
  18. package/mcp-server/src/preload-http.ts +25 -0
  19. package/mcp-server/src/preload.ts +29 -0
  20. package/mcp-server/src/scripts/migrate-qdrant-to-sqlite.ts +506 -0
  21. package/mcp-server/src/services/categories.ts +429 -0
  22. package/mcp-server/src/services/discord.ts +702 -0
  23. package/mcp-server/src/services/file-checksums.ts +33 -0
  24. package/mcp-server/src/services/local-embeddings.ts +77 -0
  25. package/mcp-server/src/services/neural-interface.ts +344 -0
  26. package/mcp-server/src/services/sqlite.ts +823 -0
  27. package/mcp-server/src/tools/browser-advanced.ts +123 -0
  28. package/mcp-server/src/tools/browser-interact.ts +255 -0
  29. package/mcp-server/src/tools/browser-navigate.ts +77 -0
  30. package/mcp-server/src/tools/browser-observe.ts +1330 -0
  31. package/mcp-server/src/tools/browser.ts +107 -0
  32. package/mcp-server/src/tools/card-tools.ts +193 -0
  33. package/mcp-server/src/tools/card.ts +22 -0
  34. package/mcp-server/src/tools/category.ts +379 -0
  35. package/mcp-server/src/tools/discord-channel.ts +274 -0
  36. package/mcp-server/src/tools/discord-guild.ts +188 -0
  37. package/mcp-server/src/tools/discord-member.ts +158 -0
  38. package/mcp-server/src/tools/discord-message.ts +231 -0
  39. package/mcp-server/src/tools/discord-onboarding.ts +228 -0
  40. package/mcp-server/src/tools/discord-role.ts +209 -0
  41. package/mcp-server/src/tools/discord-thread.ts +110 -0
  42. package/mcp-server/src/tools/discord-webhook.ts +176 -0
  43. package/mcp-server/src/tools/discord.ts +40 -0
  44. package/mcp-server/src/tools/forget.ts +38 -0
  45. package/mcp-server/src/tools/git-tools.ts +141 -0
  46. package/mcp-server/src/tools/git.ts +13 -0
  47. package/mcp-server/src/tools/image-tools.ts +98 -0
  48. package/mcp-server/src/tools/image.ts +17 -0
  49. package/mcp-server/src/tools/leonardo-browser-tools.ts +281 -0
  50. package/mcp-server/src/tools/leonardo.ts +28 -0
  51. package/mcp-server/src/tools/loop.ts +348 -0
  52. package/mcp-server/src/tools/memories.ts +118 -0
  53. package/mcp-server/src/tools/recall.ts +323 -0
  54. package/mcp-server/src/tools/reflect.ts +145 -0
  55. package/mcp-server/src/tools/remember.ts +115 -0
  56. package/mcp-server/src/tools/response.ts +26 -0
  57. package/mcp-server/src/tools/restore.ts +34 -0
  58. package/mcp-server/src/tools/sync.ts +114 -0
  59. package/mcp-server/src/tools/tictactoe-tools.ts +98 -0
  60. package/mcp-server/src/tools/tictactoe.ts +13 -0
  61. package/mcp-server/src/tools/utils.ts +25 -0
  62. package/mcp-server/src/tools/whiteboard-tools.ts +260 -0
  63. package/mcp-server/src/tools/whiteboard.ts +22 -0
  64. package/mcp-server/src/tui.ts +940 -0
  65. package/mcp-server/src/types.ts +85 -0
  66. package/mcp-server/tsconfig.json +19 -0
  67. package/neural-interface/package.json +27 -0
  68. package/neural-interface/public/apple-touch-icon.png +0 -0
  69. package/neural-interface/public/category-logos/claude.svg +1 -0
  70. package/neural-interface/public/category-logos/gemini-color.svg +1 -0
  71. package/neural-interface/public/category-logos/openai.svg +1 -0
  72. package/neural-interface/public/category-logos/synabun.png +0 -0
  73. package/neural-interface/public/claude-chat.css +878 -0
  74. package/neural-interface/public/claude-chat.html +92 -0
  75. package/neural-interface/public/claude-chat.js +1168 -0
  76. package/neural-interface/public/favicon-16x16.png +0 -0
  77. package/neural-interface/public/favicon-32x32.png +0 -0
  78. package/neural-interface/public/gallery.svg +6 -0
  79. package/neural-interface/public/icon-192.png +0 -0
  80. package/neural-interface/public/icon-512.png +0 -0
  81. package/neural-interface/public/index-legacy.html +14677 -0
  82. package/neural-interface/public/index.html +212 -0
  83. package/neural-interface/public/index2d-legacy.html +8193 -0
  84. package/neural-interface/public/index2d.html +127 -0
  85. package/neural-interface/public/invite.html +351 -0
  86. package/neural-interface/public/leonardoai.svg +1 -0
  87. package/neural-interface/public/logo-synabun.png +0 -0
  88. package/neural-interface/public/logoHD.png +0 -0
  89. package/neural-interface/public/manifest.json +14 -0
  90. package/neural-interface/public/offline.html +299 -0
  91. package/neural-interface/public/onboarding.html +2335 -0
  92. package/neural-interface/public/openclaw-logo-text.png +0 -0
  93. package/neural-interface/public/pin-svgrepo-com.svg +25 -0
  94. package/neural-interface/public/sessionicon.svg +8 -0
  95. package/neural-interface/public/shared/ansi-parser.js +305 -0
  96. package/neural-interface/public/shared/api.js +1043 -0
  97. package/neural-interface/public/shared/colors.js +153 -0
  98. package/neural-interface/public/shared/constants.js +141 -0
  99. package/neural-interface/public/shared/html-shell.js +777 -0
  100. package/neural-interface/public/shared/html-term-renderer.js +1022 -0
  101. package/neural-interface/public/shared/i18n.js +147 -0
  102. package/neural-interface/public/shared/registry.js +82 -0
  103. package/neural-interface/public/shared/state.js +86 -0
  104. package/neural-interface/public/shared/storage.js +150 -0
  105. package/neural-interface/public/shared/styles.css +21212 -0
  106. package/neural-interface/public/shared/ui-automation-studio.js +3573 -0
  107. package/neural-interface/public/shared/ui-bookmarks.js +231 -0
  108. package/neural-interface/public/shared/ui-claude-panel.js +6333 -0
  109. package/neural-interface/public/shared/ui-command-runner.js +534 -0
  110. package/neural-interface/public/shared/ui-cost-widget.js +602 -0
  111. package/neural-interface/public/shared/ui-detail.js +1454 -0
  112. package/neural-interface/public/shared/ui-explorer.js +1356 -0
  113. package/neural-interface/public/shared/ui-file-explorer.js +2750 -0
  114. package/neural-interface/public/shared/ui-games.js +411 -0
  115. package/neural-interface/public/shared/ui-help.js +107 -0
  116. package/neural-interface/public/shared/ui-image-gallery.js +461 -0
  117. package/neural-interface/public/shared/ui-invite.js +519 -0
  118. package/neural-interface/public/shared/ui-keybinds.js +452 -0
  119. package/neural-interface/public/shared/ui-layouts.js +297 -0
  120. package/neural-interface/public/shared/ui-link.js +731 -0
  121. package/neural-interface/public/shared/ui-loading.js +255 -0
  122. package/neural-interface/public/shared/ui-menubar.js +499 -0
  123. package/neural-interface/public/shared/ui-multiselect.js +93 -0
  124. package/neural-interface/public/shared/ui-navbar.js +222 -0
  125. package/neural-interface/public/shared/ui-notifications.js +287 -0
  126. package/neural-interface/public/shared/ui-panels.js +309 -0
  127. package/neural-interface/public/shared/ui-resume.js +653 -0
  128. package/neural-interface/public/shared/ui-search.js +145 -0
  129. package/neural-interface/public/shared/ui-sessions.js +531 -0
  130. package/neural-interface/public/shared/ui-settings.js +5987 -0
  131. package/neural-interface/public/shared/ui-sidebar.js +1905 -0
  132. package/neural-interface/public/shared/ui-skills.js +2805 -0
  133. package/neural-interface/public/shared/ui-stats.js +38 -0
  134. package/neural-interface/public/shared/ui-sync.js +201 -0
  135. package/neural-interface/public/shared/ui-terminal.js +6385 -0
  136. package/neural-interface/public/shared/ui-tooltip.js +123 -0
  137. package/neural-interface/public/shared/ui-trash.js +439 -0
  138. package/neural-interface/public/shared/ui-tutorial-draw.js +875 -0
  139. package/neural-interface/public/shared/ui-tutorial-steps.js +2276 -0
  140. package/neural-interface/public/shared/ui-tutorial.js +1117 -0
  141. package/neural-interface/public/shared/ui-update.js +244 -0
  142. package/neural-interface/public/shared/ui-whiteboard.js +3262 -0
  143. package/neural-interface/public/shared/ui-workspaces.js +722 -0
  144. package/neural-interface/public/shared/utils.js +360 -0
  145. package/neural-interface/public/shared/vterm-buffer.js +1102 -0
  146. package/neural-interface/public/sw.js +52 -0
  147. package/neural-interface/public/synabun.png +0 -0
  148. package/neural-interface/public/toolbar-album.svg +8 -0
  149. package/neural-interface/public/toolbar-question.svg +6 -0
  150. package/neural-interface/public/toolbar-sessions.svg +6 -0
  151. package/neural-interface/public/variant/2d/background.js +113 -0
  152. package/neural-interface/public/variant/2d/context-menu.js +133 -0
  153. package/neural-interface/public/variant/2d/gfx.js +45 -0
  154. package/neural-interface/public/variant/2d/graph.js +1635 -0
  155. package/neural-interface/public/variant/2d/hulls.js +207 -0
  156. package/neural-interface/public/variant/2d/lasso.js +135 -0
  157. package/neural-interface/public/variant/2d/layout.js +594 -0
  158. package/neural-interface/public/variant/2d/main.js +521 -0
  159. package/neural-interface/public/variant/2d/minimap.js +312 -0
  160. package/neural-interface/public/variant/2d/settings-gfx.js +98 -0
  161. package/neural-interface/public/variant/3d/background.js +884 -0
  162. package/neural-interface/public/variant/3d/camera.js +661 -0
  163. package/neural-interface/public/variant/3d/gfx.js +116 -0
  164. package/neural-interface/public/variant/3d/graph.js +2399 -0
  165. package/neural-interface/public/variant/3d/main.js +500 -0
  166. package/neural-interface/public/variant/3d/settings-gfx.js +228 -0
  167. package/neural-interface/scripts/rebuild-pty.js +100 -0
  168. package/neural-interface/server.js +15000 -0
  169. package/neural-interface/templates/CLAUDE-template.md +211 -0
  170. package/package.json +53 -0
  171. package/postinstall.js +135 -0
  172. package/setup.js +260 -0
  173. package/skills/leonardo/SKILL.md +159 -0
  174. package/skills/leonardo/icon.svg +1 -0
  175. package/skills/leonardo/modules/browser-guide.md +378 -0
  176. package/skills/leonardo/modules/image-prompter.md +277 -0
  177. package/skills/leonardo/modules/model-advisor.md +100 -0
  178. package/skills/leonardo/modules/prompt-library.md +110 -0
  179. package/skills/leonardo/modules/style-guide.md +186 -0
  180. package/skills/leonardo/modules/video-prompter.md +311 -0
  181. package/skills/synabun/SKILL.md +185 -0
  182. package/skills/synabun/icon.png +0 -0
  183. package/skills/synabun/modules/audit.md +374 -0
  184. package/skills/synabun/modules/changelog.md +194 -0
  185. package/skills/synabun/modules/idea.md +144 -0
  186. package/skills/synabun/modules/memorize.md +188 -0
  187. package/skills/synabun/synabunicon.png +0 -0
@@ -0,0 +1,452 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun Neural Interface — Keybinds System
3
+ // Central keyboard shortcut dispatcher + settings modal
4
+ // ═══════════════════════════════════════════
5
+
6
+ import { DEFAULT_KEYBINDS, KEYBIND_META } from './constants.js';
7
+ import { fetchKeybinds, saveKeybindsToServer } from './api.js';
8
+
9
+ // ── CLI icons for Launch group ──
10
+
11
+ const CLI_ICONS = {
12
+ claude: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"/></svg>',
13
+ codex: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.205 8.658v-2.26c0-.19.072-.333.238-.428l4.543-2.616c.619-.357 1.356-.523 2.117-.523 2.854 0 4.662 2.212 4.662 4.566 0 .167 0 .357-.024.547l-4.71-2.759a.797.797 0 00-.856 0l-5.97 3.473zm10.609 8.8V12.06c0-.333-.143-.57-.429-.737l-5.97-3.473 1.95-1.118a.433.433 0 01.476 0l4.543 2.617c1.309.76 2.189 2.378 2.189 3.948 0 1.808-1.07 3.473-2.76 4.163zM7.802 12.703l-1.95-1.142c-.167-.095-.239-.238-.239-.428V5.899c0-2.545 1.95-4.472 4.591-4.472 1 0 1.927.333 2.712.928L8.23 5.067c-.285.166-.428.404-.428.737v6.898zM12 15.128l-2.795-1.57v-3.33L12 8.658l2.795 1.57v3.33L12 15.128zm1.796 7.23c-1 0-1.927-.332-2.712-.927l4.686-2.712c.285-.166.428-.404.428-.737v-6.898l1.974 1.142c.167.095.238.238.238.428v5.233c0 2.545-1.974 4.472-4.614 4.472zm-5.637-5.303l-4.544-2.617c-1.308-.761-2.188-2.378-2.188-3.948A4.482 4.482 0 014.21 6.327v5.423c0 .333.143.571.428.738l5.947 3.449-1.95 1.118a.432.432 0 01-.476 0zm-.262 3.9c-2.688 0-4.662-2.021-4.662-4.519 0-.19.024-.38.047-.57l4.686 2.71c.286.167.571.167.856 0l5.97-3.448v2.26c0 .19-.07.333-.237.428l-4.543 2.616c-.619.357-1.356.523-2.117.523zm5.899 2.83a5.947 5.947 0 005.827-4.756C22.287 18.339 24 15.84 24 13.296c0-1.665-.713-3.282-1.998-4.448.119-.5.19-.999.19-1.498 0-3.401-2.759-5.947-5.946-5.947-.642 0-1.26.095-1.88.31A5.962 5.962 0 0010.205 0a5.947 5.947 0 00-5.827 4.757C1.713 5.447 0 7.945 0 10.49c0 1.666.713 3.283 1.998 4.448-.119.5-.19 1-.19 1.499 0 3.401 2.759 5.946 5.946 5.946.642 0 1.26-.095 1.88-.309a5.96 5.96 0 004.162 1.713z"/></svg>',
14
+ gemini: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z"/></svg>',
15
+ browser: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><ellipse cx="12" cy="12" rx="4" ry="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M4.5 7h15M4.5 17h15" stroke-width="1"/></svg>',
16
+ youtube: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>',
17
+ };
18
+
19
+ // ── State ──
20
+
21
+ const _actions = new Map(); // actionId → handler function
22
+ let _bindings = {}; // actionId → combo string (merged defaults + overrides)
23
+ let _reverseMap = new Map(); // combo string → actionId (for O(1) dispatch)
24
+ let _recording = null; // { actionId, originalCombo } when recording
25
+ let _pendingBindings = null; // working copy during modal editing
26
+ let _modalOpen = false;
27
+
28
+ // ── Combo building ──
29
+
30
+ function buildCombo(e) {
31
+ const parts = [];
32
+ if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
33
+ if (e.altKey) parts.push('Alt');
34
+
35
+ let key = e.key;
36
+ // Normalize: don't include bare modifier keys as the key portion
37
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(key)) return null;
38
+
39
+ // Only include Shift for non-printable keys (Tab, Enter, arrows, F-keys, etc.)
40
+ // For printable characters, the key value already reflects Shift (e.g. ? vs /, C vs c)
41
+ if (e.shiftKey && key.length > 1) {
42
+ parts.push('Shift');
43
+ }
44
+
45
+ parts.push(key);
46
+ return parts.join('+');
47
+ }
48
+
49
+ function comboToDisplay(combo) {
50
+ if (!combo) return '—';
51
+ return combo
52
+ .split('+')
53
+ .map(part => {
54
+ if (part === 'Ctrl') return 'Ctrl';
55
+ if (part === 'Alt') return 'Alt';
56
+ if (part === 'Shift') return 'Shift';
57
+ if (part === 'Meta') return 'Meta';
58
+ if (part === '`') return '`';
59
+ if (part === '/') return '/';
60
+ if (part === '?') return '?';
61
+ if (part === ' ') return 'Space';
62
+ if (part === 'Escape') return 'Esc';
63
+ if (part.length === 1) return part.toUpperCase();
64
+ return part;
65
+ })
66
+ .join(' + ');
67
+ }
68
+
69
+ function buildReverseMap(bindings) {
70
+ const map = new Map();
71
+ for (const [actionId, combo] of Object.entries(bindings)) {
72
+ if (combo) map.set(combo, actionId);
73
+ }
74
+ return map;
75
+ }
76
+
77
+ // ── Public API ──
78
+
79
+ /** Register an action handler. Called by each module during init. */
80
+ export function registerAction(actionId, handler) {
81
+ _actions.set(actionId, handler);
82
+ }
83
+
84
+ /** Get human-readable key label for an action (used by help modal). */
85
+ export function getDisplayKey(actionId) {
86
+ return comboToDisplay(_bindings[actionId]);
87
+ }
88
+
89
+ /** Get raw combo string for an action. */
90
+ export function getBinding(actionId) {
91
+ return _bindings[actionId] || null;
92
+ }
93
+
94
+ // ── Central dispatcher ──
95
+
96
+ function centralKeyHandler(e) {
97
+ // Don't dispatch when modal is open (recording uses its own handler)
98
+ if (_modalOpen) return;
99
+
100
+ // Skip if in text input
101
+ const tag = document.activeElement?.tagName;
102
+ if (tag === 'INPUT' || tag === 'TEXTAREA') return;
103
+ // Skip if in xterm.js terminal
104
+ if (document.activeElement?.closest('.term-viewport')) return;
105
+ // Skip if contenteditable
106
+ if (document.activeElement?.isContentEditable) return;
107
+
108
+ const combo = buildCombo(e);
109
+ if (!combo) return;
110
+
111
+ const actionId = _reverseMap.get(combo);
112
+ if (!actionId) return;
113
+
114
+ const handler = _actions.get(actionId);
115
+ if (!handler) return;
116
+
117
+ e.preventDefault();
118
+ handler();
119
+ }
120
+
121
+ // ── Modal ──
122
+
123
+ function renderModal() {
124
+ // Group actions by their group
125
+ const groups = {};
126
+ for (const [actionId, meta] of Object.entries(KEYBIND_META)) {
127
+ const group = meta.group || 'Other';
128
+ if (!groups[group]) groups[group] = [];
129
+ groups[group].push({ actionId, ...meta });
130
+ }
131
+
132
+ // Split into two columns: Navigation left, rest right
133
+ const leftGroups = {};
134
+ const rightGroups = {};
135
+ for (const [groupName, actions] of Object.entries(groups)) {
136
+ if (groupName === 'Navigation') leftGroups[groupName] = actions;
137
+ else rightGroups[groupName] = actions;
138
+ }
139
+
140
+ function renderColumn(colGroups) {
141
+ let html = '';
142
+ for (const [groupName, actions] of Object.entries(colGroups)) {
143
+ html += `<div class="kb-group">
144
+ <div class="kb-group-title">${groupName}</div>`;
145
+ for (const { actionId, label, icon } of actions) {
146
+ const combo = _pendingBindings[actionId];
147
+ const display = comboToDisplay(combo);
148
+ const iconHtml = icon && CLI_ICONS[icon]
149
+ ? `<span class="kb-cli-icon kb-cli-${icon}">${CLI_ICONS[icon]}</span>`
150
+ : '';
151
+ html += `<div class="kb-row${combo ? '' : ' kb-row-unbound'}" data-action="${actionId}">
152
+ <span class="kb-label">${iconHtml}${label}</span>
153
+ <button class="kb-key-btn${combo ? '' : ' kb-unbound'}" data-action="${actionId}"${combo ? '' : ' style="display:none"'}>
154
+ <span class="kb-key">${display}</span>
155
+ </button>
156
+ </div>`;
157
+ }
158
+ html += `</div>`;
159
+ }
160
+ return html;
161
+ }
162
+
163
+ return `<div class="kb-col">${renderColumn(leftGroups)}</div>
164
+ <div class="kb-col">${renderColumn(rightGroups)}</div>`;
165
+ }
166
+
167
+ function updateRowDisplay(actionId) {
168
+ const modal = document.getElementById('keybinds-panel');
169
+ if (!modal) return;
170
+ const row = modal.querySelector(`.kb-row[data-action="${actionId}"]`);
171
+ const btn = modal.querySelector(`.kb-key-btn[data-action="${actionId}"]`);
172
+ if (!btn) return;
173
+ const combo = _pendingBindings[actionId];
174
+ const display = comboToDisplay(combo);
175
+ btn.querySelector('.kb-key').textContent = display;
176
+ btn.classList.toggle('kb-unbound', !combo);
177
+ btn.style.display = combo ? '' : 'none';
178
+ if (row) row.classList.toggle('kb-row-unbound', !combo);
179
+ btn.classList.remove('kb-conflict');
180
+ }
181
+
182
+ function startRecording(actionId) {
183
+ // Cancel any previous recording
184
+ stopRecording();
185
+
186
+ _recording = { actionId, originalCombo: _pendingBindings[actionId] };
187
+
188
+ const modal = document.getElementById('keybinds-panel');
189
+ if (!modal) return;
190
+ const btn = modal.querySelector(`.kb-key-btn[data-action="${actionId}"]`);
191
+ if (btn) {
192
+ btn.style.display = '';
193
+ btn.classList.add('kb-recording');
194
+ btn.querySelector('.kb-key').textContent = 'Press keys...';
195
+ }
196
+
197
+ // Recording keydown listener
198
+ document.addEventListener('keydown', recordKeyHandler, true);
199
+ }
200
+
201
+ function stopRecording() {
202
+ if (!_recording) return;
203
+ const { actionId } = _recording;
204
+ _recording = null;
205
+
206
+ document.removeEventListener('keydown', recordKeyHandler, true);
207
+
208
+ const modal = document.getElementById('keybinds-panel');
209
+ if (!modal) return;
210
+ const btn = modal.querySelector(`.kb-key-btn[data-action="${actionId}"]`);
211
+ if (btn) btn.classList.remove('kb-recording');
212
+
213
+ updateRowDisplay(actionId);
214
+ }
215
+
216
+ function recordKeyHandler(e) {
217
+ e.preventDefault();
218
+ e.stopPropagation();
219
+ e.stopImmediatePropagation();
220
+
221
+ if (!_recording) return;
222
+
223
+ // Escape cancels recording
224
+ if (e.key === 'Escape') {
225
+ stopRecording();
226
+ return;
227
+ }
228
+
229
+ // Ignore bare modifier presses
230
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(e.key)) return;
231
+
232
+ const combo = buildCombo(e);
233
+ if (!combo) return;
234
+
235
+ const { actionId } = _recording;
236
+
237
+ // Check for conflicts
238
+ const conflictAction = Object.entries(_pendingBindings).find(
239
+ ([id, c]) => c === combo && id !== actionId
240
+ );
241
+
242
+ if (conflictAction) {
243
+ const [conflictId] = conflictAction;
244
+ const conflictMeta = KEYBIND_META[conflictId];
245
+
246
+ // Show conflict — swap the bindings
247
+ const modal = document.getElementById('keybinds-panel');
248
+ if (modal) {
249
+ const conflictBtn = modal.querySelector(`.kb-key-btn[data-action="${conflictId}"]`);
250
+ if (conflictBtn) {
251
+ conflictBtn.classList.add('kb-conflict');
252
+ setTimeout(() => conflictBtn.classList.remove('kb-conflict'), 1500);
253
+ }
254
+ }
255
+
256
+ // Swap: give the conflicting action our old binding (or unbind it)
257
+ _pendingBindings[conflictId] = _recording.originalCombo || null;
258
+ updateRowDisplay(conflictId);
259
+
260
+ // Show a toast-like notification
261
+ showConflictToast(conflictMeta?.label || conflictId, combo);
262
+ }
263
+
264
+ // Apply the new binding
265
+ _pendingBindings[actionId] = combo;
266
+
267
+ stopRecording();
268
+ }
269
+
270
+ function showConflictToast(conflictLabel, combo) {
271
+ const existing = document.querySelector('.kb-toast');
272
+ if (existing) existing.remove();
273
+
274
+ const toast = document.createElement('div');
275
+ toast.className = 'kb-toast';
276
+ toast.textContent = `Swapped "${conflictLabel}" — was also bound to ${comboToDisplay(combo)}`;
277
+ document.body.appendChild(toast);
278
+ requestAnimationFrame(() => toast.classList.add('kb-toast-visible'));
279
+ setTimeout(() => {
280
+ toast.classList.remove('kb-toast-visible');
281
+ setTimeout(() => toast.remove(), 300);
282
+ }, 3500);
283
+ }
284
+
285
+ export function openKeybindsModal() {
286
+ // If already open, ignore
287
+ const existing = document.getElementById('keybinds-panel');
288
+ if (existing) return;
289
+ if (_modalOpen) return;
290
+
291
+ _modalOpen = true;
292
+ _pendingBindings = { ..._bindings };
293
+
294
+ // Backdrop overlay
295
+ const backdrop = document.createElement('div');
296
+ backdrop.className = 'kb-backdrop';
297
+ backdrop.id = 'kb-backdrop';
298
+ // Backdrop click disabled — close only via ESC or close button
299
+
300
+ const panel = document.createElement('div');
301
+ panel.className = 'keybinds-panel glass';
302
+ panel.id = 'keybinds-panel';
303
+
304
+ panel.innerHTML = `
305
+ <div class="settings-panel-header">
306
+ <h3>Keyboard Shortcuts</h3>
307
+ <button class="backdrop-toggle-btn" id="kb-backdrop-toggle" data-tooltip="Toggle backdrop">
308
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
309
+ </button>
310
+ <button class="settings-panel-close kb-close">&times;</button>
311
+ </div>
312
+ <div class="kb-hint">Click any action to rebind. Press <kbd>Esc</kbd> to cancel recording.</div>
313
+ <div class="kb-body">${renderModal()}</div>
314
+ <div class="kb-footer">
315
+ <button class="action-btn action-btn--ghost kb-reset-btn">Reset to Defaults</button>
316
+ <div class="kb-footer-right">
317
+ <button class="action-btn action-btn--danger kb-unbind-btn">Unbind</button>
318
+ <button class="action-btn action-btn--primary kb-save-btn">Save</button>
319
+ </div>
320
+ </div>
321
+ `;
322
+
323
+ document.body.appendChild(backdrop);
324
+ document.body.appendChild(panel);
325
+ requestAnimationFrame(() => {
326
+ backdrop.classList.add('open');
327
+ panel.classList.add('open');
328
+ });
329
+
330
+ // Wire events
331
+ panel.querySelector('.kb-close').addEventListener('click', closeKeybindsModal);
332
+
333
+ const kbBdToggle = panel.querySelector('#kb-backdrop-toggle');
334
+ if (kbBdToggle) {
335
+ kbBdToggle.addEventListener('click', () => {
336
+ const kbBackdrop = document.getElementById('kb-backdrop');
337
+ if (kbBackdrop) {
338
+ kbBackdrop.classList.toggle('backdrop-hidden');
339
+ kbBdToggle.classList.toggle('active', kbBackdrop.classList.contains('backdrop-hidden'));
340
+ }
341
+ });
342
+ }
343
+
344
+ // Row or key button clicks → start recording
345
+ panel.querySelectorAll('.kb-row').forEach(row => {
346
+ row.addEventListener('click', () => {
347
+ const actionId = row.dataset.action;
348
+ if (actionId) startRecording(actionId);
349
+ });
350
+ });
351
+
352
+ // Reset to defaults
353
+ panel.querySelector('.kb-reset-btn').addEventListener('click', () => {
354
+ _pendingBindings = { ...DEFAULT_KEYBINDS };
355
+ panel.querySelector('.kb-body').innerHTML = renderModal();
356
+ panel.querySelectorAll('.kb-row').forEach(row => {
357
+ row.addEventListener('click', () => {
358
+ const actionId = row.dataset.action;
359
+ if (actionId) startRecording(actionId);
360
+ });
361
+ });
362
+ });
363
+
364
+ // Unbind — removes binding for the currently recording action
365
+ panel.querySelector('.kb-unbind-btn').addEventListener('click', () => {
366
+ if (_recording) {
367
+ const { actionId } = _recording;
368
+ _pendingBindings[actionId] = null;
369
+ stopRecording();
370
+ }
371
+ });
372
+
373
+ // Save
374
+ panel.querySelector('.kb-save-btn').addEventListener('click', async () => {
375
+ stopRecording();
376
+ _bindings = { ..._pendingBindings };
377
+ _reverseMap = buildReverseMap(_bindings);
378
+ try {
379
+ await saveKeybindsToServer({ _version: 1, bindings: _bindings });
380
+ } catch (err) {
381
+ console.error('Failed to save keybinds:', err);
382
+ }
383
+ syncKeybindLabels();
384
+ closeKeybindsModal();
385
+ });
386
+
387
+ // Escape key (when not recording)
388
+ const panelEscHandler = (e) => {
389
+ if (e.key === 'Escape' && !_recording) {
390
+ closeKeybindsModal();
391
+ document.removeEventListener('keydown', panelEscHandler);
392
+ }
393
+ };
394
+ document.addEventListener('keydown', panelEscHandler);
395
+ }
396
+
397
+ export function closeKeybindsModal() {
398
+ stopRecording();
399
+ _modalOpen = false;
400
+ _pendingBindings = null;
401
+
402
+ const backdrop = document.getElementById('kb-backdrop');
403
+ const panel = document.getElementById('keybinds-panel');
404
+ if (backdrop) {
405
+ backdrop.classList.remove('open');
406
+ setTimeout(() => backdrop.remove(), 200);
407
+ }
408
+ if (panel) {
409
+ panel.classList.remove('open');
410
+ setTimeout(() => panel.remove(), 200);
411
+ }
412
+ }
413
+
414
+ // ── Init ──
415
+
416
+ export async function initKeybinds() {
417
+ // Load server-side overrides
418
+ try {
419
+ const data = await fetchKeybinds();
420
+ if (data && data.bindings) {
421
+ _bindings = { ...DEFAULT_KEYBINDS, ...data.bindings };
422
+ } else {
423
+ _bindings = { ...DEFAULT_KEYBINDS };
424
+ }
425
+ } catch {
426
+ _bindings = { ...DEFAULT_KEYBINDS };
427
+ }
428
+
429
+ _reverseMap = buildReverseMap(_bindings);
430
+
431
+ // Register the "open keybinds modal" action
432
+ registerAction('open-keybinds', openKeybindsModal);
433
+
434
+ // Install single global keydown listener
435
+ document.addEventListener('keydown', centralKeyHandler);
436
+
437
+ // Wire toolbar button
438
+ const btn = document.getElementById('topright-keybinds-btn');
439
+ if (btn) btn.addEventListener('click', openKeybindsModal);
440
+
441
+ // Populate [data-keybind-for] shortcut labels in menus/dropdowns
442
+ syncKeybindLabels();
443
+ }
444
+
445
+ /** Update all [data-keybind-for="actionId"] elements with current keybinds. */
446
+ function syncKeybindLabels() {
447
+ document.querySelectorAll('[data-keybind-for]').forEach(el => {
448
+ const actionId = el.dataset.keybindFor;
449
+ const combo = _bindings[actionId];
450
+ el.textContent = combo ? comboToDisplay(combo) : '';
451
+ });
452
+ }