| 720 | // ─── Input Handling ────────────────────────────────────────────────── |
| 721 | |
| 722 | async _onKeypress(data) { |
| 723 | const key = data.toString(); |
| 724 | |
| 725 | // Bracketed paste detection — strip paste markers and handle as text |
| 726 | if (key.includes('\x1b[200~')) { |
| 727 | const cleaned = key.replace(/\x1b\[200~/g, '').replace(/\x1b\[201~/g, ''); |
| 728 | if (cleaned.length > 0) { |
| 729 | const printable = cleaned.split('').filter(c => c.charCodeAt(0) >= 32 || c === '\n').join(''); |
| 730 | // Replace newlines with spaces for single-line input |
| 731 | const text = printable.replace(/\n/g, ' '); |
| 732 | this.inputBuffer = this.inputBuffer.slice(0, this.inputCursor) + text + this.inputBuffer.slice(this.inputCursor); |
| 733 | this.inputCursor += text.length; |
| 734 | this.commandPaletteOpen = this.inputBuffer.startsWith('/'); |
| 735 | this.render(); |
| 736 | } |
| 737 | return; |
| 738 | } |
| 739 | |
| 740 | // Ctrl+C — exit |
| 741 | if (key === '\x03') { |
| 742 | this.leave(); |
| 743 | this.onExit(); |
| 744 | return; |
| 745 | } |
| 746 | |
| 747 | // Ctrl+D — exit |
| 748 | if (key === '\x04') { |
| 749 | this.leave(); |
| 750 | this.onExit(); |
| 751 | return; |
| 752 | } |
| 753 | |
| 754 | // Ctrl+Z — suspend cleanly. In raw mode the kernel delivers Ctrl+Z as a |
| 755 | // raw byte (0x1a) rather than generating SIGTSTP, so we trigger the |
| 756 | // controller's suspend path ourselves to restore the terminal first |
| 757 | // (issue #71). On `fg`, SIGCONT re-enters the TUI and redraws. |
| 758 | if (key === '\x1a') { |
| 759 | if (this._terminal) this._terminal.suspend(); |
| 760 | return; |
| 761 | } |
| 762 | |
| 763 | // Enter — submit |
| 764 | if (key === '\r' || key === '\n') { |
| 765 | // If command palette is open, select and execute immediately |
| 766 | if (this.commandPaletteOpen) { |
| 767 | const filter = this.inputBuffer.slice(1).toLowerCase(); |
| 768 | const filtered = this.commands.filter(c => |
| 769 | c.cmd.slice(1).startsWith(filter) || (c.alias && c.alias.slice(1).startsWith(filter)) |
| 770 | ); |
| 771 | if (filtered.length > 0) { |
| 772 | const selected = filtered[Math.min(this.commandPaletteSelection, filtered.length - 1)]; |
| 773 | this.inputBuffer = selected.cmd; |
| 774 | this.inputCursor = this.inputBuffer.length; |
| 775 | } |
| 776 | this.commandPaletteOpen = false; |
| 777 | this.commandPaletteSelection = 0; |
| 778 | this._paletteScrollOffset = 0; |
| 779 | // Fall through to execute the command below (don't return) |