import { getKeybindings } from "../keybindings.js"; import { decodeKittyPrintable } from "../keys.js"; import { KillRing } from "../kill-ring.js"; import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js"; import { UndoStack } from "../undo-stack.js"; import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js"; const segmenter = getSegmenter(); interface InputState { value: string; cursor: number; } export interface InputOptions { /** * Render the value as bullets while keeping the real buffer for edits and * submit: a pasted secret is never drawn, but getValue()/onSubmit still * return exactly what was typed. Cursor math maps by grapheme so editing * (backspace, word moves, paste) behaves exactly like the unmasked input. */ masked?: boolean; } /** * Input component - single-line text input with horizontal scrolling */ export class Input implements Component, Focusable { private value: string = ""; private cursor: number = 0; // Cursor position in the value public onSubmit?: (value: string) => void; public onEscape?: () => void; focused: boolean = false; constructor(private readonly options: InputOptions = {}) {} private pasteBuffer: string = ""; private isInPaste: boolean = false; private killRing = new KillRing(); private lastAction: "kill" | "yank" | "type-word" | null = null; private undoStack = new UndoStack(); getValue(): string { return this.value; } getCursor(): number { return this.cursor; } setValue(value: string): void { this.value = value; this.cursor = Math.min(this.cursor, value.length); } handleInput(data: string): void { if (data.includes("\x1b[200~")) { this.isInPaste = true; this.pasteBuffer = ""; data = data.replace("\x1b[200~", ""); } if (this.isInPaste) { this.pasteBuffer += data; const endIndex = this.pasteBuffer.indexOf("\x1b[201~"); if (endIndex !== -1) { const pasteContent = this.pasteBuffer.substring(0, endIndex); this.handlePaste(pasteContent); this.isInPaste = false; const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~ this.pasteBuffer = ""; if (remaining) { this.handleInput(remaining); } } return; } const kb = getKeybindings(); if (kb.matches(data, "tui.select.cancel")) { if (this.onEscape) this.onEscape(); return; } if (kb.matches(data, "tui.editor.undo")) { this.undo(); return; } if (kb.matches(data, "tui.input.submit") || data === "\n") { if (this.onSubmit) this.onSubmit(this.value); return; } if (kb.matches(data, "tui.editor.deleteCharBackward")) { this.handleBackspace(); return; } if (kb.matches(data, "tui.editor.deleteCharForward")) { this.handleForwardDelete(); return; } if (kb.matches(data, "tui.editor.deleteWordBackward")) { this.deleteWordBackwards(); return; } if (kb.matches(data, "tui.editor.deleteWordForward")) { this.deleteWordForward(); return; } if (kb.matches(data, "tui.editor.deleteToLineStart")) { this.deleteToLineStart(); return; } if (kb.matches(data, "tui.editor.deleteToLineEnd")) { this.deleteToLineEnd(); return; } if (kb.matches(data, "tui.editor.yank")) { this.yank(); return; } if (kb.matches(data, "tui.editor.yankPop")) { this.yankPop(); return; } if (kb.matches(data, "tui.editor.cursorLeft")) { this.lastAction = null; if (this.cursor > 0) { const beforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1; } return; } if (kb.matches(data, "tui.editor.cursorRight")) { this.lastAction = null; if (this.cursor < this.value.length) { const afterCursor = this.value.slice(this.cursor); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1; } return; } if (kb.matches(data, "tui.editor.cursorLineStart")) { this.lastAction = null; this.cursor = 0; return; } if (kb.matches(data, "tui.editor.cursorLineEnd")) { this.lastAction = null; this.cursor = this.value.length; return; } if (kb.matches(data, "tui.editor.cursorWordLeft")) { this.moveWordBackwards(); return; } if (kb.matches(data, "tui.editor.cursorWordRight")) { this.moveWordForwards(); return; } // Kitty CSI-u printable character (e.g. \x1b[97u for 'a'). // Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys, // including plain printable characters. Decode before the control-char check // since CSI-u sequences contain \x1b which would be rejected. const kittyPrintable = decodeKittyPrintable(data); if (kittyPrintable !== undefined) { this.insertCharacter(kittyPrintable); return; } // Regular character input - accept printable characters including Unicode, // but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F) const hasControlChars = [...data].some((ch) => { const code = ch.charCodeAt(0); return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f); }); if (!hasControlChars) { this.insertCharacter(data); } } private insertCharacter(char: string): void { if (isWhitespaceChar(char) || this.lastAction !== "type-word") { this.pushUndo(); } this.lastAction = "type-word"; this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor); this.cursor += char.length; } private handleBackspace(): void { this.lastAction = null; if (this.cursor > 0) { this.pushUndo(); const beforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(beforeCursor)]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor); this.cursor -= graphemeLength; } } private handleForwardDelete(): void { this.lastAction = null; if (this.cursor < this.value.length) { this.pushUndo(); const afterCursor = this.value.slice(this.cursor); const graphemes = [...segmenter.segment(afterCursor)]; const firstGrapheme = graphemes[0]; const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1; this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength); } } private deleteToLineStart(): void { if (this.cursor === 0) return; this.pushUndo(); const deletedText = this.value.slice(0, this.cursor); this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; this.value = this.value.slice(this.cursor); this.cursor = 0; } private deleteToLineEnd(): void { if (this.cursor >= this.value.length) return; this.pushUndo(); const deletedText = this.value.slice(this.cursor); this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" }); this.lastAction = "kill"; this.value = this.value.slice(0, this.cursor); } private deleteWordBackwards(): void { if (this.cursor === 0) return; // Save lastAction before cursor movement (moveWordBackwards resets it) const wasKill = this.lastAction === "kill"; this.pushUndo(); const oldCursor = this.cursor; this.moveWordBackwards(); const deleteFrom = this.cursor; this.cursor = oldCursor; const deletedText = this.value.slice(deleteFrom, this.cursor); this.killRing.push(deletedText, { prepend: true, accumulate: wasKill }); this.lastAction = "kill"; this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor); this.cursor = deleteFrom; } private deleteWordForward(): void { if (this.cursor >= this.value.length) return; // Save lastAction before cursor movement (moveWordForwards resets it) const wasKill = this.lastAction === "kill"; this.pushUndo(); const oldCursor = this.cursor; this.moveWordForwards(); const deleteTo = this.cursor; this.cursor = oldCursor; const deletedText = this.value.slice(this.cursor, deleteTo); this.killRing.push(deletedText, { prepend: false, accumulate: wasKill }); this.lastAction = "kill"; this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo); } private yank(): void { const text = this.killRing.peek(); if (!text) return; this.pushUndo(); this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); this.cursor += text.length; this.lastAction = "yank"; } private yankPop(): void { if (this.lastAction !== "yank" || this.killRing.length <= 1) return; this.pushUndo(); const prevText = this.killRing.peek() || ""; this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor); this.cursor -= prevText.length; this.killRing.rotate(); const text = this.killRing.peek() || ""; this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor); this.cursor += text.length; this.lastAction = "yank"; } private pushUndo(): void { this.undoStack.push({ value: this.value, cursor: this.cursor }); } private undo(): void { const snapshot = this.undoStack.pop(); if (!snapshot) return; this.value = snapshot.value; this.cursor = snapshot.cursor; this.lastAction = null; } private moveWordBackwards(): void { if (this.cursor === 0) { return; } this.lastAction = null; const textBeforeCursor = this.value.slice(0, this.cursor); const graphemes = [...segmenter.segment(textBeforeCursor)]; while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) { this.cursor -= graphemes.pop()?.segment.length || 0; } if (graphemes.length > 0) { const lastGrapheme = graphemes[graphemes.length - 1]?.segment || ""; if (isPunctuationChar(lastGrapheme)) { while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) { this.cursor -= graphemes.pop()?.segment.length || 0; } } else { while ( graphemes.length > 0 && !isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") && !isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") ) { this.cursor -= graphemes.pop()?.segment.length || 0; } } } } private moveWordForwards(): void { if (this.cursor >= this.value.length) { return; } this.lastAction = null; const textAfterCursor = this.value.slice(this.cursor); const segments = segmenter.segment(textAfterCursor); const iterator = segments[Symbol.iterator](); let next = iterator.next(); while (!next.done && isWhitespaceChar(next.value.segment)) { this.cursor += next.value.segment.length; next = iterator.next(); } if (!next.done) { const firstGrapheme = next.value.segment; if (isPunctuationChar(firstGrapheme)) { while (!next.done && isPunctuationChar(next.value.segment)) { this.cursor += next.value.segment.length; next = iterator.next(); } } else { while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) { this.cursor += next.value.segment.length; next = iterator.next(); } } } } private handlePaste(pastedText: string): void { this.lastAction = null; this.pushUndo(); const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " "); this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor); this.cursor += cleanText.length; } invalidate(): void {} /** * The (value, cursor) pair rendering operates on. Unmasked input renders the * real buffer; masked input renders one bullet per grapheme with the cursor * mapped by grapheme count, so the displayed text never contains the secret * while the real buffer keeps every edit and submit. */ private displayState(): { value: string; cursor: number } { if (this.options.masked !== true) return { value: this.value, cursor: this.cursor }; const graphemes = [...segmenter.segment(this.value)]; const beforeCursor = [...segmenter.segment(this.value.slice(0, this.cursor))]; return { value: graphemes.map(() => "•").join(""), cursor: beforeCursor.length }; } render(width: number): string[] { const prompt = "> "; const availableWidth = width - prompt.length; if (availableWidth <= 0) { return [prompt]; } const display = this.displayState(); let visibleText = ""; let cursorDisplay = display.cursor; const totalWidth = visibleWidth(display.value); if (totalWidth < availableWidth) { visibleText = display.value; } else { const scrollWidth = display.cursor === display.value.length ? availableWidth - 1 : availableWidth; const cursorCol = visibleWidth(display.value.slice(0, display.cursor)); if (scrollWidth > 0) { const halfWidth = Math.floor(scrollWidth / 2); let startCol = 0; if (cursorCol < halfWidth) { startCol = 0; } else if (cursorCol > totalWidth - halfWidth) { startCol = Math.max(0, totalWidth - scrollWidth); } else { startCol = Math.max(0, cursorCol - halfWidth); } visibleText = sliceByColumn(display.value, startCol, scrollWidth, true); const beforeCursor = sliceByColumn(display.value, startCol, Math.max(0, cursorCol - startCol), true); cursorDisplay = beforeCursor.length; } else { visibleText = ""; cursorDisplay = 0; } } const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))]; const cursorGrapheme = graphemes[0]; const beforeCursor = visibleText.slice(0, cursorDisplay); const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end const afterCursor = visibleText.slice(cursorDisplay + atCursor.length); // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = this.focused ? CURSOR_MARKER : ""; const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal const textWithCursor = beforeCursor + marker + cursorChar + afterCursor; const visualLength = visibleWidth(textWithCursor); const padding = " ".repeat(Math.max(0, availableWidth - visualLength)); const line = prompt + textWithCursor + padding; return [line]; } }