@sagmans/dsh-tui 0.1.2 → 0.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 (158) hide show
  1. package/README.md +196 -17
  2. package/cordis.patch.yml +4 -2
  3. package/lib/agent/host.d.ts +14 -0
  4. package/lib/agent/host.d.ts.map +1 -1
  5. package/lib/agent/host.js +20 -4
  6. package/lib/agent/host.js.map +1 -1
  7. package/lib/agent/model.d.ts +50 -0
  8. package/lib/agent/model.d.ts.map +1 -1
  9. package/lib/agent/model.js +87 -1
  10. package/lib/agent/model.js.map +1 -1
  11. package/lib/agent/present.d.ts +7 -1
  12. package/lib/agent/present.d.ts.map +1 -1
  13. package/lib/agent/present.js +33 -4
  14. package/lib/agent/present.js.map +1 -1
  15. package/lib/agent/presets.d.ts +7 -1
  16. package/lib/agent/presets.d.ts.map +1 -1
  17. package/lib/agent/presets.js +7 -3
  18. package/lib/agent/presets.js.map +1 -1
  19. package/lib/agent/status.d.ts +10 -0
  20. package/lib/agent/status.d.ts.map +1 -1
  21. package/lib/agent/status.js +2 -0
  22. package/lib/agent/status.js.map +1 -1
  23. package/lib/cards.d.ts +127 -7
  24. package/lib/cards.d.ts.map +1 -1
  25. package/lib/cards.js +381 -52
  26. package/lib/cards.js.map +1 -1
  27. package/lib/export.d.ts +5 -4
  28. package/lib/export.d.ts.map +1 -1
  29. package/lib/export.js +62 -13
  30. package/lib/export.js.map +1 -1
  31. package/lib/fold-cursor.d.ts +23 -0
  32. package/lib/fold-cursor.d.ts.map +1 -0
  33. package/lib/fold-cursor.js +31 -0
  34. package/lib/fold-cursor.js.map +1 -0
  35. package/lib/gates.d.ts +145 -2
  36. package/lib/gates.d.ts.map +1 -1
  37. package/lib/gates.js +335 -40
  38. package/lib/gates.js.map +1 -1
  39. package/lib/index.d.ts +7 -1
  40. package/lib/index.d.ts.map +1 -1
  41. package/lib/index.js +651 -92
  42. package/lib/index.js.map +1 -1
  43. package/lib/input/actions.d.ts +136 -0
  44. package/lib/input/actions.d.ts.map +1 -0
  45. package/lib/input/actions.js +740 -0
  46. package/lib/input/actions.js.map +1 -0
  47. package/lib/input/keymap-settings.d.ts +21 -0
  48. package/lib/input/keymap-settings.d.ts.map +1 -0
  49. package/lib/input/keymap-settings.js +28 -0
  50. package/lib/input/keymap-settings.js.map +1 -0
  51. package/lib/input/keymap.d.ts +91 -0
  52. package/lib/input/keymap.d.ts.map +1 -0
  53. package/lib/input/keymap.js +170 -0
  54. package/lib/input/keymap.js.map +1 -0
  55. package/lib/input/match.d.ts +10 -0
  56. package/lib/input/match.d.ts.map +1 -0
  57. package/lib/input/match.js +83 -0
  58. package/lib/input/match.js.map +1 -0
  59. package/lib/input/submission.d.ts +8 -1
  60. package/lib/input/submission.d.ts.map +1 -1
  61. package/lib/input/submission.js +9 -2
  62. package/lib/input/submission.js.map +1 -1
  63. package/lib/input.d.ts +17 -0
  64. package/lib/input.d.ts.map +1 -0
  65. package/lib/input.js +27 -0
  66. package/lib/input.js.map +1 -0
  67. package/lib/keys-command.d.ts +16 -0
  68. package/lib/keys-command.d.ts.map +1 -0
  69. package/lib/keys-command.js +60 -0
  70. package/lib/keys-command.js.map +1 -0
  71. package/lib/queue.d.ts +6 -0
  72. package/lib/queue.d.ts.map +1 -0
  73. package/lib/queue.js +68 -0
  74. package/lib/queue.js.map +1 -0
  75. package/lib/settings-notice.d.ts +19 -0
  76. package/lib/settings-notice.d.ts.map +1 -0
  77. package/lib/settings-notice.js +30 -0
  78. package/lib/settings-notice.js.map +1 -0
  79. package/lib/terminal/warning-screen.d.ts +8 -0
  80. package/lib/terminal/warning-screen.d.ts.map +1 -0
  81. package/lib/terminal/warning-screen.js +66 -0
  82. package/lib/terminal/warning-screen.js.map +1 -0
  83. package/lib/theme-capability.d.ts +36 -0
  84. package/lib/theme-capability.d.ts.map +1 -0
  85. package/lib/theme-capability.js +190 -0
  86. package/lib/theme-capability.js.map +1 -0
  87. package/lib/theme-command.d.ts +10 -0
  88. package/lib/theme-command.d.ts.map +1 -0
  89. package/lib/theme-command.js +61 -0
  90. package/lib/theme-command.js.map +1 -0
  91. package/lib/theme-settings.d.ts +196 -0
  92. package/lib/theme-settings.d.ts.map +1 -0
  93. package/lib/theme-settings.js +229 -0
  94. package/lib/theme-settings.js.map +1 -0
  95. package/lib/theme-tokens.d.ts +110 -0
  96. package/lib/theme-tokens.d.ts.map +1 -0
  97. package/lib/theme-tokens.js +427 -0
  98. package/lib/theme-tokens.js.map +1 -0
  99. package/lib/theme.d.ts +32 -35
  100. package/lib/theme.d.ts.map +1 -1
  101. package/lib/theme.js +112 -63
  102. package/lib/theme.js.map +1 -1
  103. package/lib/tokens.d.ts +15 -0
  104. package/lib/tokens.d.ts.map +1 -0
  105. package/lib/tokens.js +29 -0
  106. package/lib/tokens.js.map +1 -0
  107. package/lib/transcript.d.ts +26 -1
  108. package/lib/transcript.d.ts.map +1 -1
  109. package/lib/transcript.js +153 -25
  110. package/lib/transcript.js.map +1 -1
  111. package/lib/ui/dock.d.ts.map +1 -1
  112. package/lib/ui/dock.js +67 -28
  113. package/lib/ui/dock.js.map +1 -1
  114. package/lib/ui/editor.d.ts +51 -0
  115. package/lib/ui/editor.d.ts.map +1 -0
  116. package/lib/ui/editor.js +159 -0
  117. package/lib/ui/editor.js.map +1 -0
  118. package/lib/ui/frame.d.ts +54 -0
  119. package/lib/ui/frame.d.ts.map +1 -0
  120. package/lib/ui/frame.js +79 -0
  121. package/lib/ui/frame.js.map +1 -0
  122. package/lib/ui/gate-input.d.ts +20 -0
  123. package/lib/ui/gate-input.d.ts.map +1 -0
  124. package/lib/ui/gate-input.js +96 -0
  125. package/lib/ui/gate-input.js.map +1 -0
  126. package/lib/ui/markdown.d.ts +19 -2
  127. package/lib/ui/markdown.d.ts.map +1 -1
  128. package/lib/ui/markdown.js +37 -6
  129. package/lib/ui/markdown.js.map +1 -1
  130. package/lib/ui/mermaid.d.ts +44 -0
  131. package/lib/ui/mermaid.d.ts.map +1 -0
  132. package/lib/ui/mermaid.js +178 -0
  133. package/lib/ui/mermaid.js.map +1 -0
  134. package/lib/ui/picker.d.ts +66 -7
  135. package/lib/ui/picker.d.ts.map +1 -1
  136. package/lib/ui/picker.js +112 -19
  137. package/lib/ui/picker.js.map +1 -1
  138. package/lib/ui/prompt.d.ts +30 -0
  139. package/lib/ui/prompt.d.ts.map +1 -0
  140. package/lib/ui/prompt.js +45 -0
  141. package/lib/ui/prompt.js.map +1 -0
  142. package/lib/ui/queue.d.ts +24 -0
  143. package/lib/ui/queue.d.ts.map +1 -0
  144. package/lib/ui/queue.js +57 -0
  145. package/lib/ui/queue.js.map +1 -0
  146. package/lib/ui/status.d.ts +7 -1
  147. package/lib/ui/status.d.ts.map +1 -1
  148. package/lib/ui/status.js +84 -24
  149. package/lib/ui/status.js.map +1 -1
  150. package/lib/ui/view.d.ts +65 -2
  151. package/lib/ui/view.d.ts.map +1 -1
  152. package/lib/ui/view.js +319 -59
  153. package/lib/ui/view.js.map +1 -1
  154. package/lib/work.d.ts +48 -0
  155. package/lib/work.d.ts.map +1 -1
  156. package/lib/work.js +44 -0
  157. package/lib/work.js.map +1 -1
  158. package/package.json +5 -2
