Something went wrong. Try again.
fork of prime agent with changes i want but are not upstreamable probqbly (and some fixes)
Something went wrong. Try again.
15 kB · 488 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489import { 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<InputState>();
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]; }}