| 215 | } |
| 216 | |
| 217 | export function parseMultipleKeypresses( |
| 218 | prevState: KeyParseState, |
| 219 | input: Buffer | string | null = '', |
| 220 | ): [ParsedInput[], KeyParseState] { |
| 221 | const isFlush = input === null |
| 222 | const inputString = isFlush ? '' : inputToString(input) |
| 223 | |
| 224 | // Get or create tokenizer |
| 225 | const tokenizer = prevState._tokenizer ?? createTokenizer({ x10Mouse: true }) |
| 226 | |
| 227 | // Tokenize the input |
| 228 | const tokens = isFlush ? tokenizer.flush() : tokenizer.feed(inputString) |
| 229 | |
| 230 | // Convert tokens to parsed keys, handling paste mode |
| 231 | const keys: ParsedInput[] = [] |
| 232 | let inPaste = prevState.mode === 'IN_PASTE' |
| 233 | let pasteBuffer = prevState.pasteBuffer |
| 234 | |
| 235 | for (const token of tokens) { |
| 236 | if (token.type === 'sequence') { |
| 237 | if (token.value === PASTE_START) { |
| 238 | inPaste = true |
| 239 | pasteBuffer = '' |
| 240 | } else if (token.value === PASTE_END) { |
| 241 | // Always emit a paste key, even for empty pastes. This allows |
| 242 | // downstream handlers to detect empty pastes (e.g., for clipboard |
| 243 | // image handling on macOS). The paste content may be empty string. |
| 244 | keys.push(createPasteKey(pasteBuffer)) |
| 245 | inPaste = false |
| 246 | pasteBuffer = '' |
| 247 | } else if (inPaste) { |
| 248 | // Sequences inside paste are treated as literal text |
| 249 | pasteBuffer += token.value |
| 250 | } else { |
| 251 | const response = parseTerminalResponse(token.value) |
| 252 | if (response) { |
| 253 | keys.push({ kind: 'response', sequence: token.value, response }) |
| 254 | } else { |
| 255 | const mouse = parseMouseEvent(token.value) |
| 256 | if (mouse) { |
| 257 | keys.push(mouse) |
| 258 | } else { |
| 259 | keys.push(parseKeypress(token.value)) |
| 260 | } |
| 261 | } |
| 262 | } |
| 263 | } else if (token.type === 'text') { |
| 264 | if (inPaste) { |
| 265 | pasteBuffer += token.value |
| 266 | } else if ( |
| 267 | /^\[<\d+;\d+;\d+[Mm]$/.test(token.value) || |
| 268 | /^\[M[\x60-\x7f][\x20-\uffff]{2}$/.test(token.value) |
| 269 | ) { |
| 270 | // Orphaned SGR/X10 mouse tail (fullscreen only — mouse tracking is off |
| 271 | // otherwise). A heavy render blocked the event loop past App's 50ms |
| 272 | // flush timer, so the buffered ESC was flushed as a lone Escape and |
| 273 | // the continuation `[<btn;col;rowM` arrived as text. Re-synthesize |
| 274 | // with the ESC prefix so the scroll event still fires instead of |