@@ -0,0 +1,740 @@
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.back', name: 'back', layer: 'surface', defaultKeys: ['ctrl+b'], label: 'back to this session', mayUseBare: false, mayUnbind: false },
103
+ { id: 'surface.interrupt', name: 'interrupt', layer: 'surface', defaultKeys: ['ctrl+c'], label: 'interrupt or exit', mayUseBare: false, mayUnbind: false },
104
+ ];
105
+ const CHORD_ACTIONS = [
106
+ { id: 'chord.prefix', layer: 'chord', defaultKeys: ['ctrl+x'], label: 'start a chord', mayUseBare: false, mayUnbind: false, keyShape: 'chord' },
107
+ { id: 'chord.model', layer: 'chord', defaultKeys: ['m'], label: 'model', mayUseBare: true, mayUnbind: false },
108
+ { id: 'chord.plan', layer: 'chord', defaultKeys: ['p'], label: 'plan mode', mayUseBare: true, mayUnbind: false },
109
+ { id: 'chord.copy', layer: 'chord', defaultKeys: ['y'], label: 'copy', mayUseBare: true, mayUnbind: false },
110
+ ];
111
+ const GATE_ACTIONS = [
112
+ { id: 'gate.allow', layer: 'gate', defaultKeys: ['y'], label: 'allow once', mayUseBare: true, mayUnbind: false },
113
+ { id: 'gate.reject', layer: 'gate', defaultKeys: ['n'], label: 'reject', mayUseBare: true, mayUnbind: false },
114
+ { id: 'gate.cancel', layer: 'gate', defaultKeys: ['escape'], label: 'cancel', mayUseBare: false, mayUnbind: false },
115
+ ];
116
+ const QUESTION_ACTIONS = [
117
+ { id: 'question.up', layer: 'question', defaultKeys: ['up'], label: 'previous option', mayUseBare: false, mayUnbind: false },
118
+ { id: 'question.down', layer: 'question', defaultKeys: ['down'], label: 'next option', mayUseBare: false, mayUnbind: false },
119
+ { id: 'question.toggle', layer: 'question', defaultKeys: ['space'], label: 'toggle an option', mayUseBare: false, mayUnbind: false },
120
+ { id: 'question.confirm', layer: 'question', defaultKeys: [ENTER_KEY], label: 'answer the question', mayUseBare: false, mayUnbind: false },
121
+ { id: 'question.skip', layer: 'question', defaultKeys: ['escape'], label: 'skip this question', mayUseBare: false, mayUnbind: false },
122
+ ];
123
+ const PICKER_ACTIONS = [
124
+ { id: 'picker.up', layer: 'picker', defaultKeys: ['up'], label: 'previous row', mayUseBare: false, mayUnbind: false },
125
+ { id: 'picker.down', layer: 'picker', defaultKeys: ['down'], label: 'next row', mayUseBare: false, mayUnbind: false },
126
+ { id: 'picker.confirm', layer: 'picker', defaultKeys: [ENTER_KEY], label: 'take the row', mayUseBare: false, mayUnbind: false },
127
+ { id: 'picker.cancel', layer: 'picker', defaultKeys: ['escape', 'ctrl+c'], label: 'leave the list', mayUseBare: false, mayUnbind: false },
128
+ ];
129
+ /**
130
+ * Library ids whose meaning this surface owns.
131
+ *
132
+ * The prompt bar reads both through its own actions, because it adds a guard
133
+ * the library cannot know: Enter is a line break unless a completion menu is
134
+ * open, and a terminal that cannot report alt+enter spells it as a sequence the
135
+ * library reads as a newline. Writing the library's spelling is refused with a
136
+ * pointer at the action that does own it.
137
+ */
138
+ export const KEYMAP_ALIASES = {
139
+ 'tui.input.submit': 'prompt.submit',
140
+ 'tui.input.newLine': 'prompt.newLine',
141
+ };
142
+ /** The library's own actions, read from the library rather than restated. */
143
+ function libraryActions() {
144
+ return Object.entries(TUI_KEYBINDINGS)
145
+ .filter(([id]) => KEYMAP_ALIASES[id] === undefined)
146
+ .map(([id, definition]) => {
147
+ const defaults = Array.isArray(definition.defaultKeys) ? definition.defaultKeys : [definition.defaultKeys];
148
+ return {
149
+ id,
150
+ layer: 'library',
151
+ defaultKeys: defaults.map(key => normalizeKey(key)).filter((key) => key !== undefined),
152
+ label: definition.description,
153
+ mayUseBare: false,
154
+ // Only a key the library ships unbound may be emptied: every other row
155
+ // is one the reader would otherwise have to remember a command for.
156
+ mayUnbind: defaults.length === 0,
157
+ };
158
+ });
159
+ }
160
+ /** Every action a reader may bind. */
161
+ export const ACTION_CATALOG = [
162
+ ...PROMPT_ACTIONS,
163
+ ...SURFACE_ACTIONS,
164
+ ...CHORD_ACTIONS,
165
+ ...GATE_ACTIONS,
166
+ ...QUESTION_ACTIONS,
167
+ ...PICKER_ACTIONS,
168
+ ...libraryActions(),
169
+ ];
170
+ /**
171
+ * Whether the library can match a key at all.
172
+ *
173
+ * The library answers no modifier on Escape or on a function key, because no
174
+ * terminal reports those, and no modifier beyond plain, shift, and control on
175
+ * Clear. A binding there would be a key the reader could never press.
176
+ */
177
+ function isPressable(key) {
178
+ const parts = key.split('+');
179
+ const base = parts.pop() ?? '';
180
+ const count = parts.length;
181
+ if (base === 'escape' && count > 0)
182
+ return false;
183
+ if (/^f([1-9]|1[0-2])$/u.test(base) && count > 0)
184
+ return false;
185
+ if (base === 'clear' && (count > 1 || parts.includes('alt') || parts.includes('super')))
186
+ return false;
187
+ return true;
188
+ }
189
+ /** Whether a key is one modifier chord on one letter or digit, the only shape a chord starter may take. */
190
+ function isSimpleChord(key) {
191
+ const base = key.split('+').pop() ?? '';
192
+ return key.includes('+') && /^[a-z0-9]$/u.test(base);
193
+ }
194
+ /**
195
+ * Whether a key is a character the reader types, which only an armed layer may take.
196
+ *
197
+ * Shift is not a way out of this: a terminal reports a capital letter as the
198
+ * shifted letter, so shift+c types a C wherever c does.
199
+ */
200
+ function isBareCharacter(key) {
201
+ const bare = key.startsWith('shift+') ? key.slice('shift+'.length) : key;
202
+ if (bare.includes('+'))
203
+ return false;
204
+ return bare === 'space' || (bare.length === 1 && bare >= ' ');
205
+ }
206
+ function actionOf(id) {
207
+ return ACTION_CATALOG.find(entry => entry.id === id);
208
+ }
209
+ /** The keys in force for one action, or nothing when no action has that id. */
210
+ export function keysFor(map, id) {
211
+ return map.effective[id] ?? [];
212
+ }
213
+ /** Whether a press is one of the keys in force for an action. */
214
+ export function matchesAction(map, id, data) {
215
+ return keysFor(map, id).some(key => matchesKey(data, key));
216
+ }
217
+ /**
218
+ * The name a hint prints for a key.
219
+ *
220
+ * A hint is a line of prose in a card, so the names a terminal already writes on
221
+ * its own keycaps win over the library's spelling of them.
222
+ */
223
+ const SHORT_KEY_NAMES = { escape: 'esc' };
224
+ export function keyName(key) {
225
+ return SHORT_KEY_NAMES[key] ?? key;
226
+ }
227
+ /**
228
+ * The keys that move a cursor, as a hint prints them.
229
+ *
230
+ * A terminal draws arrows on its keycaps, which is shorter than the names the
231
+ * library uses; a map that moved them falls back to naming whatever it moved
232
+ * them to, because a glyph for a key nobody has would be a lie.
233
+ */
234
+ export function moveHint(map, upId, downId) {
235
+ const up = keysFor(map, upId).map(keyName);
236
+ const down = keysFor(map, downId).map(keyName);
237
+ if (up.length === 1 && up[0] === 'up' && down.length === 1 && down[0] === 'down')
238
+ return '↑↓';
239
+ return `${up.join('/')} or ${down.join('/')}`;
240
+ }
241
+ /** How an action names itself in a hint. */
242
+ export function actionLabel(id) {
243
+ return actionOf(id)?.label ?? id;
244
+ }
245
+ /**
246
+ * The keys in force for one action, as a hint prints them.
247
+ *
248
+ * One word for the action however many keys reach it, so a hint does not read
249
+ * as two actions. Read from the live map, because a hint drawn after a settings
250
+ * edit must not keep advertising the key the reader just moved.
251
+ */
252
+ export function hintKeys(map, id) {
253
+ return keysFor(map, id).map(keyName).join('/');
254
+ }
255
+ function readKeys(action, value) {
256
+ const written = Array.isArray(value) ? value : [value];
257
+ const keys = [];
258
+ for (const raw of written) {
259
+ const key = normalizeKey(raw);
260
+ if (key === undefined)
261
+ throw new Error(`key "${raw}" on ${action.id} is not a key a terminal reports`);
262
+ if (TERMINAL_OWNED_KEYS.includes(key))
263
+ throw new Error(`key "${key}" on ${action.id} is the terminal's own key`);
264
+ // A row's own shipped key is always writable: a document that spells out the
265
+ // default changes nothing, and refusing it would cost the reader the section.
266
+ if (!action.mayUseBare && isBareCharacter(key) && !action.defaultKeys.includes(key)) {
267
+ throw new Error(`key "${key}" on ${action.id} would be typed rather than commanded; write a modifier chord or a named key`);
268
+ }
269
+ if (action.keyShape === 'chord' && !isSimpleChord(key)) {
270
+ throw new Error(`key "${key}" on ${action.id} must be a modifier chord like ctrl+x`);
271
+ }
272
+ if (!isPressable(key)) {
273
+ throw new Error(`key "${key}" on ${action.id} is one the library never matches; write a key the terminal reports`);
274
+ }
275
+ // A repeat inside one list is the same press written twice, not a second key.
276
+ if (!keys.includes(key))
277
+ keys.push(key);
278
+ }
279
+ if (keys.length === 0 && !action.mayUnbind) {
280
+ throw new Error(`${action.id} needs at least one key; the surface has no other way to do it`);
281
+ }
282
+ return keys;
283
+ }
284
+ /** How many single-byte presses a probe walks: every byte a terminal can send on its own. */
285
+ const PROBE_BYTES = 0x80;
286
+ /**
287
+ * The sequences one press can arrive as, so an overlap is read from the matcher.
288
+ *
289
+ * The library is the only authority on which sequences are one press, and its
290
+ * reading is looser than a spelling table could say: a bare terminal reports
291
+ * Return for both Enter and Ctrl+M, one control byte carries Ctrl+- and Ctrl+_
292
+ * alike, and escape-prefixed bytes reach more than one alt key — an escape and a
293
+ * letter answers Alt+Up as well as Alt+P. Both protocol modes are read over the
294
+ * same bytes, because a terminal without the protocol folds spellings together
295
+ * that one with it keeps apart. The answer is cached: which sequences reach a key
296
+ * is a property of the key rather than of the map.
297
+ */
298
+ const PRESS_PROBES = Array.from({ length: PROBE_BYTES }, (_, code) => String.fromCharCode(code)).flatMap(byte => [byte, `\u001b${byte}`]);
299
+ /** One sequence named so two rows can agree on it without carrying the bytes. */
300
+ function spellingOf(sequence) {
301
+ return `seq:${[...sequence].map(character => character.charCodeAt(0).toString(16)).join('-')}`;
302
+ }
303
+ const pressSpellings = new Map();
304
+ /** The sequences that reach a key in either protocol mode, or its own id when none does. */
305
+ function matchPresses(key) {
306
+ const cached = pressSpellings.get(key);
307
+ if (cached !== undefined)
308
+ return cached;
309
+ const spelled = new Set();
310
+ const wasKitty = isKittyProtocolActive();
311
+ try {
312
+ for (const kitty of [false, true]) {
313
+ setKittyProtocolActive(kitty);
314
+ for (const probe of PRESS_PROBES) {
315
+ if (matchesKey(probe, key))
316
+ spelled.add(spellingOf(probe));
317
+ }
318
+ }
319
+ }
320
+ finally {
321
+ setKittyProtocolActive(wasKitty);
322
+ }
323
+ const answer = spelled.size === 0 ? [`key:${key}`] : [...spelled];
324
+ pressSpellings.set(key, answer);
325
+ return answer;
326
+ }
327
+ /**
328
+ * Group the rows one terminal sequence reaches, so a group is a press that cannot
329
+ * be split between them.
330
+ */
331
+ function pressOverlaps(rows) {
332
+ const rowAt = (index) => rows[index];
333
+ const reached = new Map();
334
+ rows.forEach((row, index) => {
335
+ for (const spelling of matchPresses(row.key)) {
336
+ const found = reached.get(spelling) ?? [];
337
+ if (!found.includes(index))
338
+ found.push(index);
339
+ reached.set(spelling, found);
340
+ }
341
+ });
342
+ const parent = rows.map((_, index) => index);
343
+ const find = (index) => {
344
+ const kept = parent[index] ?? index;
345
+ if (kept === index)
346
+ return index;
347
+ const root = find(kept);
348
+ parent[index] = root;
349
+ return root;
350
+ };
351
+ for (const indexes of reached.values()) {
352
+ for (const index of indexes.slice(1)) {
353
+ const left = find(indexes[0]);
354
+ const right = find(index);
355
+ if (left !== right)
356
+ parent[right] = left;
357
+ }
358
+ }
359
+ const groups = new Map();
360
+ rows.forEach((_, index) => {
361
+ const root = find(index);
362
+ const found = groups.get(root) ?? [];
363
+ found.push(index);
364
+ groups.set(root, found);
365
+ });
366
+ const overlaps = [];
367
+ for (const found of groups.values()) {
368
+ const ids = [...new Set(found.map(index => rowAt(index).id))].sort();
369
+ if (ids.length < 2)
370
+ continue;
371
+ // A spelling counts as shared only when two different rows read it: one row
372
+ // may hold two spellings of the same press (Return beside Ctrl+M), which is
373
+ // the reader saying one thing rather than a press two rows fight over.
374
+ const spellings = [...new Set(found.flatMap(index => matchPresses(rowAt(index).key)))]
375
+ .filter(spelling => new Set((reached.get(spelling) ?? []).filter(index => found.includes(index)).map(index => rowAt(index).id)).size > 1)
376
+ .sort();
377
+ overlaps.push({ ids, key: rowAt(found[0]).key, spellings });
378
+ }
379
+ return overlaps;
380
+ }
381
+ /**
382
+ * Refuse two actions of one layer a terminal cannot tell apart.
383
+ *
384
+ * Layers are checked apart because sharing across them is the design: the chord
385
+ * layer takes a key before the surface answers it, and the surface takes one
386
+ * before the library's editor sees it. Within one layer a shared press is an
387
+ * action the reader could never reach — and the surface answers these layers
388
+ * itself, first row first, so two rows sharing one byte is one row that loses.
389
+ */
390
+ function refuseMatcherClashes(effective) {
391
+ for (const layer of ['surface', 'chord', 'gate', 'question', 'picker']) {
392
+ const rows = [];
393
+ for (const action of ACTION_CATALOG) {
394
+ if (action.layer !== layer)
395
+ continue;
396
+ for (const key of effective[action.id] ?? [])
397
+ rows.push({ id: action.id, key });
398
+ }
399
+ const overlap = pressOverlaps(rows)[0];
400
+ if (overlap !== undefined) {
401
+ throw new Error(`key "${overlap.key}" is bound to both ${overlap.ids.join(' and ')}`);
402
+ }
403
+ }
404
+ }
405
+ /**
406
+ * Refuse two prompt rows claiming one press.
407
+ *
408
+ * The bar is the one layer the library's matcher does not decide: Return is
409
+ * answered in the bar itself, which reads a line feed as send when the reader
410
+ * bound it and as a line otherwise. Its rows are therefore compared by the
411
+ * press they arrive as rather than by every byte the matcher folds, so moving
412
+ * send onto Ctrl+J stays possible.
413
+ */
414
+ function refusePromptClashes(effective) {
415
+ const owner = new Map();
416
+ for (const action of ACTION_CATALOG) {
417
+ if (action.layer !== 'prompt')
418
+ continue;
419
+ for (const key of effective[action.id] ?? []) {
420
+ const press = pressOf(key);
421
+ const taken = owner.get(press);
422
+ if (taken !== undefined && taken !== action.id) {
423
+ throw new Error(`key "${key}" is bound to both ${taken} and ${action.id}`);
424
+ }
425
+ owner.set(press, action.id);
426
+ }
427
+ }
428
+ }
429
+ /**
430
+ * Refuse a chord starter that would take a key the surface already answers.
431
+ *
432
+ * The prefix is consumed before anything else looks at the press, so a starter
433
+ * that is also a surface key would not shadow that action while a chord is
434
+ * armed: it would remove it for the whole session.
435
+ */
436
+ function refusePrefixTakingKeys(effective) {
437
+ const owners = ACTION_CATALOG.filter(action => action.layer === 'surface' || action.layer === 'prompt');
438
+ for (const key of effective['chord.prefix'] ?? []) {
439
+ for (const owner of owners) {
440
+ // A prefix is consumed before anything else looks at the press, so it takes
441
+ // the key from a row that reads the same press under another spelling too.
442
+ if ((effective[owner.id] ?? []).some(owned => pressOf(owned) === pressOf(key))) {
443
+ throw new Error(`key "${key}" as chord.prefix would take it from ${owner.id}`);
444
+ }
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * The rows the library reads, as one map.
450
+ *
451
+ * Every library action appears, wherever the key came from, because a moved row
452
+ * and an untouched default are read by the same matcher. The bar's two actions
453
+ * are here as well: they are installed even when the reader never wrote them, so
454
+ * they share the keyboard with the library's own rows.
455
+ */
456
+ function libraryRows(effective) {
457
+ const rows = {};
458
+ for (const action of ACTION_CATALOG) {
459
+ if (action.layer === 'library')
460
+ rows[action.id] = [...(effective[action.id] ?? [])];
461
+ }
462
+ rows['tui.input.submit'] = [...(effective['prompt.submit'] ?? [])];
463
+ // Enter is left out of the installation for one reason and kept here for the
464
+ // opposite one: the library reads that press as a line break before it looks
465
+ // for this row, so a library row that took Return would be the row that never
466
+ // runs while the bar still answers it — as a line, or as send.
467
+ const line = effective['prompt.newLine'] ?? [];
468
+ rows['tui.input.newLine'] = line.includes(ENTER_KEY) ? [...line] : line.filter(key => key !== ENTER_KEY);
469
+ return rows;
470
+ }
471
+ /** The same rows as they read before the reader wrote anything. */
472
+ function shippedLibraryRows() {
473
+ const defaults = new Map(ACTION_CATALOG.map(action => [action.id, action.defaultKeys]));
474
+ const rows = {};
475
+ for (const action of ACTION_CATALOG) {
476
+ if (action.layer === 'library')
477
+ rows[action.id] = [...action.defaultKeys];
478
+ }
479
+ rows['tui.input.submit'] = [...(defaults.get('prompt.submit') ?? [])];
480
+ rows['tui.input.newLine'] = [...(defaults.get('prompt.newLine') ?? [])];
481
+ return rows;
482
+ }
483
+ /**
484
+ * The component that reads a library row.
485
+ *
486
+ * Two rows one component reads can fight over a press. Rows of different
487
+ * components meet only where the library already ships the overlap, such as the
488
+ * viewport's page keys shadowing the editor's, so those are its business rather
489
+ * than a clash to refuse.
490
+ */
491
+ function libraryComponent(id) {
492
+ if (id.startsWith('tui.editor.') || id.startsWith('tui.input.'))
493
+ return 'editor';
494
+ if (id.startsWith('tui.select.'))
495
+ return 'select';
496
+ if (id.startsWith('tui.altScreen.'))
497
+ return 'viewport';
498
+ return 'other';
499
+ }
500
+ /** Every press one component reads on more than one row, with the rows that read it. */
501
+ function sharedPresses(rows) {
502
+ const owners = new Map();
503
+ for (const [id, keys] of Object.entries(rows)) {
504
+ const component = libraryComponent(id);
505
+ for (const key of keys) {
506
+ const press = pressOf(key);
507
+ const byComponent = owners.get(press) ?? new Map();
508
+ const found = byComponent.get(component) ?? [];
509
+ if (!found.includes(id))
510
+ found.push(id);
511
+ byComponent.set(component, found);
512
+ owners.set(press, byComponent);
513
+ }
514
+ }
515
+ const shared = new Map();
516
+ for (const [press, byComponent] of owners) {
517
+ for (const ids of byComponent.values()) {
518
+ if (ids.length > 1)
519
+ shared.set(press, [...ids].sort());
520
+ }
521
+ }
522
+ return shared;
523
+ }
524
+ /**
525
+ * The overlaps the library's own rows carry, one component at a time.
526
+ *
527
+ * The bar's own two rows are left out: they are read by the bar before the
528
+ * library's matcher runs, so a line feed is send when the reader bound it and a
529
+ * line otherwise, and comparing them by every sequence the matcher folds would
530
+ * forbid a choice the bar can honour.
531
+ */
532
+ function libraryOverlaps(rows) {
533
+ const overlaps = [];
534
+ for (const component of ['editor', 'select', 'viewport', 'other']) {
535
+ const own = [];
536
+ for (const [id, keys] of Object.entries(rows)) {
537
+ if (id === 'tui.input.submit' || id === 'tui.input.newLine')
538
+ continue;
539
+ if (libraryComponent(id) !== component)
540
+ continue;
541
+ for (const key of keys)
542
+ own.push({ id, key });
543
+ }
544
+ overlaps.push(...pressOverlaps(own));
545
+ }
546
+ return overlaps;
547
+ }
548
+ /** What makes two overlaps the same fight: the rows, and the sequences they share. */
549
+ function overlapIdentity(overlap) {
550
+ return `${overlap.ids.join(',')}\u0000${overlap.spellings.join('|')}`;
551
+ }
552
+ /**
553
+ * Refuse two rows the library reads on one press.
554
+ *
555
+ * The library's own manager reports a clash between rows the reader wrote and
556
+ * nothing else, which would let a new binding quietly take a key from a row the
557
+ * reader never touched. Rows are compared twice: by the press they were written
558
+ * as, which is what the bar's own rows are matched by, and by every sequence the
559
+ * matcher folds, which is what catches one control byte carrying two spellings.
560
+ * The overlaps the library itself ships are left alone: those are rows it
561
+ * already knows how to tell apart.
562
+ */
563
+ function refuseLibraryClashes(effective) {
564
+ const shipped = new Set([...sharedPresses(shippedLibraryRows())].map(([press, ids]) => `${press}\u0000${ids.join(',')}`));
565
+ for (const [press, ids] of sharedPresses(libraryRows(effective))) {
566
+ if (shipped.has(`${press}\u0000${ids.join(',')}`))
567
+ continue;
568
+ throw new Error(`key "${press}" is bound to both ${ids.join(' and ')}; move one of them or pick another key`);
569
+ }
570
+ const shippedOverlaps = new Set(libraryOverlaps(shippedLibraryRows()).map(overlapIdentity));
571
+ for (const overlap of libraryOverlaps(libraryRows(effective))) {
572
+ if (shippedOverlaps.has(overlapIdentity(overlap)))
573
+ continue;
574
+ throw new Error(`key "${overlap.key}" is bound to both ${overlap.ids.join(' and ')}; move one of them or pick another key`);
575
+ }
576
+ }
577
+ /**
578
+ * The library rows that read a press before the surface's own listener runs.
579
+ *
580
+ * The alternate screen registers its viewport listener while the terminal is
581
+ * built, so its keys arrive before a listener the surface adds later. These rows
582
+ * are read without asking whether an overlay holds the keyboard; the search
583
+ * overlay's own next, previous, and close keys are left out because they wait
584
+ * for it and would let an overlay-scoped surface key through.
585
+ */
586
+ const VIEWPORT_FIRST_ROWS = [
587
+ 'tui.altScreen.search',
588
+ 'tui.altScreen.pageUp',
589
+ 'tui.altScreen.pageDown',
590
+ 'tui.altScreen.halfPageUp',
591
+ 'tui.altScreen.halfPageDown',
592
+ 'tui.altScreen.lineUp',
593
+ 'tui.altScreen.lineDown',
594
+ 'tui.altScreen.previousPrompt',
595
+ 'tui.altScreen.nextPrompt',
596
+ 'tui.altScreen.top',
597
+ 'tui.altScreen.bottom',
598
+ ];
599
+ /** Every row a press reaches, with the keys in force: the catalog and the bar's own library rows. */
600
+ function dispatchRows(effective) {
601
+ const rows = {};
602
+ for (const action of ACTION_CATALOG)
603
+ rows[action.id] = [...(effective[action.id] ?? [])];
604
+ rows['tui.input.submit'] = [...(effective['prompt.submit'] ?? [])];
605
+ rows['tui.input.newLine'] = (effective['prompt.newLine'] ?? []).filter(key => key !== ENTER_KEY);
606
+ return rows;
607
+ }
608
+ /** The same rows as they read before the reader wrote anything. */
609
+ function shippedRows() {
610
+ const rows = {};
611
+ for (const action of ACTION_CATALOG)
612
+ rows[action.id] = [...action.defaultKeys];
613
+ rows['tui.input.submit'] = [...(actionOf('prompt.submit')?.defaultKeys ?? [])];
614
+ rows['tui.input.newLine'] = [...(actionOf('prompt.newLine')?.defaultKeys ?? [])].filter(key => key !== ENTER_KEY);
615
+ return rows;
616
+ }
617
+ function viewportPairs(rows) {
618
+ const table = [];
619
+ for (const [id, keys] of Object.entries(rows)) {
620
+ for (const key of keys)
621
+ table.push({ id, key });
622
+ }
623
+ const pairs = [];
624
+ for (const overlap of pressOverlaps(table)) {
625
+ const viewport = overlap.ids.filter(id => VIEWPORT_FIRST_ROWS.includes(id));
626
+ for (const row of viewport) {
627
+ for (const other of overlap.ids.filter(id => id !== row && !VIEWPORT_FIRST_ROWS.includes(id))) {
628
+ pairs.push({ key: overlap.key, spellings: overlap.spellings, viewport: row, other });
629
+ }
630
+ }
631
+ }
632
+ return pairs;
633
+ }
634
+ /**
635
+ * Refuse a pair the viewport answers before the row it was written on.
636
+ *
637
+ * A key the alternate screen's listener reads never reaches the surface: the
638
+ * press scrolls or searches instead, and the binding only looks alive while an
639
+ * overlay defers the viewport. Either side of the pair can be the one the
640
+ * reader wrote, and both are a binding that does nothing, so both are refused
641
+ * apart from the overlap the library already ships.
642
+ */
643
+ function refuseViewportTakingKeys(effective, written) {
644
+ const pairKey = (pair) => `${pair.viewport}\u0000${pair.other}\u0000${pair.spellings.join('|')}`;
645
+ const shipped = new Set(viewportPairs(shippedRows()).map(pairKey));
646
+ // A prompt row is read through a library row of the bar's own, so the reader's
647
+ // name for the row is the action they wrote rather than the mirror.
648
+ const isWritten = (id) => written.has(id) ||
649
+ (id === 'tui.input.submit' && written.has('prompt.submit')) ||
650
+ (id === 'tui.input.newLine' && written.has('prompt.newLine'));
651
+ for (const pair of viewportPairs(dispatchRows(effective))) {
652
+ if (shipped.has(pairKey(pair)))
653
+ continue;
654
+ if (isWritten(pair.other)) {
655
+ throw new Error(`key "${pair.key}" on ${pair.other} is read by ${pair.viewport} first; move that row or pick another key`);
656
+ }
657
+ if (isWritten(pair.viewport)) {
658
+ throw new Error(`key "${pair.key}" on ${pair.viewport} would take it from ${pair.other}, which never sees the press; pick another key`);
659
+ }
660
+ }
661
+ }
662
+ /**
663
+ * The reader's map over the shipped one.
664
+ *
665
+ * Every refusal names the action or the key that caused it, because a settings
666
+ * document is edited by hand: a message the reader cannot act on is a message
667
+ * that costs them the whole section.
668
+ */
669
+ export function resolveKeymap(overrides) {
670
+ const effective = {};
671
+ for (const action of ACTION_CATALOG)
672
+ effective[action.id] = [...action.defaultKeys];
673
+ const written = new Set();
674
+ for (const [id, value] of Object.entries(overrides)) {
675
+ if (value === undefined)
676
+ continue;
677
+ const canonical = KEYMAP_ALIASES[id];
678
+ if (canonical !== undefined)
679
+ throw new Error(`key action "${id}" belongs to the prompt bar; write ${canonical} instead`);
680
+ const action = actionOf(id);
681
+ if (action === undefined)
682
+ throw new Error(`unknown dsh-tui key action: ${id}`);
683
+ effective[id] = readKeys(action, value);
684
+ written.add(id);
685
+ }
686
+ refusePromptClashes(effective);
687
+ refuseMatcherClashes(effective);
688
+ refusePrefixTakingKeys(effective);
689
+ refuseLibraryClashes(effective);
690
+ refuseViewportTakingKeys(effective, written);
691
+ return { effective, written };
692
+ }
693
+ /** The map as it reads when the reader has written nothing, built once. */
694
+ export function defaultKeymap() {
695
+ // Read on every press by components that outlive a settings edit, so the
696
+ // shipped map is resolved once rather than a row at a time on each key.
697
+ shippedMap ??= resolveKeymap({});
698
+ return shippedMap;
699
+ }
700
+ let shippedMap;
701
+ const WINNING_LAYERS = ['chord', 'surface'];
702
+ /** Every key the chord or surface layer takes from a library action, in layer order. */
703
+ export function shadowsOf(map) {
704
+ const library = ACTION_CATALOG.filter(action => action.layer === 'library');
705
+ const found = [];
706
+ for (const layer of WINNING_LAYERS) {
707
+ for (const winner of ACTION_CATALOG.filter(action => action.layer === layer)) {
708
+ for (const key of keysFor(map, winner.id)) {
709
+ for (const loser of library) {
710
+ if (keysFor(map, loser.id).includes(key))
711
+ found.push({ key, winner: winner.id, loser: loser.id });
712
+ }
713
+ }
714
+ }
715
+ }
716
+ return found;
717
+ }
718
+ function shadowId(shadow) {
719
+ return `${shadow.key} ${shadow.winner} ${shadow.loser}`;
720
+ }
721
+ /**
722
+ * The shadows a reader's map introduces, beyond the ones the surface ships with.
723
+ *
724
+ * Only the new ones: reporting the shipped map's own overlaps on every session
725
+ * would teach the reader to ignore the line that matters.
726
+ */
727
+ export function newShadows(map) {
728
+ const shipped = new Set(shadowsOf(defaultKeymap()).map(shadowId));
729
+ return shadowsOf(map).filter(shadow => !shipped.has(shadowId(shadow)));
730
+ }
731
+ /** Every key the surface's own listener answers, in catalog order. */
732
+ export function surfaceBindings(map) {
733
+ const found = [];
734
+ for (const action of SURFACE_ACTIONS) {
735
+ for (const key of keysFor(map, action.id))
736
+ found.push({ key, action: action.name, id: action.id });
737
+ }
738
+ return found;
739
+ }
740
+ //# sourceMappingURL=actions.js.map