@sagmans/dsh-tui 0.6.0 → 0.7.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 (250) hide show
  1. package/.agents/skills/dsh-tui-dogfood/SKILL.md +83 -0
  2. package/.agents/skills/dsh-tui-dogfood/references/home-state.md +78 -0
  3. package/.agents/skills/dsh-tui-dogfood/scripts/clone-links.mjs +133 -0
  4. package/.agents/skills/dsh-tui-dogfood/scripts/run-plugin-from-worktree.sh +417 -0
  5. package/.agents/skills/dsh-tui-update-models/SKILL.md +121 -0
  6. package/.agents/skills/dsh-tui-update-models/references/model-wiring.md +162 -0
  7. package/.agents/skills/dsh-tui-update-models/scripts/dump-model-catalog.mjs +178 -0
  8. package/README.md +99 -5
  9. package/lib/agent/present.d.ts.map +1 -1
  10. package/lib/agent/present.js +2 -1
  11. package/lib/agent/present.js.map +1 -1
  12. package/lib/cards/composition.d.ts +68 -0
  13. package/lib/cards/composition.d.ts.map +1 -0
  14. package/lib/cards/composition.js +117 -0
  15. package/lib/cards/composition.js.map +1 -0
  16. package/lib/cards/presenter.d.ts +23 -0
  17. package/lib/cards/presenter.d.ts.map +1 -0
  18. package/lib/cards/presenter.js +394 -0
  19. package/lib/cards/presenter.js.map +1 -0
  20. package/lib/cards/preview.d.ts +42 -0
  21. package/lib/cards/preview.d.ts.map +1 -0
  22. package/lib/cards/preview.js +51 -0
  23. package/lib/cards/preview.js.map +1 -0
  24. package/lib/cards.d.ts +12 -120
  25. package/lib/cards.d.ts.map +1 -1
  26. package/lib/cards.js +4 -556
  27. package/lib/cards.js.map +1 -1
  28. package/lib/compat/section.d.ts +29 -0
  29. package/lib/compat/section.d.ts.map +1 -0
  30. package/lib/compat/section.js +82 -0
  31. package/lib/compat/section.js.map +1 -0
  32. package/lib/config.d.ts +27 -2
  33. package/lib/config.d.ts.map +1 -1
  34. package/lib/config.js +61 -1
  35. package/lib/config.js.map +1 -1
  36. package/lib/contracts.d.ts +28 -0
  37. package/lib/contracts.d.ts.map +1 -1
  38. package/lib/fold-cursor.d.ts +8 -0
  39. package/lib/fold-cursor.d.ts.map +1 -1
  40. package/lib/fold-cursor.js +18 -0
  41. package/lib/fold-cursor.js.map +1 -1
  42. package/lib/gates/question-card.d.ts +77 -0
  43. package/lib/gates/question-card.d.ts.map +1 -0
  44. package/lib/gates/question-card.js +113 -0
  45. package/lib/gates/question-card.js.map +1 -0
  46. package/lib/gates/questions.d.ts +127 -0
  47. package/lib/gates/questions.d.ts.map +1 -0
  48. package/lib/gates/questions.js +418 -0
  49. package/lib/gates/questions.js.map +1 -0
  50. package/lib/gates.d.ts +1 -113
  51. package/lib/gates.d.ts.map +1 -1
  52. package/lib/gates.js +1 -484
  53. package/lib/gates.js.map +1 -1
  54. package/lib/index.d.ts +1 -0
  55. package/lib/index.d.ts.map +1 -1
  56. package/lib/index.js +343 -2602
  57. package/lib/index.js.map +1 -1
  58. package/lib/input/action-catalog.d.ts +74 -0
  59. package/lib/input/action-catalog.d.ts.map +1 -0
  60. package/lib/input/action-catalog.js +137 -0
  61. package/lib/input/action-catalog.js.map +1 -0
  62. package/lib/input/actions.d.ts +5 -81
  63. package/lib/input/actions.d.ts.map +1 -1
  64. package/lib/input/actions.js +4 -625
  65. package/lib/input/actions.js.map +1 -1
  66. package/lib/input/completion.d.ts +1 -1
  67. package/lib/input/completion.d.ts.map +1 -1
  68. package/lib/input/completion.js +2 -1
  69. package/lib/input/completion.js.map +1 -1
  70. package/lib/input/file-index.d.ts +40 -0
  71. package/lib/input/file-index.d.ts.map +1 -0
  72. package/lib/input/file-index.js +141 -0
  73. package/lib/input/file-index.js.map +1 -0
  74. package/lib/input/file-search.d.ts +1 -38
  75. package/lib/input/file-search.d.ts.map +1 -1
  76. package/lib/input/file-search.js +1 -487
  77. package/lib/input/file-search.js.map +1 -1
  78. package/lib/input/key-press.d.ts +57 -0
  79. package/lib/input/key-press.d.ts.map +1 -0
  80. package/lib/input/key-press.js +218 -0
  81. package/lib/input/key-press.js.map +1 -0
  82. package/lib/input/keymap-conflicts.d.ts +59 -0
  83. package/lib/input/keymap-conflicts.d.ts.map +1 -0
  84. package/lib/input/keymap-conflicts.js +292 -0
  85. package/lib/input/keymap-conflicts.js.map +1 -0
  86. package/lib/input/keymap-settings.js +1 -1
  87. package/lib/input/keymap-settings.js.map +1 -1
  88. package/lib/input/keymap.d.ts.map +1 -1
  89. package/lib/input/keymap.js +3 -1
  90. package/lib/input/keymap.js.map +1 -1
  91. package/lib/input/workspace-files.d.ts +10 -0
  92. package/lib/input/workspace-files.d.ts.map +1 -0
  93. package/lib/input/workspace-files.js +359 -0
  94. package/lib/input/workspace-files.js.map +1 -0
  95. package/lib/install-skills.d.ts +20 -0
  96. package/lib/install-skills.d.ts.map +1 -0
  97. package/lib/install-skills.js +198 -0
  98. package/lib/install-skills.js.map +1 -0
  99. package/lib/keys-command.d.ts +2 -1
  100. package/lib/keys-command.d.ts.map +1 -1
  101. package/lib/keys-command.js +2 -1
  102. package/lib/keys-command.js.map +1 -1
  103. package/lib/model-list.d.ts +37 -0
  104. package/lib/model-list.d.ts.map +1 -0
  105. package/lib/model-list.js +60 -0
  106. package/lib/model-list.js.map +1 -0
  107. package/lib/startup.d.ts +8 -0
  108. package/lib/startup.d.ts.map +1 -1
  109. package/lib/startup.js +120 -24
  110. package/lib/startup.js.map +1 -1
  111. package/lib/subagents.d.ts +7 -3
  112. package/lib/subagents.d.ts.map +1 -1
  113. package/lib/subagents.js +28 -4
  114. package/lib/subagents.js.map +1 -1
  115. package/lib/surface/appearance-preferences.d.ts +26 -0
  116. package/lib/surface/appearance-preferences.d.ts.map +1 -0
  117. package/lib/surface/appearance-preferences.js +139 -0
  118. package/lib/surface/appearance-preferences.js.map +1 -0
  119. package/lib/surface/appearance.d.ts +87 -0
  120. package/lib/surface/appearance.d.ts.map +1 -0
  121. package/lib/surface/appearance.js +353 -0
  122. package/lib/surface/appearance.js.map +1 -0
  123. package/lib/surface/background-work.d.ts +49 -0
  124. package/lib/surface/background-work.d.ts.map +1 -0
  125. package/lib/surface/background-work.js +146 -0
  126. package/lib/surface/background-work.js.map +1 -0
  127. package/lib/surface/commands.d.ts +80 -0
  128. package/lib/surface/commands.d.ts.map +1 -0
  129. package/lib/surface/commands.js +364 -0
  130. package/lib/surface/commands.js.map +1 -0
  131. package/lib/surface/modal-input.d.ts +58 -0
  132. package/lib/surface/modal-input.d.ts.map +1 -0
  133. package/lib/surface/modal-input.js +237 -0
  134. package/lib/surface/modal-input.js.map +1 -0
  135. package/lib/surface/model-choice.d.ts +54 -0
  136. package/lib/surface/model-choice.d.ts.map +1 -0
  137. package/lib/surface/model-choice.js +325 -0
  138. package/lib/surface/model-choice.js.map +1 -0
  139. package/lib/surface/preset-choice.d.ts +78 -0
  140. package/lib/surface/preset-choice.d.ts.map +1 -0
  141. package/lib/surface/preset-choice.js +163 -0
  142. package/lib/surface/preset-choice.js.map +1 -0
  143. package/lib/surface/prompt-input.d.ts +97 -0
  144. package/lib/surface/prompt-input.d.ts.map +1 -0
  145. package/lib/surface/prompt-input.js +215 -0
  146. package/lib/surface/prompt-input.js.map +1 -0
  147. package/lib/surface/prompt-memory.d.ts +76 -0
  148. package/lib/surface/prompt-memory.d.ts.map +1 -0
  149. package/lib/surface/prompt-memory.js +167 -0
  150. package/lib/surface/prompt-memory.js.map +1 -0
  151. package/lib/surface/session-lifecycle.d.ts +120 -0
  152. package/lib/surface/session-lifecycle.d.ts.map +1 -0
  153. package/lib/surface/session-lifecycle.js +321 -0
  154. package/lib/surface/session-lifecycle.js.map +1 -0
  155. package/lib/surface/session-picker.d.ts +39 -0
  156. package/lib/surface/session-picker.d.ts.map +1 -0
  157. package/lib/surface/session-picker.js +107 -0
  158. package/lib/surface/session-picker.js.map +1 -0
  159. package/lib/surface/session-view.d.ts +86 -0
  160. package/lib/surface/session-view.d.ts.map +1 -0
  161. package/lib/surface/session-view.js +215 -0
  162. package/lib/surface/session-view.js.map +1 -0
  163. package/lib/surface/staged-turns.d.ts +81 -0
  164. package/lib/surface/staged-turns.d.ts.map +1 -0
  165. package/lib/surface/staged-turns.js +206 -0
  166. package/lib/surface/staged-turns.js.map +1 -0
  167. package/lib/surface/terminal-lifecycle.d.ts +76 -0
  168. package/lib/surface/terminal-lifecycle.d.ts.map +1 -0
  169. package/lib/surface/terminal-lifecycle.js +222 -0
  170. package/lib/surface/terminal-lifecycle.js.map +1 -0
  171. package/lib/terminal-text/scan.d.ts +44 -0
  172. package/lib/terminal-text/scan.d.ts.map +1 -0
  173. package/lib/terminal-text/scan.js +195 -0
  174. package/lib/terminal-text/scan.js.map +1 -0
  175. package/lib/terminal-text/sgr.d.ts +32 -0
  176. package/lib/terminal-text/sgr.d.ts.map +1 -0
  177. package/lib/terminal-text/sgr.js +203 -0
  178. package/lib/terminal-text/sgr.js.map +1 -0
  179. package/lib/terminal-text.d.ts +1 -1
  180. package/lib/terminal-text.d.ts.map +1 -1
  181. package/lib/terminal-text.js +2 -379
  182. package/lib/terminal-text.js.map +1 -1
  183. package/lib/theme-command.js +2 -1
  184. package/lib/theme-command.js.map +1 -1
  185. package/lib/theme-defaults.d.ts +64 -0
  186. package/lib/theme-defaults.d.ts.map +1 -0
  187. package/lib/theme-defaults.js +248 -0
  188. package/lib/theme-defaults.js.map +1 -0
  189. package/lib/theme-resolver.d.ts +36 -0
  190. package/lib/theme-resolver.d.ts.map +1 -0
  191. package/lib/theme-resolver.js +112 -0
  192. package/lib/theme-resolver.js.map +1 -0
  193. package/lib/theme-schema.d.ts.map +1 -1
  194. package/lib/theme-schema.js +3 -2
  195. package/lib/theme-schema.js.map +1 -1
  196. package/lib/theme-settings.d.ts +5 -7
  197. package/lib/theme-settings.d.ts.map +1 -1
  198. package/lib/theme-settings.js +27 -19
  199. package/lib/theme-settings.js.map +1 -1
  200. package/lib/theme-tokens.d.ts +0 -89
  201. package/lib/theme-tokens.d.ts.map +1 -1
  202. package/lib/theme-tokens.js +0 -356
  203. package/lib/theme-tokens.js.map +1 -1
  204. package/lib/theme.d.ts +1 -1
  205. package/lib/theme.d.ts.map +1 -1
  206. package/lib/theme.js +2 -1
  207. package/lib/theme.js.map +1 -1
  208. package/lib/tool-display.d.ts.map +1 -1
  209. package/lib/tool-display.js +2 -1
  210. package/lib/tool-display.js.map +1 -1
  211. package/lib/transcript/message-content.d.ts +21 -0
  212. package/lib/transcript/message-content.d.ts.map +1 -0
  213. package/lib/transcript/message-content.js +61 -0
  214. package/lib/transcript/message-content.js.map +1 -0
  215. package/lib/transcript/tool-calls.d.ts +106 -0
  216. package/lib/transcript/tool-calls.d.ts.map +1 -0
  217. package/lib/transcript/tool-calls.js +254 -0
  218. package/lib/transcript/tool-calls.js.map +1 -0
  219. package/lib/transcript.d.ts +14 -55
  220. package/lib/transcript.d.ts.map +1 -1
  221. package/lib/transcript.js +31 -253
  222. package/lib/transcript.js.map +1 -1
  223. package/lib/ui/dock.d.ts +14 -2
  224. package/lib/ui/dock.d.ts.map +1 -1
  225. package/lib/ui/dock.js +52 -10
  226. package/lib/ui/dock.js.map +1 -1
  227. package/lib/ui/editor.d.ts.map +1 -1
  228. package/lib/ui/editor.js +2 -1
  229. package/lib/ui/editor.js.map +1 -1
  230. package/lib/ui/keymap-picker.d.ts +2 -1
  231. package/lib/ui/keymap-picker.d.ts.map +1 -1
  232. package/lib/ui/keymap-picker.js +1 -0
  233. package/lib/ui/keymap-picker.js.map +1 -1
  234. package/lib/ui/view/gate-card.d.ts +21 -0
  235. package/lib/ui/view/gate-card.d.ts.map +1 -0
  236. package/lib/ui/view/gate-card.js +104 -0
  237. package/lib/ui/view/gate-card.js.map +1 -0
  238. package/lib/ui/view/tool-card.d.ts +138 -0
  239. package/lib/ui/view/tool-card.d.ts.map +1 -0
  240. package/lib/ui/view/tool-card.js +413 -0
  241. package/lib/ui/view/tool-card.js.map +1 -0
  242. package/lib/ui/view/transcript-message.d.ts +62 -0
  243. package/lib/ui/view/transcript-message.d.ts.map +1 -0
  244. package/lib/ui/view/transcript-message.js +158 -0
  245. package/lib/ui/view/transcript-message.js.map +1 -0
  246. package/lib/ui/view.d.ts +80 -220
  247. package/lib/ui/view.d.ts.map +1 -1
  248. package/lib/ui/view.js +81 -675
  249. package/lib/ui/view.js.map +1 -1
  250. package/package.json +3 -1
