lonny-agent 0.1.2 → 0.1.9

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,1440 @@
1
+ /**
2
+ * Keyboard input handling for terminal applications.
3
+ *
4
+ * Supports both legacy terminal sequences and Kitty keyboard protocol.
5
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
6
+ * Reference: https://github.com/sst/opentui/blob/7da92b4088aebfe27b9f691c04163a48821e49fd/packages/core/src/lib/parse.keypress.ts
7
+ *
8
+ * Symbol keys are also supported, however some ctrl+symbol combos
9
+ * overlap with ASCII codes, e.g. ctrl+[ = ESC.
10
+ * See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/#legacy-ctrl-mapping-of-ascii-keys
11
+ * Those can still be * used for ctrl+shift combos
12
+ *
13
+ * API:
14
+ * - matchesKey(data, keyId) - Check if input matches a key identifier
15
+ * - parseKey(data) - Parse input and return the key identifier
16
+ * - Key - Helper object for creating typed key identifiers
17
+ * - setKittyProtocolActive(active) - Set global Kitty protocol state
18
+ * - isKittyProtocolActive() - Query global Kitty protocol state
19
+ */
20
+
21
+ // =============================================================================
22
+ // Global Kitty Protocol State
23
+ // =============================================================================
24
+
25
+ let _kittyProtocolActive = false
26
+
27
+ /**
28
+ * Set the global Kitty keyboard protocol state.
29
+ * Called by ProcessTerminal after detecting protocol support.
30
+ */
31
+ export function setKittyProtocolActive(active: boolean): void {
32
+ _kittyProtocolActive = active
33
+ }
34
+
35
+ /**
36
+ * Query whether Kitty keyboard protocol is currently active.
37
+ */
38
+ export function isKittyProtocolActive(): boolean {
39
+ return _kittyProtocolActive
40
+ }
41
+
42
+ // =============================================================================
43
+ // Type-Safe Key Identifiers
44
+ // =============================================================================
45
+
46
+ type Letter =
47
+ | 'a'
48
+ | 'b'
49
+ | 'c'
50
+ | 'd'
51
+ | 'e'
52
+ | 'f'
53
+ | 'g'
54
+ | 'h'
55
+ | 'i'
56
+ | 'j'
57
+ | 'k'
58
+ | 'l'
59
+ | 'm'
60
+ | 'n'
61
+ | 'o'
62
+ | 'p'
63
+ | 'q'
64
+ | 'r'
65
+ | 's'
66
+ | 't'
67
+ | 'u'
68
+ | 'v'
69
+ | 'w'
70
+ | 'x'
71
+ | 'y'
72
+ | 'z'
73
+
74
+ type Digit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
75
+
76
+ type SymbolKey =
77
+ | '`'
78
+ | '-'
79
+ | '='
80
+ | '['
81
+ | ']'
82
+ | '\\'
83
+ | ';'
84
+ | "'"
85
+ | ','
86
+ | '.'
87
+ | '/'
88
+ | '!'
89
+ | '@'
90
+ | '#'
91
+ | '$'
92
+ | '%'
93
+ | '^'
94
+ | '&'
95
+ | '*'
96
+ | '('
97
+ | ')'
98
+ | '_'
99
+ | '+'
100
+ | '|'
101
+ | '~'
102
+ | '{'
103
+ | '}'
104
+ | ':'
105
+ | '<'
106
+ | '>'
107
+ | '?'
108
+
109
+ type SpecialKey =
110
+ | 'escape'
111
+ | 'esc'
112
+ | 'enter'
113
+ | 'return'
114
+ | 'tab'
115
+ | 'space'
116
+ | 'backspace'
117
+ | 'delete'
118
+ | 'insert'
119
+ | 'clear'
120
+ | 'home'
121
+ | 'end'
122
+ | 'pageUp'
123
+ | 'pageDown'
124
+ | 'up'
125
+ | 'down'
126
+ | 'left'
127
+ | 'right'
128
+ | 'f1'
129
+ | 'f2'
130
+ | 'f3'
131
+ | 'f4'
132
+ | 'f5'
133
+ | 'f6'
134
+ | 'f7'
135
+ | 'f8'
136
+ | 'f9'
137
+ | 'f10'
138
+ | 'f11'
139
+ | 'f12'
140
+
141
+ type BaseKey = Letter | Digit | SymbolKey | SpecialKey
142
+ type ModifierName = 'ctrl' | 'shift' | 'alt' | 'super'
143
+
144
+ type ModifiedKeyId<Key extends string, RemainingModifiers extends ModifierName = ModifierName> = {
145
+ [M in RemainingModifiers]:
146
+ | `${M}+${Key}`
147
+ | `${M}+${ModifiedKeyId<Key, Exclude<RemainingModifiers, M>>}`
148
+ }[RemainingModifiers]
149
+
150
+ /**
151
+ * Union type of all valid key identifiers.
152
+ * Provides autocomplete and catches typos at compile time.
153
+ */
154
+ export type KeyId = BaseKey | ModifiedKeyId<BaseKey>
155
+
156
+ /**
157
+ * Helper object for creating typed key identifiers with autocomplete.
158
+ *
159
+ * Usage:
160
+ * - Key.escape, Key.enter, Key.tab, etc. for special keys
161
+ * - Key.backtick, Key.comma, Key.period, etc. for symbol keys
162
+ * - Key.ctrl("c"), Key.alt("x"), Key.super("k") for single modifiers
163
+ * - Key.ctrlShift("p"), Key.ctrlAlt("x"), Key.ctrlSuper("k") for combined modifiers
164
+ */
165
+ export const Key = {
166
+ // Special keys
167
+ escape: 'escape' as const,
168
+ esc: 'esc' as const,
169
+ enter: 'enter' as const,
170
+ return: 'return' as const,
171
+ tab: 'tab' as const,
172
+ space: 'space' as const,
173
+ backspace: 'backspace' as const,
174
+ delete: 'delete' as const,
175
+ insert: 'insert' as const,
176
+ clear: 'clear' as const,
177
+ home: 'home' as const,
178
+ end: 'end' as const,
179
+ pageUp: 'pageUp' as const,
180
+ pageDown: 'pageDown' as const,
181
+ up: 'up' as const,
182
+ down: 'down' as const,
183
+ left: 'left' as const,
184
+ right: 'right' as const,
185
+ f1: 'f1' as const,
186
+ f2: 'f2' as const,
187
+ f3: 'f3' as const,
188
+ f4: 'f4' as const,
189
+ f5: 'f5' as const,
190
+ f6: 'f6' as const,
191
+ f7: 'f7' as const,
192
+ f8: 'f8' as const,
193
+ f9: 'f9' as const,
194
+ f10: 'f10' as const,
195
+ f11: 'f11' as const,
196
+ f12: 'f12' as const,
197
+
198
+ // Symbol keys
199
+ backtick: '`' as const,
200
+ hyphen: '-' as const,
201
+ equals: '=' as const,
202
+ leftbracket: '[' as const,
203
+ rightbracket: ']' as const,
204
+ backslash: '\\' as const,
205
+ semicolon: ';' as const,
206
+ quote: "'" as const,
207
+ comma: ',' as const,
208
+ period: '.' as const,
209
+ slash: '/' as const,
210
+ exclamation: '!' as const,
211
+ at: '@' as const,
212
+ hash: '#' as const,
213
+ dollar: '$' as const,
214
+ percent: '%' as const,
215
+ caret: '^' as const,
216
+ ampersand: '&' as const,
217
+ asterisk: '*' as const,
218
+ leftparen: '(' as const,
219
+ rightparen: ')' as const,
220
+ underscore: '_' as const,
221
+ plus: '+' as const,
222
+ pipe: '|' as const,
223
+ tilde: '~' as const,
224
+ leftbrace: '{' as const,
225
+ rightbrace: '}' as const,
226
+ colon: ':' as const,
227
+ lessthan: '<' as const,
228
+ greaterthan: '>' as const,
229
+ question: '?' as const,
230
+
231
+ // Single modifiers
232
+ ctrl: <K extends BaseKey>(key: K): `ctrl+${K}` => `ctrl+${key}`,
233
+ shift: <K extends BaseKey>(key: K): `shift+${K}` => `shift+${key}`,
234
+ alt: <K extends BaseKey>(key: K): `alt+${K}` => `alt+${key}`,
235
+ super: <K extends BaseKey>(key: K): `super+${K}` => `super+${key}`,
236
+
237
+ // Combined modifiers
238
+ ctrlShift: <K extends BaseKey>(key: K): `ctrl+shift+${K}` => `ctrl+shift+${key}`,
239
+ shiftCtrl: <K extends BaseKey>(key: K): `shift+ctrl+${K}` => `shift+ctrl+${key}`,
240
+ ctrlAlt: <K extends BaseKey>(key: K): `ctrl+alt+${K}` => `ctrl+alt+${key}`,
241
+ altCtrl: <K extends BaseKey>(key: K): `alt+ctrl+${K}` => `alt+ctrl+${key}`,
242
+ shiftAlt: <K extends BaseKey>(key: K): `shift+alt+${K}` => `shift+alt+${key}`,
243
+ altShift: <K extends BaseKey>(key: K): `alt+shift+${K}` => `alt+shift+${key}`,
244
+ ctrlSuper: <K extends BaseKey>(key: K): `ctrl+super+${K}` => `ctrl+super+${key}`,
245
+ superCtrl: <K extends BaseKey>(key: K): `super+ctrl+${K}` => `super+ctrl+${key}`,
246
+ shiftSuper: <K extends BaseKey>(key: K): `shift+super+${K}` => `shift+super+${key}`,
247
+ superShift: <K extends BaseKey>(key: K): `super+shift+${K}` => `super+shift+${key}`,
248
+ altSuper: <K extends BaseKey>(key: K): `alt+super+${K}` => `alt+super+${key}`,
249
+ superAlt: <K extends BaseKey>(key: K): `super+alt+${K}` => `super+alt+${key}`,
250
+
251
+ // Triple modifiers
252
+ ctrlShiftAlt: <K extends BaseKey>(key: K): `ctrl+shift+alt+${K}` => `ctrl+shift+alt+${key}`,
253
+ ctrlShiftSuper: <K extends BaseKey>(key: K): `ctrl+shift+super+${K}` => `ctrl+shift+super+${key}`,
254
+ } as const
255
+
256
+ // =============================================================================
257
+ // Constants
258
+ // =============================================================================
259
+
260
+ const SYMBOL_KEYS = new Set([
261
+ '`',
262
+ '-',
263
+ '=',
264
+ '[',
265
+ ']',
266
+ '\\',
267
+ ';',
268
+ "'",
269
+ ',',
270
+ '.',
271
+ '/',
272
+ '!',
273
+ '@',
274
+ '#',
275
+ '$',
276
+ '%',
277
+ '^',
278
+ '&',
279
+ '*',
280
+ '(',
281
+ ')',
282
+ '_',
283
+ '+',
284
+ '|',
285
+ '~',
286
+ '{',
287
+ '}',
288
+ ':',
289
+ '<',
290
+ '>',
291
+ '?',
292
+ ])
293
+
294
+ const MODIFIERS = {
295
+ shift: 1,
296
+ alt: 2,
297
+ ctrl: 4,
298
+ super: 8,
299
+ } as const
300
+
301
+ const LOCK_MASK = 64 + 128 // Caps Lock + Num Lock
302
+
303
+ const CODEPOINTS = {
304
+ escape: 27,
305
+ tab: 9,
306
+ enter: 13,
307
+ space: 32,
308
+ backspace: 127,
309
+ kpEnter: 57414, // Numpad Enter (Kitty protocol)
310
+ } as const
311
+
312
+ const ARROW_CODEPOINTS = {
313
+ up: -1,
314
+ down: -2,
315
+ right: -3,
316
+ left: -4,
317
+ } as const
318
+
319
+ const FUNCTIONAL_CODEPOINTS = {
320
+ delete: -10,
321
+ insert: -11,
322
+ pageUp: -12,
323
+ pageDown: -13,
324
+ home: -14,
325
+ end: -15,
326
+ } as const
327
+
328
+ const KITTY_FUNCTIONAL_KEY_EQUIVALENTS = new Map<number, number>([
329
+ [57399, 48], // KP_0 -> 0
330
+ [57400, 49], // KP_1 -> 1
331
+ [57401, 50], // KP_2 -> 2
332
+ [57402, 51], // KP_3 -> 3
333
+ [57403, 52], // KP_4 -> 4
334
+ [57404, 53], // KP_5 -> 5
335
+ [57405, 54], // KP_6 -> 6
336
+ [57406, 55], // KP_7 -> 7
337
+ [57407, 56], // KP_8 -> 8
338
+ [57408, 57], // KP_9 -> 9
339
+ [57409, 46], // KP_DECIMAL -> .
340
+ [57410, 47], // KP_DIVIDE -> /
341
+ [57411, 42], // KP_MULTIPLY -> *
342
+ [57412, 45], // KP_SUBTRACT -> -
343
+ [57413, 43], // KP_ADD -> +
344
+ [57415, 61], // KP_EQUAL -> =
345
+ [57416, 44], // KP_SEPARATOR -> ,
346
+ [57417, ARROW_CODEPOINTS.left],
347
+ [57418, ARROW_CODEPOINTS.right],
348
+ [57419, ARROW_CODEPOINTS.up],
349
+ [57420, ARROW_CODEPOINTS.down],
350
+ [57421, FUNCTIONAL_CODEPOINTS.pageUp],
351
+ [57422, FUNCTIONAL_CODEPOINTS.pageDown],
352
+ [57423, FUNCTIONAL_CODEPOINTS.home],
353
+ [57424, FUNCTIONAL_CODEPOINTS.end],
354
+ [57425, FUNCTIONAL_CODEPOINTS.insert],
355
+ [57426, FUNCTIONAL_CODEPOINTS.delete],
356
+ ])
357
+
358
+ function normalizeKittyFunctionalCodepoint(codepoint: number): number {
359
+ return KITTY_FUNCTIONAL_KEY_EQUIVALENTS.get(codepoint) ?? codepoint
360
+ }
361
+
362
+ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: number): number {
363
+ const effectiveModifier = modifier & ~LOCK_MASK
364
+ if ((effectiveModifier & MODIFIERS.shift) !== 0 && codepoint >= 65 && codepoint <= 90) {
365
+ return codepoint + 32
366
+ }
367
+ return codepoint
368
+ }
369
+
370
+ const LEGACY_KEY_SEQUENCES = {
371
+ up: ['\x1b[A', '\x1bOA'],
372
+ down: ['\x1b[B', '\x1bOB'],
373
+ right: ['\x1b[C', '\x1bOC'],
374
+ left: ['\x1b[D', '\x1bOD'],
375
+ home: ['\x1b[H', '\x1bOH', '\x1b[1~', '\x1b[7~'],
376
+ end: ['\x1b[F', '\x1bOF', '\x1b[4~', '\x1b[8~'],
377
+ insert: ['\x1b[2~'],
378
+ delete: ['\x1b[3~'],
379
+ pageUp: ['\x1b[5~', '\x1b[[5~'],
380
+ pageDown: ['\x1b[6~', '\x1b[[6~'],
381
+ clear: ['\x1b[E', '\x1bOE'],
382
+ f1: ['\x1bOP', '\x1b[11~', '\x1b[[A'],
383
+ f2: ['\x1bOQ', '\x1b[12~', '\x1b[[B'],
384
+ f3: ['\x1bOR', '\x1b[13~', '\x1b[[C'],
385
+ f4: ['\x1bOS', '\x1b[14~', '\x1b[[D'],
386
+ f5: ['\x1b[15~', '\x1b[[E'],
387
+ f6: ['\x1b[17~'],
388
+ f7: ['\x1b[18~'],
389
+ f8: ['\x1b[19~'],
390
+ f9: ['\x1b[20~'],
391
+ f10: ['\x1b[21~'],
392
+ f11: ['\x1b[23~'],
393
+ f12: ['\x1b[24~'],
394
+ } as const
395
+
396
+ const LEGACY_SHIFT_SEQUENCES = {
397
+ up: ['\x1b[a'],
398
+ down: ['\x1b[b'],
399
+ right: ['\x1b[c'],
400
+ left: ['\x1b[d'],
401
+ clear: ['\x1b[e'],
402
+ insert: ['\x1b[2$'],
403
+ delete: ['\x1b[3$'],
404
+ pageUp: ['\x1b[5$'],
405
+ pageDown: ['\x1b[6$'],
406
+ home: ['\x1b[7$'],
407
+ end: ['\x1b[8$'],
408
+ } as const
409
+
410
+ const LEGACY_CTRL_SEQUENCES = {
411
+ up: ['\x1bOa'],
412
+ down: ['\x1bOb'],
413
+ right: ['\x1bOc'],
414
+ left: ['\x1bOd'],
415
+ clear: ['\x1bOe'],
416
+ insert: ['\x1b[2^'],
417
+ delete: ['\x1b[3^'],
418
+ pageUp: ['\x1b[5^'],
419
+ pageDown: ['\x1b[6^'],
420
+ home: ['\x1b[7^'],
421
+ end: ['\x1b[8^'],
422
+ } as const
423
+
424
+ const LEGACY_SEQUENCE_KEY_IDS: Record<string, KeyId> = {
425
+ '\x1bOA': 'up',
426
+ '\x1bOB': 'down',
427
+ '\x1bOC': 'right',
428
+ '\x1bOD': 'left',
429
+ '\x1bOH': 'home',
430
+ '\x1bOF': 'end',
431
+ '\x1b[E': 'clear',
432
+ '\x1bOE': 'clear',
433
+ '\x1bOe': 'ctrl+clear',
434
+ '\x1b[e': 'shift+clear',
435
+ '\x1b[2~': 'insert',
436
+ '\x1b[2$': 'shift+insert',
437
+ '\x1b[2^': 'ctrl+insert',
438
+ '\x1b[3$': 'shift+delete',
439
+ '\x1b[3^': 'ctrl+delete',
440
+ '\x1b[[5~': 'pageUp',
441
+ '\x1b[[6~': 'pageDown',
442
+ '\x1b[a': 'shift+up',
443
+ '\x1b[b': 'shift+down',
444
+ '\x1b[c': 'shift+right',
445
+ '\x1b[d': 'shift+left',
446
+ '\x1bOa': 'ctrl+up',
447
+ '\x1bOb': 'ctrl+down',
448
+ '\x1bOc': 'ctrl+right',
449
+ '\x1bOd': 'ctrl+left',
450
+ '\x1b[5$': 'shift+pageUp',
451
+ '\x1b[6$': 'shift+pageDown',
452
+ '\x1b[7$': 'shift+home',
453
+ '\x1b[8$': 'shift+end',
454
+ '\x1b[5^': 'ctrl+pageUp',
455
+ '\x1b[6^': 'ctrl+pageDown',
456
+ '\x1b[7^': 'ctrl+home',
457
+ '\x1b[8^': 'ctrl+end',
458
+ '\x1bOP': 'f1',
459
+ '\x1bOQ': 'f2',
460
+ '\x1bOR': 'f3',
461
+ '\x1bOS': 'f4',
462
+ '\x1b[11~': 'f1',
463
+ '\x1b[12~': 'f2',
464
+ '\x1b[13~': 'f3',
465
+ '\x1b[14~': 'f4',
466
+ '\x1b[[A': 'f1',
467
+ '\x1b[[B': 'f2',
468
+ '\x1b[[C': 'f3',
469
+ '\x1b[[D': 'f4',
470
+ '\x1b[[E': 'f5',
471
+ '\x1b[15~': 'f5',
472
+ '\x1b[17~': 'f6',
473
+ '\x1b[18~': 'f7',
474
+ '\x1b[19~': 'f8',
475
+ '\x1b[20~': 'f9',
476
+ '\x1b[21~': 'f10',
477
+ '\x1b[23~': 'f11',
478
+ '\x1b[24~': 'f12',
479
+ '\x1bb': 'alt+left',
480
+ '\x1bf': 'alt+right',
481
+ '\x1bp': 'alt+up',
482
+ '\x1bn': 'alt+down',
483
+ } as const
484
+
485
+ type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES
486
+
487
+ const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean =>
488
+ sequences.includes(data)
489
+
490
+ const matchesLegacyModifierSequence = (
491
+ data: string,
492
+ key: LegacyModifierKey,
493
+ modifier: number,
494
+ ): boolean => {
495
+ if (modifier === MODIFIERS.shift) {
496
+ return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key])
497
+ }
498
+ if (modifier === MODIFIERS.ctrl) {
499
+ return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key])
500
+ }
501
+ return false
502
+ }
503
+
504
+ // =============================================================================
505
+ // Kitty Protocol Parsing
506
+ // =============================================================================
507
+
508
+ /**
509
+ * Event types from Kitty keyboard protocol (flag 2)
510
+ * 1 = key press, 2 = key repeat, 3 = key release
511
+ */
512
+ export type KeyEventType = 'press' | 'repeat' | 'release'
513
+
514
+ interface ParsedKittySequence {
515
+ codepoint: number
516
+ shiftedKey?: number // Shifted version of the key (when shift is pressed)
517
+ baseLayoutKey?: number // Key in standard PC-101 layout (for non-Latin layouts)
518
+ modifier: number
519
+ eventType: KeyEventType
520
+ }
521
+
522
+ interface ParsedModifyOtherKeysSequence {
523
+ codepoint: number
524
+ modifier: number
525
+ }
526
+
527
+ // Store the last parsed event type for isKeyRelease() to query
528
+ let _lastEventType: KeyEventType = 'press'
529
+
530
+ /**
531
+ * Check if the last parsed key event was a key release.
532
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
533
+ */
534
+ export function isKeyRelease(data: string): boolean {
535
+ // Don't treat bracketed paste content as key release, even if it contains
536
+ // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5").
537
+ // Terminal.ts re-wraps paste content with bracketed paste markers before
538
+ // passing to TUI, so pasted data will always contain \x1b[200~.
539
+ if (data.includes('\x1b[200~')) {
540
+ return false
541
+ }
542
+
543
+ // Quick check: release events with flag 2 contain ":3"
544
+ // Format: \x1b[<codepoint>;<modifier>:3u
545
+ if (
546
+ data.includes(':3u') ||
547
+ data.includes(':3~') ||
548
+ data.includes(':3A') ||
549
+ data.includes(':3B') ||
550
+ data.includes(':3C') ||
551
+ data.includes(':3D') ||
552
+ data.includes(':3H') ||
553
+ data.includes(':3F')
554
+ ) {
555
+ return true
556
+ }
557
+ return false
558
+ }
559
+
560
+ /**
561
+ * Check if the last parsed key event was a key repeat.
562
+ * Only meaningful when Kitty keyboard protocol with flag 2 is active.
563
+ */
564
+ export function isKeyRepeat(data: string): boolean {
565
+ // Don't treat bracketed paste content as key repeat, even if it contains
566
+ // patterns like ":2F". See isKeyRelease() for details.
567
+ if (data.includes('\x1b[200~')) {
568
+ return false
569
+ }
570
+
571
+ if (
572
+ data.includes(':2u') ||
573
+ data.includes(':2~') ||
574
+ data.includes(':2A') ||
575
+ data.includes(':2B') ||
576
+ data.includes(':2C') ||
577
+ data.includes(':2D') ||
578
+ data.includes(':2H') ||
579
+ data.includes(':2F')
580
+ ) {
581
+ return true
582
+ }
583
+ return false
584
+ }
585
+
586
+ function parseEventType(eventTypeStr: string | undefined): KeyEventType {
587
+ if (!eventTypeStr) return 'press'
588
+ const eventType = parseInt(eventTypeStr, 10)
589
+ if (eventType === 2) return 'repeat'
590
+ if (eventType === 3) return 'release'
591
+ return 'press'
592
+ }
593
+
594
+ function parseKittySequence(data: string): ParsedKittySequence | null {
595
+ // CSI u format with alternate keys (flag 4):
596
+ // \x1b[<codepoint>u
597
+ // \x1b[<codepoint>;<mod>u
598
+ // \x1b[<codepoint>;<mod>:<event>u
599
+ // \x1b[<codepoint>:<shifted>;<mod>u
600
+ // \x1b[<codepoint>:<shifted>:<base>;<mod>u
601
+ // \x1b[<codepoint>::<base>;<mod>u (no shifted key, only base)
602
+ //
603
+ // With flag 2, event type is appended after modifier colon: 1=press, 2=repeat, 3=release
604
+ // With flag 4, alternate keys are appended after codepoint with colons
605
+ const csiUMatch = data.match(/^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/)
606
+ if (csiUMatch) {
607
+ const codepoint = parseInt(csiUMatch[1]!, 10)
608
+ const shiftedKey =
609
+ csiUMatch[2] && csiUMatch[2].length > 0 ? parseInt(csiUMatch[2], 10) : undefined
610
+ const baseLayoutKey = csiUMatch[3] ? parseInt(csiUMatch[3], 10) : undefined
611
+ const modValue = csiUMatch[4] ? parseInt(csiUMatch[4], 10) : 1
612
+ const eventType = parseEventType(csiUMatch[5])
613
+ _lastEventType = eventType
614
+ return { codepoint, shiftedKey, baseLayoutKey, modifier: modValue - 1, eventType }
615
+ }
616
+
617
+ // Arrow keys with modifier: \x1b[1;<mod>A/B/C/D or \x1b[1;<mod>:<event>A/B/C/D
618
+ const arrowMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([ABCD])$/)
619
+ if (arrowMatch) {
620
+ const modValue = parseInt(arrowMatch[1]!, 10)
621
+ const eventType = parseEventType(arrowMatch[2])
622
+ const arrowCodes: Record<string, number> = { A: -1, B: -2, C: -3, D: -4 }
623
+ _lastEventType = eventType
624
+ return { codepoint: arrowCodes[arrowMatch[3]!]!, modifier: modValue - 1, eventType }
625
+ }
626
+
627
+ // Functional keys: \x1b[<num>~ or \x1b[<num>;<mod>~ or \x1b[<num>;<mod>:<event>~
628
+ const funcMatch = data.match(/^\x1b\[(\d+)(?:;(\d+))?(?::(\d+))?~$/)
629
+ if (funcMatch) {
630
+ const keyNum = parseInt(funcMatch[1]!, 10)
631
+ const modValue = funcMatch[2] ? parseInt(funcMatch[2], 10) : 1
632
+ const eventType = parseEventType(funcMatch[3])
633
+ const funcCodes: Record<number, number> = {
634
+ 2: FUNCTIONAL_CODEPOINTS.insert,
635
+ 3: FUNCTIONAL_CODEPOINTS.delete,
636
+ 5: FUNCTIONAL_CODEPOINTS.pageUp,
637
+ 6: FUNCTIONAL_CODEPOINTS.pageDown,
638
+ 7: FUNCTIONAL_CODEPOINTS.home,
639
+ 8: FUNCTIONAL_CODEPOINTS.end,
640
+ }
641
+ const codepoint = funcCodes[keyNum]
642
+ if (codepoint !== undefined) {
643
+ _lastEventType = eventType
644
+ return { codepoint, modifier: modValue - 1, eventType }
645
+ }
646
+ }
647
+
648
+ // Home/End with modifier: \x1b[1;<mod>H/F or \x1b[1;<mod>:<event>H/F
649
+ const homeEndMatch = data.match(/^\x1b\[1;(\d+)(?::(\d+))?([HF])$/)
650
+ if (homeEndMatch) {
651
+ const modValue = parseInt(homeEndMatch[1]!, 10)
652
+ const eventType = parseEventType(homeEndMatch[2])
653
+ const codepoint =
654
+ homeEndMatch[3] === 'H' ? FUNCTIONAL_CODEPOINTS.home : FUNCTIONAL_CODEPOINTS.end
655
+ _lastEventType = eventType
656
+ return { codepoint, modifier: modValue - 1, eventType }
657
+ }
658
+
659
+ return null
660
+ }
661
+
662
+ function matchesKittySequence(
663
+ data: string,
664
+ expectedCodepoint: number,
665
+ expectedModifier: number,
666
+ ): boolean {
667
+ const parsed = parseKittySequence(data)
668
+ if (!parsed) return false
669
+ const actualMod = parsed.modifier & ~LOCK_MASK
670
+ const expectedMod = expectedModifier & ~LOCK_MASK
671
+
672
+ // Check if modifiers match
673
+ if (actualMod !== expectedMod) return false
674
+
675
+ const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint(
676
+ normalizeKittyFunctionalCodepoint(parsed.codepoint),
677
+ parsed.modifier,
678
+ )
679
+ const normalizedExpectedCodepoint = normalizeShiftedLetterIdentityCodepoint(
680
+ normalizeKittyFunctionalCodepoint(expectedCodepoint),
681
+ expectedModifier,
682
+ )
683
+
684
+ // Primary match: codepoint matches directly after normalizing functional keys
685
+ if (normalizedCodepoint === normalizedExpectedCodepoint) return true
686
+
687
+ // Alternate match: use base layout key for non-Latin keyboard layouts.
688
+ // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
689
+ // the base layout key (the key in standard PC-101 layout).
690
+ //
691
+ // Only fall back to base layout key when the codepoint is NOT already a
692
+ // recognized Latin letter (a-z) or symbol (e.g., /, -, [, ;, etc.).
693
+ // When the codepoint is a recognized key, it is authoritative regardless
694
+ // of physical key position. This prevents remapped layouts (Dvorak, Colemak,
695
+ // xremap, etc.) from causing false matches: both letters and symbols move
696
+ // to different physical positions, so Ctrl+K could falsely match Ctrl+V
697
+ // (letter remapping) and Ctrl+/ could falsely match Ctrl+[ (symbol remapping)
698
+ // if the base layout key were always considered.
699
+ if (parsed.baseLayoutKey !== undefined && parsed.baseLayoutKey === expectedCodepoint) {
700
+ const cp = normalizedCodepoint
701
+ const isLatinLetter = cp >= 97 && cp <= 122 // a-z
702
+ const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(cp))
703
+ if (!isLatinLetter && !isKnownSymbol) return true
704
+ }
705
+
706
+ return false
707
+ }
708
+
709
+ function parseModifyOtherKeysSequence(data: string): ParsedModifyOtherKeysSequence | null {
710
+ const match = data.match(/^\x1b\[27;(\d+);(\d+)~$/)
711
+ if (!match) return null
712
+ const modValue = parseInt(match[1]!, 10)
713
+ const codepoint = parseInt(match[2]!, 10)
714
+ return { codepoint, modifier: modValue - 1 }
715
+ }
716
+
717
+ /**
718
+ * Match xterm modifyOtherKeys format: CSI 27 ; modifiers ; keycode ~
719
+ * This is used by terminals when Kitty protocol is not enabled.
720
+ * Modifier values are 1-indexed: 2=shift, 3=alt, 5=ctrl, etc.
721
+ */
722
+ function matchesModifyOtherKeys(
723
+ data: string,
724
+ expectedKeycode: number,
725
+ expectedModifier: number,
726
+ ): boolean {
727
+ const parsed = parseModifyOtherKeysSequence(data)
728
+ if (!parsed) return false
729
+ return parsed.codepoint === expectedKeycode && parsed.modifier === expectedModifier
730
+ }
731
+
732
+ function isWindowsTerminalSession(): boolean {
733
+ return (
734
+ Boolean(process.env.WT_SESSION) &&
735
+ !process.env.SSH_CONNECTION &&
736
+ !process.env.SSH_CLIENT &&
737
+ !process.env.SSH_TTY
738
+ )
739
+ }
740
+
741
+ /**
742
+ * Raw 0x08 (BS) is ambiguous in legacy terminals.
743
+ *
744
+ * - Windows Terminal uses it for Ctrl+Backspace.
745
+ * - Some legacy terminals and tmux setups send it for plain Backspace.
746
+ *
747
+ * Prefer explicit Kitty / CSI-u / modifyOtherKeys sequences whenever they are
748
+ * available. Fall back to a Windows Terminal heuristic only for raw BS bytes.
749
+ */
750
+ function matchesRawBackspace(data: string, expectedModifier: number): boolean {
751
+ if (data === '\x7f') return expectedModifier === 0
752
+ if (data !== '\x08') return false
753
+ return isWindowsTerminalSession() ? expectedModifier === MODIFIERS.ctrl : expectedModifier === 0
754
+ }
755
+
756
+ // =============================================================================
757
+ // Generic Key Matching
758
+ // =============================================================================
759
+
760
+ /**
761
+ * Get the control character for a key.
762
+ * Uses the universal formula: code & 0x1f (mask to lower 5 bits)
763
+ *
764
+ * Works for:
765
+ * - Letters a-z → 1-26
766
+ * - Symbols [\]_ → 27, 28, 29, 31
767
+ * - Also maps - to same as _ (same physical key on US keyboards)
768
+ */
769
+ function rawCtrlChar(key: string): string | null {
770
+ const char = key.toLowerCase()
771
+ const code = char.charCodeAt(0)
772
+ if (
773
+ (code >= 97 && code <= 122) ||
774
+ char === '[' ||
775
+ char === '\\' ||
776
+ char === ']' ||
777
+ char === '_'
778
+ ) {
779
+ return String.fromCharCode(code & 0x1f)
780
+ }
781
+ // Handle - as _ (same physical key on US keyboards)
782
+ if (char === '-') {
783
+ return String.fromCharCode(31) // Same as Ctrl+_
784
+ }
785
+ return null
786
+ }
787
+
788
+ function isDigitKey(key: string): boolean {
789
+ return key >= '0' && key <= '9'
790
+ }
791
+
792
+ function matchesPrintableModifyOtherKeys(
793
+ data: string,
794
+ expectedKeycode: number,
795
+ expectedModifier: number,
796
+ ): boolean {
797
+ if (expectedModifier === 0) return false
798
+ const parsed = parseModifyOtherKeysSequence(data)
799
+ if (!parsed || parsed.modifier !== expectedModifier) return false
800
+ return (
801
+ normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) ===
802
+ normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier)
803
+ )
804
+ }
805
+
806
+ function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined {
807
+ const mods: string[] = []
808
+ const effectiveMod = modifier & ~LOCK_MASK
809
+ const supportedModifierMask = MODIFIERS.shift | MODIFIERS.ctrl | MODIFIERS.alt | MODIFIERS.super
810
+ if ((effectiveMod & ~supportedModifierMask) !== 0) return undefined
811
+ if (effectiveMod & MODIFIERS.shift) mods.push('shift')
812
+ if (effectiveMod & MODIFIERS.ctrl) mods.push('ctrl')
813
+ if (effectiveMod & MODIFIERS.alt) mods.push('alt')
814
+ if (effectiveMod & MODIFIERS.super) mods.push('super')
815
+ return mods.length > 0 ? `${mods.join('+')}+${keyName}` : keyName
816
+ }
817
+
818
+ function parseKeyId(
819
+ keyId: string,
820
+ ): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null {
821
+ const parts = keyId.toLowerCase().split('+')
822
+ const key = parts[parts.length - 1]
823
+ if (!key) return null
824
+ return {
825
+ key,
826
+ ctrl: parts.includes('ctrl'),
827
+ shift: parts.includes('shift'),
828
+ alt: parts.includes('alt'),
829
+ super: parts.includes('super'),
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Match input data against a key identifier string.
835
+ *
836
+ * Supported key identifiers:
837
+ * - Single keys: "escape", "tab", "enter", "backspace", "delete", "home", "end", "space"
838
+ * - Arrow keys: "up", "down", "left", "right"
839
+ * - Ctrl combinations: "ctrl+c", "ctrl+z", etc.
840
+ * - Shift combinations: "shift+tab", "shift+enter"
841
+ * - Alt combinations: "alt+enter", "alt+backspace"
842
+ * - Super combinations: "super+k", "super+enter"
843
+ * - Combined modifiers: "shift+ctrl+p", "ctrl+alt+x", "ctrl+super+k"
844
+ *
845
+ * Use the Key helper for autocomplete: Key.ctrl("c"), Key.escape, Key.ctrlShift("p"), Key.super("k")
846
+ *
847
+ * @param data - Raw input data from terminal
848
+ * @param keyId - Key identifier (e.g., "ctrl+c", "escape", Key.ctrl("c"))
849
+ */
850
+ export function matchesKey(data: string, keyId: KeyId): boolean {
851
+ const parsed = parseKeyId(keyId)
852
+ if (!parsed) return false
853
+
854
+ const { key, ctrl, shift, alt, super: superModifier } = parsed
855
+ let modifier = 0
856
+ if (shift) modifier |= MODIFIERS.shift
857
+ if (alt) modifier |= MODIFIERS.alt
858
+ if (ctrl) modifier |= MODIFIERS.ctrl
859
+ if (superModifier) modifier |= MODIFIERS.super
860
+
861
+ switch (key) {
862
+ case 'escape':
863
+ case 'esc':
864
+ if (modifier !== 0) return false
865
+ return (
866
+ data === '\x1b' ||
867
+ matchesKittySequence(data, CODEPOINTS.escape, 0) ||
868
+ matchesModifyOtherKeys(data, CODEPOINTS.escape, 0)
869
+ )
870
+
871
+ case 'space':
872
+ if (!_kittyProtocolActive) {
873
+ if (modifier === MODIFIERS.ctrl && data === '\x00') {
874
+ return true
875
+ }
876
+ if (modifier === MODIFIERS.alt && data === '\x1b ') {
877
+ return true
878
+ }
879
+ }
880
+ if (modifier === 0) {
881
+ return (
882
+ data === ' ' ||
883
+ matchesKittySequence(data, CODEPOINTS.space, 0) ||
884
+ matchesModifyOtherKeys(data, CODEPOINTS.space, 0)
885
+ )
886
+ }
887
+ return (
888
+ matchesKittySequence(data, CODEPOINTS.space, modifier) ||
889
+ matchesModifyOtherKeys(data, CODEPOINTS.space, modifier)
890
+ )
891
+
892
+ case 'tab':
893
+ if (modifier === MODIFIERS.shift) {
894
+ return (
895
+ data === '\x1b[Z' ||
896
+ matchesKittySequence(data, CODEPOINTS.tab, MODIFIERS.shift) ||
897
+ matchesModifyOtherKeys(data, CODEPOINTS.tab, MODIFIERS.shift)
898
+ )
899
+ }
900
+ if (modifier === 0) {
901
+ return data === '\t' || matchesKittySequence(data, CODEPOINTS.tab, 0)
902
+ }
903
+ return (
904
+ matchesKittySequence(data, CODEPOINTS.tab, modifier) ||
905
+ matchesModifyOtherKeys(data, CODEPOINTS.tab, modifier)
906
+ )
907
+
908
+ case 'enter':
909
+ case 'return':
910
+ if (modifier === MODIFIERS.shift) {
911
+ // CSI u sequences (standard Kitty protocol)
912
+ if (
913
+ matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.shift) ||
914
+ matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.shift)
915
+ ) {
916
+ return true
917
+ }
918
+ // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
919
+ if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.shift)) {
920
+ return true
921
+ }
922
+ // When Kitty protocol is active, legacy sequences are custom terminal mappings
923
+ // \x1b\r = Kitty's "map shift+enter send_text all \e\r"
924
+ // \n = Ghostty's "keybind = shift+enter=text:\n"
925
+ if (_kittyProtocolActive) {
926
+ return data === '\x1b\r' || data === '\n'
927
+ }
928
+ return false
929
+ }
930
+ if (modifier === MODIFIERS.alt) {
931
+ // CSI u sequences (standard Kitty protocol)
932
+ if (
933
+ matchesKittySequence(data, CODEPOINTS.enter, MODIFIERS.alt) ||
934
+ matchesKittySequence(data, CODEPOINTS.kpEnter, MODIFIERS.alt)
935
+ ) {
936
+ return true
937
+ }
938
+ // xterm modifyOtherKeys format (fallback when Kitty protocol not enabled)
939
+ if (matchesModifyOtherKeys(data, CODEPOINTS.enter, MODIFIERS.alt)) {
940
+ return true
941
+ }
942
+ // \x1b\r is alt+enter only in legacy mode (no Kitty protocol)
943
+ // When Kitty protocol is active, alt+enter comes as CSI u sequence
944
+ if (!_kittyProtocolActive) {
945
+ return data === '\x1b\r'
946
+ }
947
+ return false
948
+ }
949
+ if (modifier === 0) {
950
+ return (
951
+ data === '\r' ||
952
+ (!_kittyProtocolActive && data === '\n') ||
953
+ data === '\x1bOM' || // SS3 M (numpad enter in some terminals)
954
+ matchesKittySequence(data, CODEPOINTS.enter, 0) ||
955
+ matchesKittySequence(data, CODEPOINTS.kpEnter, 0)
956
+ )
957
+ }
958
+ return (
959
+ matchesKittySequence(data, CODEPOINTS.enter, modifier) ||
960
+ matchesKittySequence(data, CODEPOINTS.kpEnter, modifier) ||
961
+ matchesModifyOtherKeys(data, CODEPOINTS.enter, modifier)
962
+ )
963
+
964
+ case 'backspace':
965
+ if (modifier === MODIFIERS.alt) {
966
+ if (data === '\x1b\x7f' || data === '\x1b\b') {
967
+ return true
968
+ }
969
+ return (
970
+ matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.alt) ||
971
+ matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.alt)
972
+ )
973
+ }
974
+ if (modifier === MODIFIERS.ctrl) {
975
+ // Legacy raw 0x08 is ambiguous: it can be Ctrl+Backspace on Windows
976
+ // Terminal or plain Backspace on other terminals, while also
977
+ // overlapping with Ctrl+H.
978
+ if (matchesRawBackspace(data, MODIFIERS.ctrl)) return true
979
+ return (
980
+ matchesKittySequence(data, CODEPOINTS.backspace, MODIFIERS.ctrl) ||
981
+ matchesModifyOtherKeys(data, CODEPOINTS.backspace, MODIFIERS.ctrl)
982
+ )
983
+ }
984
+ if (modifier === 0) {
985
+ return (
986
+ matchesRawBackspace(data, 0) ||
987
+ matchesKittySequence(data, CODEPOINTS.backspace, 0) ||
988
+ matchesModifyOtherKeys(data, CODEPOINTS.backspace, 0)
989
+ )
990
+ }
991
+ return (
992
+ matchesKittySequence(data, CODEPOINTS.backspace, modifier) ||
993
+ matchesModifyOtherKeys(data, CODEPOINTS.backspace, modifier)
994
+ )
995
+
996
+ case 'insert':
997
+ if (modifier === 0) {
998
+ return (
999
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) ||
1000
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0)
1001
+ )
1002
+ }
1003
+ if (matchesLegacyModifierSequence(data, 'insert', modifier)) {
1004
+ return true
1005
+ }
1006
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, modifier)
1007
+
1008
+ case 'delete':
1009
+ if (modifier === 0) {
1010
+ return (
1011
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) ||
1012
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0)
1013
+ )
1014
+ }
1015
+ if (matchesLegacyModifierSequence(data, 'delete', modifier)) {
1016
+ return true
1017
+ }
1018
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, modifier)
1019
+
1020
+ case 'clear':
1021
+ if (modifier === 0) {
1022
+ return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear)
1023
+ }
1024
+ return matchesLegacyModifierSequence(data, 'clear', modifier)
1025
+
1026
+ case 'home':
1027
+ if (modifier === 0) {
1028
+ return (
1029
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) ||
1030
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0)
1031
+ )
1032
+ }
1033
+ if (matchesLegacyModifierSequence(data, 'home', modifier)) {
1034
+ return true
1035
+ }
1036
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, modifier)
1037
+
1038
+ case 'end':
1039
+ if (modifier === 0) {
1040
+ return (
1041
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) ||
1042
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0)
1043
+ )
1044
+ }
1045
+ if (matchesLegacyModifierSequence(data, 'end', modifier)) {
1046
+ return true
1047
+ }
1048
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, modifier)
1049
+
1050
+ case 'pageup':
1051
+ if (modifier === 0) {
1052
+ return (
1053
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) ||
1054
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0)
1055
+ )
1056
+ }
1057
+ if (matchesLegacyModifierSequence(data, 'pageUp', modifier)) {
1058
+ return true
1059
+ }
1060
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, modifier)
1061
+
1062
+ case 'pagedown':
1063
+ if (modifier === 0) {
1064
+ return (
1065
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) ||
1066
+ matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0)
1067
+ )
1068
+ }
1069
+ if (matchesLegacyModifierSequence(data, 'pageDown', modifier)) {
1070
+ return true
1071
+ }
1072
+ return matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, modifier)
1073
+
1074
+ case 'up':
1075
+ if (modifier === MODIFIERS.alt) {
1076
+ return data === '\x1bp' || matchesKittySequence(data, ARROW_CODEPOINTS.up, MODIFIERS.alt)
1077
+ }
1078
+ if (modifier === 0) {
1079
+ return (
1080
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) ||
1081
+ matchesKittySequence(data, ARROW_CODEPOINTS.up, 0)
1082
+ )
1083
+ }
1084
+ if (matchesLegacyModifierSequence(data, 'up', modifier)) {
1085
+ return true
1086
+ }
1087
+ return matchesKittySequence(data, ARROW_CODEPOINTS.up, modifier)
1088
+
1089
+ case 'down':
1090
+ if (modifier === MODIFIERS.alt) {
1091
+ return data === '\x1bn' || matchesKittySequence(data, ARROW_CODEPOINTS.down, MODIFIERS.alt)
1092
+ }
1093
+ if (modifier === 0) {
1094
+ return (
1095
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) ||
1096
+ matchesKittySequence(data, ARROW_CODEPOINTS.down, 0)
1097
+ )
1098
+ }
1099
+ if (matchesLegacyModifierSequence(data, 'down', modifier)) {
1100
+ return true
1101
+ }
1102
+ return matchesKittySequence(data, ARROW_CODEPOINTS.down, modifier)
1103
+
1104
+ case 'left':
1105
+ if (modifier === MODIFIERS.alt) {
1106
+ return (
1107
+ data === '\x1b[1;3D' ||
1108
+ (!_kittyProtocolActive && data === '\x1bB') ||
1109
+ data === '\x1bb' ||
1110
+ matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.alt)
1111
+ )
1112
+ }
1113
+ if (modifier === MODIFIERS.ctrl) {
1114
+ return (
1115
+ data === '\x1b[1;5D' ||
1116
+ matchesLegacyModifierSequence(data, 'left', MODIFIERS.ctrl) ||
1117
+ matchesKittySequence(data, ARROW_CODEPOINTS.left, MODIFIERS.ctrl)
1118
+ )
1119
+ }
1120
+ if (modifier === 0) {
1121
+ return (
1122
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) ||
1123
+ matchesKittySequence(data, ARROW_CODEPOINTS.left, 0)
1124
+ )
1125
+ }
1126
+ if (matchesLegacyModifierSequence(data, 'left', modifier)) {
1127
+ return true
1128
+ }
1129
+ return matchesKittySequence(data, ARROW_CODEPOINTS.left, modifier)
1130
+
1131
+ case 'right':
1132
+ if (modifier === MODIFIERS.alt) {
1133
+ return (
1134
+ data === '\x1b[1;3C' ||
1135
+ (!_kittyProtocolActive && data === '\x1bF') ||
1136
+ data === '\x1bf' ||
1137
+ matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.alt)
1138
+ )
1139
+ }
1140
+ if (modifier === MODIFIERS.ctrl) {
1141
+ return (
1142
+ data === '\x1b[1;5C' ||
1143
+ matchesLegacyModifierSequence(data, 'right', MODIFIERS.ctrl) ||
1144
+ matchesKittySequence(data, ARROW_CODEPOINTS.right, MODIFIERS.ctrl)
1145
+ )
1146
+ }
1147
+ if (modifier === 0) {
1148
+ return (
1149
+ matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) ||
1150
+ matchesKittySequence(data, ARROW_CODEPOINTS.right, 0)
1151
+ )
1152
+ }
1153
+ if (matchesLegacyModifierSequence(data, 'right', modifier)) {
1154
+ return true
1155
+ }
1156
+ return matchesKittySequence(data, ARROW_CODEPOINTS.right, modifier)
1157
+
1158
+ case 'f1':
1159
+ case 'f2':
1160
+ case 'f3':
1161
+ case 'f4':
1162
+ case 'f5':
1163
+ case 'f6':
1164
+ case 'f7':
1165
+ case 'f8':
1166
+ case 'f9':
1167
+ case 'f10':
1168
+ case 'f11':
1169
+ case 'f12': {
1170
+ if (modifier !== 0) {
1171
+ return false
1172
+ }
1173
+ const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES
1174
+ return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey])
1175
+ }
1176
+ }
1177
+
1178
+ // Handle single letter/digit keys and symbols
1179
+ if (key.length === 1 && ((key >= 'a' && key <= 'z') || isDigitKey(key) || SYMBOL_KEYS.has(key))) {
1180
+ const codepoint = key.charCodeAt(0)
1181
+ const rawCtrl = rawCtrlChar(key)
1182
+ const isLetter = key >= 'a' && key <= 'z'
1183
+ const isDigit = isDigitKey(key)
1184
+
1185
+ if (modifier === MODIFIERS.ctrl + MODIFIERS.alt && !_kittyProtocolActive && rawCtrl) {
1186
+ // Legacy: ctrl+alt+key is ESC followed by the control character.
1187
+ // If that legacy form does not match, continue so CSI-u and
1188
+ // modifyOtherKeys sequences from tmux can still be recognized.
1189
+ if (data === `\x1b${rawCtrl}`) return true
1190
+ }
1191
+
1192
+ if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) {
1193
+ // Legacy: alt+letter/digit is ESC followed by the key
1194
+ if (data === `\x1b${key}`) return true
1195
+ }
1196
+
1197
+ if (modifier === MODIFIERS.ctrl) {
1198
+ // Legacy: ctrl+key sends the control character
1199
+ if (rawCtrl && data === rawCtrl) return true
1200
+ return (
1201
+ matchesKittySequence(data, codepoint, MODIFIERS.ctrl) ||
1202
+ matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.ctrl)
1203
+ )
1204
+ }
1205
+
1206
+ if (modifier === MODIFIERS.shift + MODIFIERS.ctrl) {
1207
+ return (
1208
+ matchesKittySequence(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl) ||
1209
+ matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift + MODIFIERS.ctrl)
1210
+ )
1211
+ }
1212
+
1213
+ if (modifier === MODIFIERS.shift) {
1214
+ // Legacy: shift+letter produces uppercase
1215
+ if (isLetter && data === key.toUpperCase()) return true
1216
+ return (
1217
+ matchesKittySequence(data, codepoint, MODIFIERS.shift) ||
1218
+ matchesPrintableModifyOtherKeys(data, codepoint, MODIFIERS.shift)
1219
+ )
1220
+ }
1221
+
1222
+ if (modifier !== 0) {
1223
+ return (
1224
+ matchesKittySequence(data, codepoint, modifier) ||
1225
+ matchesPrintableModifyOtherKeys(data, codepoint, modifier)
1226
+ )
1227
+ }
1228
+
1229
+ // Check both raw char and Kitty sequence (needed for release events)
1230
+ return data === key || matchesKittySequence(data, codepoint, 0)
1231
+ }
1232
+
1233
+ return false
1234
+ }
1235
+
1236
+ /**
1237
+ * Parse input data and return the key identifier if recognized.
1238
+ *
1239
+ * @param data - Raw input data from terminal
1240
+ * @returns Key identifier string (e.g., "ctrl+c") or undefined
1241
+ */
1242
+ function formatParsedKey(
1243
+ codepoint: number,
1244
+ modifier: number,
1245
+ baseLayoutKey?: number,
1246
+ ): string | undefined {
1247
+ const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint)
1248
+ const identityCodepoint = normalizeShiftedLetterIdentityCodepoint(normalizedCodepoint, modifier)
1249
+
1250
+ // Use base layout key only when codepoint is not a recognized Latin
1251
+ // letter (a-z), digit (0-9), or symbol (/, -, [, ;, etc.). For those,
1252
+ // the codepoint is authoritative regardless of physical key position.
1253
+ // This prevents remapped layouts (Dvorak, Colemak, xremap, etc.) from
1254
+ // reporting the wrong key name based on the QWERTY physical position.
1255
+ const isLatinLetter = identityCodepoint >= 97 && identityCodepoint <= 122 // a-z
1256
+ const isDigit = identityCodepoint >= 48 && identityCodepoint <= 57 // 0-9
1257
+ const isKnownSymbol = SYMBOL_KEYS.has(String.fromCharCode(identityCodepoint))
1258
+ const effectiveCodepoint =
1259
+ isLatinLetter || isDigit || isKnownSymbol
1260
+ ? identityCodepoint
1261
+ : (baseLayoutKey ?? identityCodepoint)
1262
+
1263
+ let keyName: string | undefined
1264
+ if (effectiveCodepoint === CODEPOINTS.escape) keyName = 'escape'
1265
+ else if (effectiveCodepoint === CODEPOINTS.tab) keyName = 'tab'
1266
+ else if (effectiveCodepoint === CODEPOINTS.enter || effectiveCodepoint === CODEPOINTS.kpEnter)
1267
+ keyName = 'enter'
1268
+ else if (effectiveCodepoint === CODEPOINTS.space) keyName = 'space'
1269
+ else if (effectiveCodepoint === CODEPOINTS.backspace) keyName = 'backspace'
1270
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.delete) keyName = 'delete'
1271
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.insert) keyName = 'insert'
1272
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.home) keyName = 'home'
1273
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.end) keyName = 'end'
1274
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageUp) keyName = 'pageUp'
1275
+ else if (effectiveCodepoint === FUNCTIONAL_CODEPOINTS.pageDown) keyName = 'pageDown'
1276
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.up) keyName = 'up'
1277
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.down) keyName = 'down'
1278
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.left) keyName = 'left'
1279
+ else if (effectiveCodepoint === ARROW_CODEPOINTS.right) keyName = 'right'
1280
+ else if (effectiveCodepoint >= 48 && effectiveCodepoint <= 57)
1281
+ keyName = String.fromCharCode(effectiveCodepoint)
1282
+ else if (effectiveCodepoint >= 97 && effectiveCodepoint <= 122)
1283
+ keyName = String.fromCharCode(effectiveCodepoint)
1284
+ else if (SYMBOL_KEYS.has(String.fromCharCode(effectiveCodepoint)))
1285
+ keyName = String.fromCharCode(effectiveCodepoint)
1286
+
1287
+ if (!keyName) return undefined
1288
+ return formatKeyNameWithModifiers(keyName, modifier)
1289
+ }
1290
+
1291
+ export function parseKey(data: string): string | undefined {
1292
+ const kitty = parseKittySequence(data)
1293
+ if (kitty) {
1294
+ return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey)
1295
+ }
1296
+
1297
+ const modifyOtherKeys = parseModifyOtherKeysSequence(data)
1298
+ if (modifyOtherKeys) {
1299
+ return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier)
1300
+ }
1301
+
1302
+ // Mode-aware legacy sequences
1303
+ // When Kitty protocol is active, ambiguous sequences are interpreted as custom terminal mappings:
1304
+ // - \x1b\r = shift+enter (Kitty mapping), not alt+enter
1305
+ // - \n = shift+enter (Ghostty mapping)
1306
+ if (_kittyProtocolActive) {
1307
+ if (data === '\x1b\r' || data === '\n') return 'shift+enter'
1308
+ }
1309
+
1310
+ const legacySequenceKeyId = LEGACY_SEQUENCE_KEY_IDS[data]
1311
+ if (legacySequenceKeyId) return legacySequenceKeyId
1312
+
1313
+ // Legacy sequences (used when Kitty protocol is not active, or for unambiguous sequences)
1314
+ if (data === '\x1b') return 'escape'
1315
+ if (data === '\x1c') return 'ctrl+\\'
1316
+ if (data === '\x1d') return 'ctrl+]'
1317
+ if (data === '\x1f') return 'ctrl+-'
1318
+ if (data === '\x1b\x1b') return 'ctrl+alt+['
1319
+ if (data === '\x1b\x1c') return 'ctrl+alt+\\'
1320
+ if (data === '\x1b\x1d') return 'ctrl+alt+]'
1321
+ if (data === '\x1b\x1f') return 'ctrl+alt+-'
1322
+ if (data === '\t') return 'tab'
1323
+ if (data === '\r' || (!_kittyProtocolActive && data === '\n') || data === '\x1bOM') return 'enter'
1324
+ if (data === '\x00') return 'ctrl+space'
1325
+ if (data === ' ') return 'space'
1326
+ if (data === '\x7f') return 'backspace'
1327
+ if (data === '\x08') return isWindowsTerminalSession() ? 'ctrl+backspace' : 'backspace'
1328
+ if (data === '\x1b[Z') return 'shift+tab'
1329
+ if (!_kittyProtocolActive && data === '\x1b\r') return 'alt+enter'
1330
+ if (!_kittyProtocolActive && data === '\x1b ') return 'alt+space'
1331
+ if (data === '\x1b\x7f' || data === '\x1b\b') return 'alt+backspace'
1332
+ if (!_kittyProtocolActive && data === '\x1bB') return 'alt+left'
1333
+ if (!_kittyProtocolActive && data === '\x1bF') return 'alt+right'
1334
+ if (!_kittyProtocolActive && data.length === 2 && data[0] === '\x1b') {
1335
+ const code = data.charCodeAt(1)
1336
+ if (code >= 1 && code <= 26) {
1337
+ return `ctrl+alt+${String.fromCharCode(code + 96)}`
1338
+ }
1339
+ // Legacy alt+letter/digit (ESC followed by the key)
1340
+ if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) {
1341
+ return `alt+${String.fromCharCode(code)}`
1342
+ }
1343
+ }
1344
+ if (data === '\x1b[A') return 'up'
1345
+ if (data === '\x1b[B') return 'down'
1346
+ if (data === '\x1b[C') return 'right'
1347
+ if (data === '\x1b[D') return 'left'
1348
+ if (data === '\x1b[H' || data === '\x1bOH') return 'home'
1349
+ if (data === '\x1b[F' || data === '\x1bOF') return 'end'
1350
+ if (data === '\x1b[3~') return 'delete'
1351
+ if (data === '\x1b[5~') return 'pageUp'
1352
+ if (data === '\x1b[6~') return 'pageDown'
1353
+
1354
+ // Raw Ctrl+letter
1355
+ if (data.length === 1) {
1356
+ const code = data.charCodeAt(0)
1357
+ if (code >= 1 && code <= 26) {
1358
+ return `ctrl+${String.fromCharCode(code + 96)}`
1359
+ }
1360
+ if (code >= 32 && code <= 126) {
1361
+ return data
1362
+ }
1363
+ }
1364
+
1365
+ return undefined
1366
+ }
1367
+
1368
+ // =============================================================================
1369
+ // Kitty CSI-u Printable Decoding
1370
+ // =============================================================================
1371
+
1372
+ const KITTY_CSI_U_REGEX = /^\x1b\[(\d+)(?::(\d*))?(?::(\d+))?(?:;(\d+))?(?::(\d+))?u$/
1373
+ const KITTY_PRINTABLE_ALLOWED_MODIFIERS = MODIFIERS.shift | LOCK_MASK
1374
+
1375
+ /**
1376
+ * Decode a Kitty CSI-u sequence into a printable character, if applicable.
1377
+ *
1378
+ * When Kitty keyboard protocol flag 1 (disambiguate) is active, terminals send
1379
+ * CSI-u sequences for all keys, including plain printable characters. This
1380
+ * function extracts the printable character from such sequences.
1381
+ *
1382
+ * Only accepts plain or Shift-modified keys. Rejects Ctrl, Alt, and unsupported
1383
+ * modifier combinations (those are handled by keybinding matching instead).
1384
+ * Prefers the shifted keycode when Shift is held and a shifted key is reported.
1385
+ *
1386
+ * @param data - Raw input data from terminal
1387
+ * @returns The printable character, or undefined if not a printable CSI-u sequence
1388
+ */
1389
+ export function decodeKittyPrintable(data: string): string | undefined {
1390
+ const match = data.match(KITTY_CSI_U_REGEX)
1391
+ if (!match) return undefined
1392
+
1393
+ // CSI-u groups: <codepoint>[:<shifted>[:<base>]];<mod>[:<event>]u
1394
+ const codepoint = Number.parseInt(match[1] ?? '', 10)
1395
+ if (!Number.isFinite(codepoint)) return undefined
1396
+
1397
+ const shiftedKey = match[2] && match[2].length > 0 ? Number.parseInt(match[2], 10) : undefined
1398
+ const modValue = match[4] ? Number.parseInt(match[4], 10) : 1
1399
+ // Modifiers are 1-indexed in CSI-u; normalize to our bitmask.
1400
+ const modifier = Number.isFinite(modValue) ? modValue - 1 : 0
1401
+
1402
+ // Only accept printable CSI-u input for plain or Shift-modified text keys.
1403
+ // Reject unsupported modifier bits (e.g. Super/Meta) to avoid inserting
1404
+ // characters from modifier-only terminal events.
1405
+ if ((modifier & ~KITTY_PRINTABLE_ALLOWED_MODIFIERS) !== 0) return undefined
1406
+ if (modifier & (MODIFIERS.alt | MODIFIERS.ctrl)) return undefined
1407
+
1408
+ // Prefer the shifted keycode when Shift is held.
1409
+ let effectiveCodepoint = codepoint
1410
+ if (modifier & MODIFIERS.shift && typeof shiftedKey === 'number') {
1411
+ effectiveCodepoint = shiftedKey
1412
+ }
1413
+ effectiveCodepoint = normalizeKittyFunctionalCodepoint(effectiveCodepoint)
1414
+ // Drop control characters or invalid codepoints.
1415
+ if (!Number.isFinite(effectiveCodepoint) || effectiveCodepoint < 32) return undefined
1416
+
1417
+ try {
1418
+ return String.fromCodePoint(effectiveCodepoint)
1419
+ } catch {
1420
+ return undefined
1421
+ }
1422
+ }
1423
+
1424
+ function decodeModifyOtherKeysPrintable(data: string): string | undefined {
1425
+ const parsed = parseModifyOtherKeysSequence(data)
1426
+ if (!parsed) return undefined
1427
+ const modifier = parsed.modifier & ~LOCK_MASK
1428
+ if ((modifier & ~MODIFIERS.shift) !== 0) return undefined
1429
+ if (!Number.isFinite(parsed.codepoint) || parsed.codepoint < 32) return undefined
1430
+
1431
+ try {
1432
+ return String.fromCodePoint(parsed.codepoint)
1433
+ } catch {
1434
+ return undefined
1435
+ }
1436
+ }
1437
+
1438
+ export function decodePrintableKey(data: string): string | undefined {
1439
+ return decodeKittyPrintable(data) ?? decodeModifyOtherKeysPrintable(data)
1440
+ }