mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-19 11:03:41 +00:00
521 lines
15 KiB
TypeScript
521 lines
15 KiB
TypeScript
import { getEditorKeybindings } 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, visibleWidth } from "../utils.js";
|
|
|
|
const segmenter = getSegmenter();
|
|
|
|
interface InputState {
|
|
value: string;
|
|
cursor: number;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
/** Focusable interface - set by TUI when focus changes */
|
|
focused: boolean = false;
|
|
|
|
// Bracketed paste mode buffering
|
|
private pasteBuffer: string = "";
|
|
private isInPaste: boolean = false;
|
|
|
|
// Kill ring for Emacs-style kill/yank operations
|
|
private killRing = new KillRing();
|
|
private lastAction: "kill" | "yank" | "type-word" | null = null;
|
|
|
|
// Undo support
|
|
private undoStack = new UndoStack<InputState>();
|
|
|
|
getValue(): string {
|
|
return this.value;
|
|
}
|
|
|
|
setValue(value: string): void {
|
|
this.value = value;
|
|
this.cursor = Math.min(this.cursor, value.length);
|
|
}
|
|
|
|
handleInput(data: string): void {
|
|
// Handle bracketed paste mode
|
|
// Start of paste: \x1b[200~
|
|
// End of paste: \x1b[201~
|
|
|
|
// Check if we're starting a bracketed paste
|
|
if (data.includes("\x1b[200~")) {
|
|
this.isInPaste = true;
|
|
this.pasteBuffer = "";
|
|
data = data.replace("\x1b[200~", "");
|
|
}
|
|
|
|
// If we're in a paste, buffer the data
|
|
if (this.isInPaste) {
|
|
// Check if this chunk contains the end marker
|
|
this.pasteBuffer += data;
|
|
|
|
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
|
|
if (endIndex !== -1) {
|
|
// Extract the pasted content
|
|
const pasteContent = this.pasteBuffer.substring(0, endIndex);
|
|
|
|
// Process the complete paste
|
|
this.handlePaste(pasteContent);
|
|
|
|
// Reset paste state
|
|
this.isInPaste = false;
|
|
|
|
// Handle any remaining input after the paste marker
|
|
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
|
|
this.pasteBuffer = "";
|
|
if (remaining) {
|
|
this.handleInput(remaining);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
const kb = getEditorKeybindings();
|
|
|
|
// Escape/Cancel
|
|
if (kb.matches(data, "selectCancel")) {
|
|
if (this.onEscape) this.onEscape();
|
|
return;
|
|
}
|
|
|
|
// Undo
|
|
if (kb.matches(data, "undo")) {
|
|
this.undo();
|
|
return;
|
|
}
|
|
|
|
// Submit
|
|
if (kb.matches(data, "submit") || data === "\n") {
|
|
if (this.onSubmit) this.onSubmit(this.value);
|
|
return;
|
|
}
|
|
|
|
// Deletion
|
|
if (kb.matches(data, "deleteCharBackward")) {
|
|
this.handleBackspace();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "deleteCharForward")) {
|
|
this.handleForwardDelete();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "deleteWordBackward")) {
|
|
this.deleteWordBackwards();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "deleteWordForward")) {
|
|
this.deleteWordForward();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "deleteToLineStart")) {
|
|
this.deleteToLineStart();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "deleteToLineEnd")) {
|
|
this.deleteToLineEnd();
|
|
return;
|
|
}
|
|
|
|
// Kill ring actions
|
|
if (kb.matches(data, "yank")) {
|
|
this.yank();
|
|
return;
|
|
}
|
|
if (kb.matches(data, "yankPop")) {
|
|
this.yankPop();
|
|
return;
|
|
}
|
|
|
|
// Cursor movement
|
|
if (kb.matches(data, "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, "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, "cursorLineStart")) {
|
|
this.lastAction = null;
|
|
this.cursor = 0;
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "cursorLineEnd")) {
|
|
this.lastAction = null;
|
|
this.cursor = this.value.length;
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "cursorWordLeft")) {
|
|
this.moveWordBackwards();
|
|
return;
|
|
}
|
|
|
|
if (kb.matches(data, "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 {
|
|
// Undo coalescing: consecutive word chars coalesce into one undo unit
|
|
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();
|
|
|
|
// Delete the previously yanked text (still at end of ring before rotation)
|
|
const prevText = this.killRing.peek() || "";
|
|
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
|
|
this.cursor -= prevText.length;
|
|
|
|
// Rotate and insert new entry
|
|
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)];
|
|
|
|
// Skip trailing whitespace
|
|
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)) {
|
|
// Skip punctuation run
|
|
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
|
this.cursor -= graphemes.pop()?.segment.length || 0;
|
|
}
|
|
} else {
|
|
// Skip word run
|
|
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();
|
|
|
|
// Skip leading whitespace
|
|
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)) {
|
|
// Skip punctuation run
|
|
while (!next.done && isPunctuationChar(next.value.segment)) {
|
|
this.cursor += next.value.segment.length;
|
|
next = iterator.next();
|
|
}
|
|
} else {
|
|
// Skip word run
|
|
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();
|
|
|
|
// Clean the pasted text - remove newlines and carriage returns
|
|
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "");
|
|
|
|
// Insert at cursor position
|
|
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
|
|
this.cursor += cleanText.length;
|
|
}
|
|
|
|
invalidate(): void {
|
|
// No cached state to invalidate currently
|
|
}
|
|
|
|
render(width: number): string[] {
|
|
// Calculate visible window
|
|
const prompt = "> ";
|
|
const availableWidth = width - prompt.length;
|
|
|
|
if (availableWidth <= 0) {
|
|
return [prompt];
|
|
}
|
|
|
|
let visibleText = "";
|
|
let cursorDisplay = this.cursor;
|
|
|
|
if (this.value.length < availableWidth) {
|
|
// Everything fits (leave room for cursor at end)
|
|
visibleText = this.value;
|
|
} else {
|
|
// Need horizontal scrolling
|
|
// Reserve one character for cursor if it's at the end
|
|
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
|
|
const halfWidth = Math.floor(scrollWidth / 2);
|
|
|
|
const findValidStart = (start: number) => {
|
|
while (start < this.value.length) {
|
|
const charCode = this.value.charCodeAt(start);
|
|
// this is low surrogate, not a valid start
|
|
if (charCode >= 0xdc00 && charCode < 0xe000) {
|
|
start++;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return start;
|
|
};
|
|
|
|
const findValidEnd = (end: number) => {
|
|
while (end > 0) {
|
|
const charCode = this.value.charCodeAt(end - 1);
|
|
// this is high surrogate, might be split.
|
|
if (charCode >= 0xd800 && charCode < 0xdc00) {
|
|
end--;
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
return end;
|
|
};
|
|
|
|
if (this.cursor < halfWidth) {
|
|
// Cursor near start
|
|
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
|
|
cursorDisplay = this.cursor;
|
|
} else if (this.cursor > this.value.length - halfWidth) {
|
|
// Cursor near end
|
|
const start = findValidStart(this.value.length - scrollWidth);
|
|
visibleText = this.value.slice(start);
|
|
cursorDisplay = this.cursor - start;
|
|
} else {
|
|
// Cursor in middle
|
|
const start = findValidStart(this.cursor - halfWidth);
|
|
visibleText = this.value.slice(start, findValidEnd(start + scrollWidth));
|
|
cursorDisplay = halfWidth;
|
|
}
|
|
}
|
|
|
|
// Build line with fake cursor
|
|
// Insert cursor character at cursor position
|
|
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 : "";
|
|
|
|
// Use inverse video to show cursor
|
|
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
|
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
|
|
|
|
// Calculate visual width
|
|
const visualLength = visibleWidth(textWithCursor);
|
|
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
|
|
const line = prompt + textWithCursor + padding;
|
|
|
|
return [line];
|
|
}
|
|
}
|