@@ -1,250 +1,7 @@
1
- import { Key, TUI_KEYBINDINGS, isKittyProtocolActive, matchesKey, setKittyProtocolActive } from '@earendil-works/pi-tui';
2
- /** A key the tty answers before the application sees it. */
3
- export const TERMINAL_OWNED_KEYS = ['ctrl+q'];
4
- /** The key a plain press of Return arrives as. */
5
- export const ENTER_KEY = 'enter';
6
- /**
7
- * The named key a control character is the same press as.
8
- *
9
- * A terminal that reports no modifiers sends the control byte itself, so the
10
- * library matches Ctrl+M where it matches Return and Ctrl+I where it matches
11
- * Tab. Two rows that read one press have to be refused under the same name
12
- * however they spell it, or the reader keeps a binding their terminal answers
13
- * with a different action.
14
- */
15
- const CONTROL_ALIASES = {
16
- 'ctrl+m': ENTER_KEY,
17
- 'ctrl+i': 'tab',
18
- 'ctrl+h': 'backspace',
19
- 'ctrl+[': 'escape',
20
- };
21
- /** The press one key names, folded across the spellings a bare terminal cannot tell apart. */
22
- export function pressOf(key) {
23
- return CONTROL_ALIASES[key] ?? key;
24
- }
25
- /** The modifiers a key id may carry, in the order a canonical id writes them. */
26
- const MODIFIER_ORDER = ['ctrl', 'shift', 'alt', 'super'];
27
- const MODIFIERS = new Set(MODIFIER_ORDER);
28
- /** Spellings the library matches as the same key, folded so they cannot claim two rows. */
29
- const SYNONYMS = { esc: 'escape', return: ENTER_KEY };
30
- /** A bare letter or digit needs no name to be a key. */
31
- const ALPHANUMERIC = /^[a-z0-9]$/u;
32
- /**
33
- * The base keys the library can name, lowercased, mapped to the library's own
34
- * spelling.
35
- *
36
- * Read from the library's own helper object: a key the library adds or renames
37
- * is validated and printed the way the library spells it, with no second table
38
- * to drift.
39
- */
40
- const BASE_KEYS = (() => {
41
- const keys = new Map();
42
- for (const value of Object.values(Key)) {
43
- if (typeof value === 'string')
44
- keys.set(value.toLowerCase(), value);
45
- }
46
- for (const [alias, canonical] of Object.entries(SYNONYMS))
47
- keys.set(alias, canonical);
48
- return keys;
49
- })();
50
- /**
51
- * One written key as the library's own canonical id, or undefined when the
52
- * terminal could never report it as a single press.
53
- *
54
- * Modifiers are folded into one order and the key is folded to the library's
55
- * spelling, because two spellings of one key would otherwise let two actions
56
- * claim the same press.
57
- */
58
- export function normalizeKey(raw) {
59
- const parts = raw.trim().toLowerCase().split('+');
60
- const key = parts.pop();
61
- if (key === undefined || key === '')
62
- return undefined;
63
- const base = BASE_KEYS.get(key);
64
- if (base === undefined && !ALPHANUMERIC.test(key))
65
- return undefined;
66
- const written = [];
67
- const seen = new Set();
68
- for (const part of parts) {
69
- // A repeat is not a chord the terminal reports, so it is a typo rather than a binding.
70
- if (!MODIFIERS.has(part) || seen.has(part))
71
- return undefined;
72
- seen.add(part);
73
- written.push(part);
74
- }
75
- written.sort((left, right) => MODIFIER_ORDER.indexOf(left) - MODIFIER_ORDER.indexOf(right));
76
- return [...written, base ?? key].join('+');
77
- }
78
- const PROMPT_ACTIONS = [
79
- {
80
- id: 'prompt.submit',
81
- layer: 'prompt',
82
- defaultKeys: ['ctrl+enter', 'alt+enter', 'ctrl+s'],
83
- label: 'submit the prompt',
84
- mayUseBare: false,
85
- mayUnbind: false,
86
- },
87
- {
88
- id: 'prompt.newLine',
89
- layer: 'prompt',
90
- defaultKeys: [ENTER_KEY, 'shift+enter', 'ctrl+j'],
91
- label: 'break the line',
92
- mayUseBare: false,
93
- mayUnbind: false,
94
- },
95
- ];
96
- /** The surface's own actions, each named as the handler map names it. */
97
- export const SURFACE_ACTIONS = [
98
- { id: 'surface.toolDetail', name: 'toolDetail', layer: 'surface', defaultKeys: ['ctrl+o'], label: 'tool detail', mayUseBare: false, mayUnbind: false },
99
- { id: 'surface.subCalls', name: 'subCalls', layer: 'surface', defaultKeys: ['ctrl+y'], label: 'nested calls', mayUseBare: false, mayUnbind: false },
100
- { id: 'surface.reasoning', name: 'reasoning', layer: 'surface', defaultKeys: ['shift+tab'], label: 'reasoning', mayUseBare: false, mayUnbind: false },
101
- { id: 'surface.effort', name: 'effort', layer: 'surface', defaultKeys: ['ctrl+t'], label: 'reasoning effort', mayUseBare: false, mayUnbind: false },
102
- { id: 'surface.history', name: 'history', layer: 'surface', defaultKeys: ['ctrl+r'], label: 'search prompt history', mayUseBare: false, mayUnbind: false },
103
- { id: 'surface.back', name: 'back', layer: 'surface', defaultKeys: ['ctrl+b'], label: 'back to this session', mayUseBare: false, mayUnbind: false },
104
- { id: 'surface.interrupt', name: 'interrupt', layer: 'surface', defaultKeys: ['ctrl+c'], label: 'cancel', mayUseBare: false, mayUnbind: false },
105
- { id: 'surface.quit', name: 'quit', layer: 'surface', defaultKeys: ['ctrl+d'], label: 'quit', mayUseBare: false, mayUnbind: false },
106
- ];
107
- const CHORD_ACTIONS = [
108
- { id: 'chord.prefix', layer: 'chord', defaultKeys: ['ctrl+x'], label: 'start a chord', mayUseBare: false, mayUnbind: false, keyShape: 'chord' },
109
- { id: 'chord.model', layer: 'chord', defaultKeys: ['m'], label: 'model', mayUseBare: true, mayUnbind: false },
110
- { id: 'chord.plan', layer: 'chord', defaultKeys: ['p'], label: 'plan mode', mayUseBare: true, mayUnbind: false },
111
- { id: 'chord.copy', layer: 'chord', defaultKeys: ['y'], label: 'copy', mayUseBare: true, mayUnbind: false },
112
- { id: 'chord.stash', layer: 'chord', defaultKeys: ['s'], label: 'stash the draft', mayUseBare: true, mayUnbind: false },
113
- { id: 'chord.stashes', layer: 'chord', defaultKeys: ['l'], label: 'stashed drafts', mayUseBare: true, mayUnbind: false },
114
- { id: 'chord.editor', layer: 'chord', defaultKeys: ['e'], label: 'external editor', mayUseBare: true, mayUnbind: false },
115
- // The map is the one thing a reader needs while their hands are already on the
116
- // keys, so it earns a chord rather than only a command they have to spell.
117
- { id: 'chord.keys', layer: 'chord', defaultKeys: ['?'], label: 'key map', mayUseBare: true, mayUnbind: false },
118
- // Starting over is asked for while the bar still holds a draft, and a typed
119
- // `/new` would have taken that draft's place rather than kept it.
120
- { id: 'chord.new', layer: 'chord', defaultKeys: ['n'], label: 'new session', mayUseBare: true, mayUnbind: false },
121
- // Stepping back through prompts is a composer verb like stash and model, and
122
- // a reader asking for it is already at the bar with their hands on the keys.
123
- { id: 'chord.undo', layer: 'chord', defaultKeys: ['u'], label: 'undo the last prompt', mayUseBare: true, mayUnbind: false },
124
- { id: 'chord.redo', layer: 'chord', defaultKeys: ['r'], label: 'redo the undone prompt', mayUseBare: true, mayUnbind: false },
125
- ];
126
- const GATE_ACTIONS = [
127
- { id: 'gate.allow', layer: 'gate', defaultKeys: ['y'], label: 'allow once', mayUseBare: true, mayUnbind: false },
128
- { id: 'gate.reject', layer: 'gate', defaultKeys: ['n'], label: 'reject', mayUseBare: true, mayUnbind: false },
129
- { id: 'gate.cancel', layer: 'gate', defaultKeys: ['escape', 'ctrl+c'], label: 'cancel', mayUseBare: false, mayUnbind: false },
130
- ];
131
- const QUESTION_ACTIONS = [
132
- { id: 'question.up', layer: 'question', defaultKeys: ['up', 'ctrl+p'], label: 'previous option', mayUseBare: false, mayUnbind: false },
133
- { id: 'question.down', layer: 'question', defaultKeys: ['down', 'ctrl+n'], label: 'next option', mayUseBare: false, mayUnbind: false },
134
- { id: 'question.toggle', layer: 'question', defaultKeys: ['space'], label: 'toggle an option', mayUseBare: false, mayUnbind: false },
135
- { id: 'question.confirm', layer: 'question', defaultKeys: [ENTER_KEY], label: 'answer the question', mayUseBare: false, mayUnbind: false },
136
- { id: 'question.skip', layer: 'question', defaultKeys: ['escape'], label: 'skip this question', mayUseBare: false, mayUnbind: false },
137
- { id: 'question.cancel', layer: 'question', defaultKeys: ['ctrl+c'], label: 'abandon the questions', mayUseBare: false, mayUnbind: false },
138
- ];
139
- const PICKER_ACTIONS = [
140
- { id: 'picker.up', layer: 'picker', defaultKeys: ['up', 'ctrl+p'], label: 'previous row', mayUseBare: false, mayUnbind: false },
141
- { id: 'picker.down', layer: 'picker', defaultKeys: ['down', 'ctrl+n'], label: 'next row', mayUseBare: false, mayUnbind: false },
142
- { id: 'picker.confirm', layer: 'picker', defaultKeys: [ENTER_KEY], label: 'take the row', mayUseBare: false, mayUnbind: false },
143
- { id: 'picker.cancel', layer: 'picker', defaultKeys: ['escape', 'ctrl+c'], label: 'leave the list', mayUseBare: false, mayUnbind: false },
144
- ];
145
- /**
146
- * Library rows this surface adds a key to.
147
- *
148
- * A key that only works when the reader writes it into settings is a key the
149
- * surface can never reach: the transcript search owns the keyboard while it is
150
- * open, so its close row is the one seam through which the cancel key gets
151
- * there. The added row travels with the map, so /keys and the clash guards read
152
- * the key that is really installed rather than the one the library shipped.
153
- *
154
- * The select rows are the same seam for a different list: the completion menu a
155
- * reader navigates belongs to the library's editor, so the navigation aliases
156
- * have to be installed where that menu reads them rather than on a row this
157
- * surface answers.
158
- */
159
- export const LIBRARY_KEY_ADDITIONS = {
160
- 'tui.altScreen.searchClose': ['escape', 'ctrl+c'],
161
- 'tui.select.up': ['up', 'ctrl+p'],
162
- 'tui.select.down': ['down', 'ctrl+n'],
163
- };
164
- /**
165
- * Library ids whose meaning this surface owns.
166
- *
167
- * The prompt bar reads both through its own actions, because it adds a guard
168
- * the library cannot know: Enter is a line break unless a completion menu is
169
- * open, and a terminal that cannot report alt+enter spells it as a sequence the
170
- * library reads as a newline. Writing the library's spelling is refused with a
171
- * pointer at the action that does own it.
172
- */
173
- export const KEYMAP_ALIASES = {
174
- 'tui.input.submit': 'prompt.submit',
175
- 'tui.input.newLine': 'prompt.newLine',
176
- };
177
- /** The library's own actions, read from the library rather than restated. */
178
- function libraryActions() {
179
- return Object.entries(TUI_KEYBINDINGS)
180
- .filter(([id]) => KEYMAP_ALIASES[id] === undefined)
181
- .map(([id, definition]) => {
182
- const defaults = Array.isArray(definition.defaultKeys) ? definition.defaultKeys : [definition.defaultKeys];
183
- return {
184
- id,
185
- layer: 'library',
186
- defaultKeys: defaults.map(key => normalizeKey(key)).filter((key) => key !== undefined),
187
- label: definition.description,
188
- mayUseBare: false,
189
- // Only a key the library ships unbound may be emptied: every other row
190
- // is one the reader would otherwise have to remember a command for.
191
- mayUnbind: defaults.length === 0,
192
- };
193
- });
194
- }
195
- /** Every action a reader may bind. */
196
- export const ACTION_CATALOG = [
197
- ...PROMPT_ACTIONS,
198
- ...SURFACE_ACTIONS,
199
- ...CHORD_ACTIONS,
200
- ...GATE_ACTIONS,
201
- ...QUESTION_ACTIONS,
202
- ...PICKER_ACTIONS,
203
- ...libraryActions(),
204
- ];
205
- /**
206
- * Whether the library can match a key at all.
207
- *
208
- * The library answers no modifier on Escape or on a function key, because no
209
- * terminal reports those, and no modifier beyond plain, shift, and control on
210
- * Clear. A binding there would be a key the reader could never press.
211
- */
212
- function isPressable(key) {
213
- const parts = key.split('+');
214
- const base = parts.pop() ?? '';
215
- const count = parts.length;
216
- if (base === 'escape' && count > 0)
217
- return false;
218
- if (/^f([1-9]|1[0-2])$/u.test(base) && count > 0)
219
- return false;
220
- if (base === 'clear' && (count > 1 || parts.includes('alt') || parts.includes('super')))
221
- return false;
222
- return true;
223
- }
224
- /** Whether a key is one modifier chord on one letter or digit, the only shape a chord starter may take. */
225
- function isSimpleChord(key) {
226
- const base = key.split('+').pop() ?? '';
227
- return key.includes('+') && /^[a-z0-9]$/u.test(base);
228
- }
229
- /**
230
- * Whether a key is a character the reader types, which only an armed layer may take.
231
- *
232
- * Shift is not a way out of this: a terminal reports a capital letter as the
233
- * shifted letter, so shift+c types a C wherever c does.
234
- */
235
- function isBareCharacter(key) {
236
- const bare = key.startsWith('shift+') ? key.slice('shift+'.length) : key;
237
- if (bare.includes('+'))
238
- return false;
239
- return bare === 'space' || (bare.length === 1 && bare >= ' ');
240
- }
241
- function actionOf(id) {
242
- return ACTION_CATALOG.find(entry => entry.id === id);
243
- }
244
- /** The keys one action ships with, this surface's own additions to a library row included. */
245
- function shippedKeys(action) {
246
- return LIBRARY_KEY_ADDITIONS[action.id] ?? action.defaultKeys;
247
- }
1
+ import { SURFACE_ACTIONS, LIBRARY_KEY_ADDITIONS, KEYMAP_ALIASES, ACTION_CATALOG, actionOf, shippedKeys } from "./action-catalog.js";
2
+ import { TERMINAL_OWNED_KEYS, normalizeKey, isPressable, isSimpleChord, isBareCharacter } from "./key-press.js";
3
+ import { refuseMatcherClashes, refusePromptClashes, refusePrefixTakingKeys, refuseLibraryClashes, refuseViewportTakingKeys } from "./keymap-conflicts.js";
4
+ import { matchesKey } from '@earendil-works/pi-tui';
248
5
  /** The keys in force for one action, or nothing when no action has that id. */
249
6
  export function keysFor(map, id) {
250
7
  return map.effective[id] ?? [];
@@ -328,384 +85,6 @@ function readKeys(action, value) {
328
85
  }
329
86
  return keys;
330
87
  }
331
- /** How many single-byte presses a probe walks: every byte a terminal can send on its own. */
332
- const PROBE_BYTES = 0x80;
333
- /**
334
- * The sequences one press can arrive as, so an overlap is read from the matcher.
335
- *
336
- * The library is the only authority on which sequences are one press, and its
337
- * reading is looser than a spelling table could say: a bare terminal reports
338
- * Return for both Enter and Ctrl+M, one control byte carries Ctrl+- and Ctrl+_
339
- * alike, and escape-prefixed bytes reach more than one alt key — an escape and a
340
- * letter answers Alt+Up as well as Alt+P. Both protocol modes are read over the
341
- * same bytes, because a terminal without the protocol folds spellings together
342
- * that one with it keeps apart. The answer is cached: which sequences reach a key
343
- * is a property of the key rather than of the map.
344
- */
345
- const PRESS_PROBES = Array.from({ length: PROBE_BYTES }, (_, code) => String.fromCharCode(code)).flatMap(byte => [byte, `\u001b${byte}`]);
346
- /** One sequence named so two rows can agree on it without carrying the bytes. */
347
- function spellingOf(sequence) {
348
- return `seq:${[...sequence].map(character => character.charCodeAt(0).toString(16)).join('-')}`;
349
- }
350
- const pressSpellings = new Map();
351
- /** The sequences that reach a key in either protocol mode, or its own id when none does. */
352
- function matchPresses(key) {
353
- const cached = pressSpellings.get(key);
354
- if (cached !== undefined)
355
- return cached;
356
- const spelled = new Set();
357
- const wasKitty = isKittyProtocolActive();
358
- try {
359
- for (const kitty of [false, true]) {
360
- setKittyProtocolActive(kitty);
361
- for (const probe of PRESS_PROBES) {
362
- if (matchesKey(probe, key))
363
- spelled.add(spellingOf(probe));
364
- }
365
- }
366
- }
367
- finally {
368
- setKittyProtocolActive(wasKitty);
369
- }
370
- const answer = spelled.size === 0 ? [`key:${key}`] : [...spelled];
371
- pressSpellings.set(key, answer);
372
- return answer;
373
- }
374
- /**
375
- * Group the rows one terminal sequence reaches, so a group is a press that cannot
376
- * be split between them.
377
- */
378
- function pressOverlaps(rows) {
379
- const rowAt = (index) => rows[index];
380
- const reached = new Map();
381
- rows.forEach((row, index) => {
382
- for (const spelling of matchPresses(row.key)) {
383
- const found = reached.get(spelling) ?? [];
384
- if (!found.includes(index))
385
- found.push(index);
386
- reached.set(spelling, found);
387
- }
388
- });
389
- const parent = rows.map((_, index) => index);
390
- const find = (index) => {
391
- const kept = parent[index] ?? index;
392
- if (kept === index)
393
- return index;
394
- const root = find(kept);
395
- parent[index] = root;
396
- return root;
397
- };
398
- for (const indexes of reached.values()) {
399
- for (const index of indexes.slice(1)) {
400
- const left = find(indexes[0]);
401
- const right = find(index);
402
- if (left !== right)
403
- parent[right] = left;
404
- }
405
- }
406
- const groups = new Map();
407
- rows.forEach((_, index) => {
408
- const root = find(index);
409
- const found = groups.get(root) ?? [];
410
- found.push(index);
411
- groups.set(root, found);
412
- });
413
- const overlaps = [];
414
- for (const found of groups.values()) {
415
- const ids = [...new Set(found.map(index => rowAt(index).id))].sort();
416
- if (ids.length < 2)
417
- continue;
418
- // A spelling counts as shared only when two different rows read it: one row
419
- // may hold two spellings of the same press (Return beside Ctrl+M), which is
420
- // the reader saying one thing rather than a press two rows fight over.
421
- const spellings = [...new Set(found.flatMap(index => matchPresses(rowAt(index).key)))]
422
- .filter(spelling => new Set((reached.get(spelling) ?? []).filter(index => found.includes(index)).map(index => rowAt(index).id)).size > 1)
423
- .sort();
424
- overlaps.push({ ids, key: rowAt(found[0]).key, spellings });
425
- }
426
- return overlaps;
427
- }
428
- /**
429
- * Refuse two actions of one layer a terminal cannot tell apart.
430
- *
431
- * Layers are checked apart because sharing across them is the design: the chord
432
- * layer takes a key before the surface answers it, and the surface takes one
433
- * before the library's editor sees it. Within one layer a shared press is an
434
- * action the reader could never reach — and the surface answers these layers
435
- * itself, first row first, so two rows sharing one byte is one row that loses.
436
- */
437
- function refuseMatcherClashes(effective) {
438
- for (const layer of ['surface', 'chord', 'gate', 'question', 'picker']) {
439
- const rows = [];
440
- for (const action of ACTION_CATALOG) {
441
- if (action.layer !== layer)
442
- continue;
443
- for (const key of effective[action.id] ?? [])
444
- rows.push({ id: action.id, key });
445
- }
446
- const overlap = pressOverlaps(rows)[0];
447
- if (overlap !== undefined) {
448
- throw new Error(`key "${overlap.key}" is bound to both ${overlap.ids.join(' and ')}`);
449
- }
450
- }
451
- }
452
- /**
453
- * Refuse two prompt rows claiming one press.
454
- *
455
- * The bar is the one layer the library's matcher does not decide: Return is
456
- * answered in the bar itself, which reads a line feed as send when the reader
457
- * bound it and as a line otherwise. Its rows are therefore compared by the
458
- * press they arrive as rather than by every byte the matcher folds, so moving
459
- * send onto Ctrl+J stays possible.
460
- */
461
- function refusePromptClashes(effective) {
462
- const owner = new Map();
463
- for (const action of ACTION_CATALOG) {
464
- if (action.layer !== 'prompt')
465
- continue;
466
- for (const key of effective[action.id] ?? []) {
467
- const press = pressOf(key);
468
- const taken = owner.get(press);
469
- if (taken !== undefined && taken !== action.id) {
470
- throw new Error(`key "${key}" is bound to both ${taken} and ${action.id}`);
471
- }
472
- owner.set(press, action.id);
473
- }
474
- }
475
- }
476
- /**
477
- * Refuse a chord starter that would take a key the surface already answers.
478
- *
479
- * The prefix is consumed before anything else looks at the press, so a starter
480
- * that is also a surface key would not shadow that action while a chord is
481
- * armed: it would remove it for the whole session.
482
- */
483
- function refusePrefixTakingKeys(effective) {
484
- const owners = ACTION_CATALOG.filter(action => action.layer === 'surface' || action.layer === 'prompt');
485
- for (const key of effective['chord.prefix'] ?? []) {
486
- for (const owner of owners) {
487
- // A prefix is consumed before anything else looks at the press, so it takes
488
- // the key from a row that reads the same press under another spelling too.
489
- if ((effective[owner.id] ?? []).some(owned => pressOf(owned) === pressOf(key))) {
490
- throw new Error(`key "${key}" as chord.prefix would take it from ${owner.id}`);
491
- }
492
- }
493
- }
494
- }
495
- /**
496
- * The rows the library reads, as one map.
497
- *
498
- * Every library action appears, wherever the key came from, because a moved row
499
- * and an untouched default are read by the same matcher. The bar's two actions
500
- * are here as well: they are installed even when the reader never wrote them, so
501
- * they share the keyboard with the library's own rows.
502
- */
503
- function libraryRows(effective) {
504
- const rows = {};
505
- for (const action of ACTION_CATALOG) {
506
- if (action.layer === 'library')
507
- rows[action.id] = [...(effective[action.id] ?? [])];
508
- }
509
- rows['tui.input.submit'] = [...(effective['prompt.submit'] ?? [])];
510
- // Enter is left out of the installation for one reason and kept here for the
511
- // opposite one: the library reads that press as a line break before it looks
512
- // for this row, so a library row that took Return would be the row that never
513
- // runs while the bar still answers it — as a line, or as send.
514
- const line = effective['prompt.newLine'] ?? [];
515
- rows['tui.input.newLine'] = line.includes(ENTER_KEY) ? [...line] : line.filter(key => key !== ENTER_KEY);
516
- return rows;
517
- }
518
- /** The same rows as they read before the reader wrote anything. */
519
- function shippedLibraryRows() {
520
- const defaults = new Map(ACTION_CATALOG.map(action => [action.id, action.defaultKeys]));
521
- const rows = {};
522
- for (const action of ACTION_CATALOG) {
523
- if (action.layer === 'library')
524
- rows[action.id] = [...shippedKeys(action)];
525
- }
526
- rows['tui.input.submit'] = [...(defaults.get('prompt.submit') ?? [])];
527
- rows['tui.input.newLine'] = [...(defaults.get('prompt.newLine') ?? [])];
528
- return rows;
529
- }
530
- /**
531
- * The component that reads a library row.
532
- *
533
- * Two rows one component reads can fight over a press. Rows of different
534
- * components meet only where the library already ships the overlap, such as the
535
- * viewport's page keys shadowing the editor's, so those are its business rather
536
- * than a clash to refuse.
537
- */
538
- function libraryComponent(id) {
539
- if (id.startsWith('tui.editor.') || id.startsWith('tui.input.'))
540
- return 'editor';
541
- if (id.startsWith('tui.select.'))
542
- return 'select';
543
- if (id.startsWith('tui.altScreen.'))
544
- return 'viewport';
545
- return 'other';
546
- }
547
- /** Every press one component reads on more than one row, with the rows that read it. */
548
- function sharedPresses(rows) {
549
- const owners = new Map();
550
- for (const [id, keys] of Object.entries(rows)) {
551
- const component = libraryComponent(id);
552
- for (const key of keys) {
553
- const press = pressOf(key);
554
- const byComponent = owners.get(press) ?? new Map();
555
- const found = byComponent.get(component) ?? [];
556
- if (!found.includes(id))
557
- found.push(id);
558
- byComponent.set(component, found);
559
- owners.set(press, byComponent);
560
- }
561
- }
562
- const shared = new Map();
563
- for (const [press, byComponent] of owners) {
564
- for (const ids of byComponent.values()) {
565
- if (ids.length > 1)
566
- shared.set(press, [...ids].sort());
567
- }
568
- }
569
- return shared;
570
- }
571
- /**
572
- * The overlaps the library's own rows carry, one component at a time.
573
- *
574
- * The bar's own two rows are left out: they are read by the bar before the
575
- * library's matcher runs, so a line feed is send when the reader bound it and a
576
- * line otherwise, and comparing them by every sequence the matcher folds would
577
- * forbid a choice the bar can honour.
578
- */
579
- function libraryOverlaps(rows) {
580
- const overlaps = [];
581
- for (const component of ['editor', 'select', 'viewport', 'other']) {
582
- const own = [];
583
- for (const [id, keys] of Object.entries(rows)) {
584
- if (id === 'tui.input.submit' || id === 'tui.input.newLine')
585
- continue;
586
- if (libraryComponent(id) !== component)
587
- continue;
588
- for (const key of keys)
589
- own.push({ id, key });
590
- }
591
- overlaps.push(...pressOverlaps(own));
592
- }
593
- return overlaps;
594
- }
595
- /** What makes two overlaps the same fight: the rows, and the sequences they share. */
596
- function overlapIdentity(overlap) {
597
- return `${overlap.ids.join(',')}\u0000${overlap.spellings.join('|')}`;
598
- }
599
- /**
600
- * Refuse two rows the library reads on one press.
601
- *
602
- * The library's own manager reports a clash between rows the reader wrote and
603
- * nothing else, which would let a new binding quietly take a key from a row the
604
- * reader never touched. Rows are compared twice: by the press they were written
605
- * as, which is what the bar's own rows are matched by, and by every sequence the
606
- * matcher folds, which is what catches one control byte carrying two spellings.
607
- * The overlaps the library itself ships are left alone: those are rows it
608
- * already knows how to tell apart.
609
- */
610
- function refuseLibraryClashes(effective) {
611
- const shipped = new Set([...sharedPresses(shippedLibraryRows())].map(([press, ids]) => `${press}\u0000${ids.join(',')}`));
612
- for (const [press, ids] of sharedPresses(libraryRows(effective))) {
613
- if (shipped.has(`${press}\u0000${ids.join(',')}`))
614
- continue;
615
- throw new Error(`key "${press}" is bound to both ${ids.join(' and ')}; move one of them or pick another key`);
616
- }
617
- const shippedOverlaps = new Set(libraryOverlaps(shippedLibraryRows()).map(overlapIdentity));
618
- for (const overlap of libraryOverlaps(libraryRows(effective))) {
619
- if (shippedOverlaps.has(overlapIdentity(overlap)))
620
- continue;
621
- throw new Error(`key "${overlap.key}" is bound to both ${overlap.ids.join(' and ')}; move one of them or pick another key`);
622
- }
623
- }
624
- /**
625
- * The library rows that read a press before the surface's own listener runs.
626
- *
627
- * The alternate screen registers its viewport listener while the terminal is
628
- * built, so its keys arrive before a listener the surface adds later. These rows
629
- * are read without asking whether an overlay holds the keyboard; the search
630
- * overlay's own next, previous, and close keys are left out because they wait
631
- * for it and would let an overlay-scoped surface key through.
632
- */
633
- const VIEWPORT_FIRST_ROWS = [
634
- 'tui.altScreen.search',
635
- 'tui.altScreen.pageUp',
636
- 'tui.altScreen.pageDown',
637
- 'tui.altScreen.halfPageUp',
638
- 'tui.altScreen.halfPageDown',
639
- 'tui.altScreen.lineUp',
640
- 'tui.altScreen.lineDown',
641
- 'tui.altScreen.previousPrompt',
642
- 'tui.altScreen.nextPrompt',
643
- 'tui.altScreen.top',
644
- 'tui.altScreen.bottom',
645
- ];
646
- /** Every row a press reaches, with the keys in force: the catalog and the bar's own library rows. */
647
- function dispatchRows(effective) {
648
- const rows = {};
649
- for (const action of ACTION_CATALOG)
650
- rows[action.id] = [...(effective[action.id] ?? [])];
651
- rows['tui.input.submit'] = [...(effective['prompt.submit'] ?? [])];
652
- rows['tui.input.newLine'] = (effective['prompt.newLine'] ?? []).filter(key => key !== ENTER_KEY);
653
- return rows;
654
- }
655
- /** The same rows as they read before the reader wrote anything. */
656
- function shippedRows() {
657
- const rows = {};
658
- for (const action of ACTION_CATALOG)
659
- rows[action.id] = [...shippedKeys(action)];
660
- rows['tui.input.submit'] = [...(actionOf('prompt.submit')?.defaultKeys ?? [])];
661
- rows['tui.input.newLine'] = [...(actionOf('prompt.newLine')?.defaultKeys ?? [])].filter(key => key !== ENTER_KEY);
662
- return rows;
663
- }
664
- function viewportPairs(rows) {
665
- const table = [];
666
- for (const [id, keys] of Object.entries(rows)) {
667
- for (const key of keys)
668
- table.push({ id, key });
669
- }
670
- const pairs = [];
671
- for (const overlap of pressOverlaps(table)) {
672
- const viewport = overlap.ids.filter(id => VIEWPORT_FIRST_ROWS.includes(id));
673
- for (const row of viewport) {
674
- for (const other of overlap.ids.filter(id => id !== row && !VIEWPORT_FIRST_ROWS.includes(id))) {
675
- pairs.push({ key: overlap.key, spellings: overlap.spellings, viewport: row, other });
676
- }
677
- }
678
- }
679
- return pairs;
680
- }
681
- /**
682
- * Refuse a pair the viewport answers before the row it was written on.
683
- *
684
- * A key the alternate screen's listener reads never reaches the surface: the
685
- * press scrolls or searches instead, and the binding only looks alive while an
686
- * overlay defers the viewport. Either side of the pair can be the one the
687
- * reader wrote, and both are a binding that does nothing, so both are refused
688
- * apart from the overlap the library already ships.
689
- */
690
- function refuseViewportTakingKeys(effective, written) {
691
- const pairKey = (pair) => `${pair.viewport}\u0000${pair.other}\u0000${pair.spellings.join('|')}`;
692
- const shipped = new Set(viewportPairs(shippedRows()).map(pairKey));
693
- // A prompt row is read through a library row of the bar's own, so the reader's
694
- // name for the row is the action they wrote rather than the mirror.
695
- const isWritten = (id) => written.has(id) ||
696
- (id === 'tui.input.submit' && written.has('prompt.submit')) ||
697
- (id === 'tui.input.newLine' && written.has('prompt.newLine'));
698
- for (const pair of viewportPairs(dispatchRows(effective))) {
699
- if (shipped.has(pairKey(pair)))
700
- continue;
701
- if (isWritten(pair.other)) {
702
- throw new Error(`key "${pair.key}" on ${pair.other} is read by ${pair.viewport} first; move that row or pick another key`);
703
- }
704
- if (isWritten(pair.viewport)) {
705
- throw new Error(`key "${pair.key}" on ${pair.viewport} would take it from ${pair.other}, which never sees the press; pick another key`);
706
- }
707
- }
708
- }
709
88
  /**
710
89
  * The reader's map over the shipped one.
711
90
  *