Files
llmx/codex-cli/src/components/chat/multiline-editor.tsx

410 lines
15 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import { useTerminalSize } from "../../hooks/use-terminal-size";
import TextBuffer from "../../lib/text-buffer.js";
import chalk from "chalk";
import { Box, Text, useInput, useStdin } from "ink";
import { EventEmitter } from "node:events";
import React, { useRef, useState } from "react";
/* --------------------------------------------------------------------------
* Polyfill missing `ref()` / `unref()` methods on the mock `Stdin` stream
* provided by `ink-testing-library`.
*
* The real `process.stdin` object exposed by Node.js inherits these methods
* from `Socket`, but the lightweight stub used in tests only extends
* `EventEmitter`. Ink calls the two methods when enabling/disabling raw
* mode, so make them harmless noops when they're absent to avoid runtime
* failures during unit tests.
* ----------------------------------------------------------------------- */
// Cast through `unknown` ➜ `any` to avoid the `TS2352`/`TS4111` complaints
// when augmenting the prototype with the stubbed `ref`/`unref` methods in the
// test environment. Using `any` here is acceptable because we purposefully
// monkeypatch internals of Node's `EventEmitter` solely for the benefit of
// Ink's stdin stub typesafety is not a primary concern at this boundary.
//
const proto: any = EventEmitter.prototype;
if (typeof proto["ref"] !== "function") {
proto["ref"] = function ref() {};
}
if (typeof proto["unref"] !== "function") {
proto["unref"] = function unref() {};
}
/*
* The `ink-testing-library` stub emits only a `data` event when its `stdin`
* mock receives `write()` calls. Ink, however, listens for `readable` and
* uses the `read()` method to fetch the buffered chunk. Bridge the gap by
* hooking into `EventEmitter.emit` so that every `data` emission also:
* 1. Buffers the chunk for a subsequent `read()` call, and
* 2. Triggers a `readable` event, matching the contract expected by Ink.
*/
// Preserve original emit to avoid infinite recursion.
// eslintdisablenextline @typescript-eslint/nounsafeassignment
const originalEmit = proto["emit"] as (...args: Array<any>) => boolean;
proto["emit"] = function patchedEmit(
this: any,
event: string,
...args: Array<any>
): boolean {
if (event === "data") {
const chunk = args[0] as string;
if (
process.env["TEXTBUFFER_DEBUG"] === "1" ||
process.env["TEXTBUFFER_DEBUG"] === "true"
) {
// eslint-disable-next-line no-console
console.log("[MultilineTextEditor:stdin] data", JSON.stringify(chunk));
}
// Store carriage returns asis so that Ink can distinguish between plain
// <Enter> ("\r") and a bare linefeed ("\n"). This matters because Ink's
// `parseKeypress` treats "\r" as key.name === "return", whereas "\n" maps
// to "enter" allowing us to differentiate between plain Enter (submit)
// and Shift+Enter (insert newline) inside `useInput`.
// Identify the lightweight testing stub: lacks `.read()` but exposes
// `.setRawMode()` and `isTTY` similar to the real TTY stream.
if (
!(this as any)._inkIsStub &&
typeof (this as any).setRawMode === "function" &&
typeof (this as any).isTTY === "boolean" &&
typeof (this as any).read !== "function"
) {
(this as any)._inkIsStub = true;
// Provide a minimal `read()` shim so Ink can pull queued chunks.
(this as any).read = function read() {
const ret = (this as any)._inkBuffered ?? null;
(this as any)._inkBuffered = null;
if (
process.env["TEXTBUFFER_DEBUG"] === "1" ||
process.env["TEXTBUFFER_DEBUG"] === "true"
) {
// eslint-disable-next-line no-console
console.log("[MultilineTextEditor:stdin.read]", JSON.stringify(ret));
}
return ret;
};
}
if ((this as any)._inkIsStub) {
// Buffer the payload so that `read()` can synchronously retrieve it.
if (typeof (this as any)._inkBuffered === "string") {
(this as any)._inkBuffered += chunk;
} else {
(this as any)._inkBuffered = chunk;
}
// Notify listeners that data is ready in a way Ink understands.
if (
process.env["TEXTBUFFER_DEBUG"] === "1" ||
process.env["TEXTBUFFER_DEBUG"] === "true"
) {
// eslint-disable-next-line no-console
console.log(
"[MultilineTextEditor:stdin] -> readable",
JSON.stringify(chunk),
);
}
originalEmit.call(this, "readable");
}
}
// Forward the original event.
return originalEmit.call(this, event, ...args);
};
export interface MultilineTextEditorProps {
// Initial contents.
readonly initialText?: string;
// Visible width.
readonly width?: number;
// Visible height.
readonly height?: number;
// Called when the user submits (plain <Enter> key).
readonly onSubmit?: (text: string) => void;
// Capture keyboard input.
readonly focus?: boolean;
// Called when the internal text buffer updates.
readonly onChange?: (text: string) => void;
}
// Expose a minimal imperative API so parent components (e.g. TerminalChatInput)
// can query the caret position to implement behaviours like history
// navigation that depend on whether the cursor sits on the first/last line.
export interface MultilineTextEditorHandle {
/** Current caret row */
getRow(): number;
/** Current caret column */
getCol(): number;
/** Total number of lines in the buffer */
getLineCount(): number;
/** Helper: caret is on the very first row */
isCursorAtFirstRow(): boolean;
/** Helper: caret is on the very last row */
isCursorAtLastRow(): boolean;
/** Full text contents */
getText(): string;
}
const MultilineTextEditorInner = (
{
initialText = "",
// Width can be provided by the caller. When omitted we fall back to the
// current terminal size (minus some padding handled by `useTerminalSize`).
width,
height = 10,
onSubmit,
focus = true,
onChange,
}: MultilineTextEditorProps,
ref: React.Ref<MultilineTextEditorHandle | null>,
): React.ReactElement => {
// ---------------------------------------------------------------------------
// Editor State
// ---------------------------------------------------------------------------
const buffer = useRef(new TextBuffer(initialText));
const [version, setVersion] = useState(0);
// Keep track of the current terminal size so that the editor grows/shrinks
// with the window. `useTerminalSize` already subtracts a small horizontal
// padding so that we don't butt up right against the edge.
const terminalSize = useTerminalSize();
// If the caller didn't specify a width we dynamically choose one based on
// the terminal's current column count. We still enforce a reasonable
// minimum so that the UI never becomes unusably small.
const effectiveWidth = Math.max(20, width ?? terminalSize.columns);
// ---------------------------------------------------------------------------
// External editor integration helpers.
// ---------------------------------------------------------------------------
// Access to stdin so we can toggle rawmode while the external editor is
// in control of the terminal.
const { stdin, setRawMode } = useStdin();
/**
* Launch the user's preferred $EDITOR, blocking until they close it, then
* reload the edited file back into the inmemory TextBuffer. The heavy
* work is delegated to `TextBuffer.openInExternalEditor`, but we are
* responsible for temporarily *disabling* raw mode so the child process can
* interact with the TTY normally.
*/
const openExternalEditor = React.useCallback(async () => {
// Preserve the current rawmode setting so we can restore it afterwards.
const wasRaw = stdin?.isRaw ?? false;
try {
setRawMode?.(false);
await buffer.current.openInExternalEditor();
} catch (err) {
// Surface the error so it doesn't fail silently for now we log to
// stderr. In the future this could surface a toast / overlay.
// eslint-disable-next-line no-console
console.error("[MultilineTextEditor] external editor error", err);
} finally {
if (wasRaw) {
setRawMode?.(true);
}
// Force a rerender so the component reflects the mutated buffer.
setVersion((v) => v + 1);
}
}, [buffer, stdin, setRawMode]);
// ---------------------------------------------------------------------------
// Keyboard handling.
// ---------------------------------------------------------------------------
useInput(
(input, key) => {
if (!focus) {
return;
}
// Singlestep editor shortcut: Ctrl+X or Ctrl+E
// Treat both true Ctrl+Key combinations *and* raw control codes so that
// the shortcut works consistently in real terminals (rawmode) and the
// inktestinglibrary stub which delivers only the raw byte (e.g. 0x05
// for CtrlE) without setting `key.ctrl`.
const isCtrlX =
(key.ctrl && (input === "x" || input === "\x18")) || input === "\x18";
const isCtrlE =
(key.ctrl && (input === "e" || input === "\x05")) ||
input === "\x05" ||
(!key.ctrl &&
input === "e" &&
input.length === 1 &&
input.charCodeAt(0) === 5);
if (isCtrlX || isCtrlE) {
openExternalEditor();
return;
}
if (
process.env["TEXTBUFFER_DEBUG"] === "1" ||
process.env["TEXTBUFFER_DEBUG"] === "true"
) {
// eslint-disable-next-line no-console
console.log("[MultilineTextEditor] event", { input, key });
}
// 1) CSIu / modifyOtherKeys (Ink strips initial ESC, so we start with '[')
if (input.startsWith("[") && input.endsWith("u")) {
const m = input.match(/^\[([0-9]+);([0-9]+)u$/);
if (m && m[1] === "13") {
const mod = Number(m[2]);
// In xterm's encoding: bit1 (value 2) is Shift. Everything >1 that
// isn't exactly 1 means some modifier was held. We treat *shift
// present* (2,4,6,8) as newline; plain (1) as submit.
// Xterm encodes modifier keys in `mod` bit2 (value 4) indicates
// that Ctrl was held. We avoid the `&` bitwise operator (disallowed
// by our ESLint config) by using arithmetic instead.
const hasCtrl = Math.floor(mod / 4) % 2 === 1;
if (hasCtrl) {
if (onSubmit) {
onSubmit(buffer.current.getText());
}
} else {
// Any variant without Ctrl just inserts newline (Shift, Alt, none)
buffer.current.newline();
}
setVersion((v) => v + 1);
return;
}
}
// 2) Singlebyte control chars ------------------------------------------------
if (input === "\n") {
// Ctrl+J or pasted newline → insert newline.
buffer.current.newline();
setVersion((v) => v + 1);
return;
}
if (input === "\r") {
// Plain Enter submit (works on all basic terminals).
if (onSubmit) {
onSubmit(buffer.current.getText());
}
return;
}
// Let <Esc> fall through so the parent handler (if any) can act on it.
// Delegate remaining keys to our pure TextBuffer
if (
process.env["TEXTBUFFER_DEBUG"] === "1" ||
process.env["TEXTBUFFER_DEBUG"] === "true"
) {
// eslint-disable-next-line no-console
console.log("[MultilineTextEditor] key event", { input, key });
}
const modified = buffer.current.handleInput(
input,
key as Record<string, boolean>,
{ height, width: effectiveWidth },
);
if (modified) {
setVersion((v) => v + 1);
}
const newText = buffer.current.getText();
if (onChange) {
onChange(newText);
}
},
{ isActive: focus },
);
// ---------------------------------------------------------------------------
// Rendering helpers.
// ---------------------------------------------------------------------------
/* ------------------------------------------------------------------------- */
/* Imperative handle expose a readonly view of caret & buffer geometry */
/* ------------------------------------------------------------------------- */
React.useImperativeHandle(
ref,
() => ({
getRow: () => buffer.current.getCursor()[0],
getCol: () => buffer.current.getCursor()[1],
getLineCount: () => buffer.current.getText().split("\n").length,
isCursorAtFirstRow: () => buffer.current.getCursor()[0] === 0,
isCursorAtLastRow: () => {
const [row] = buffer.current.getCursor();
const lineCount = buffer.current.getText().split("\n").length;
return row === lineCount - 1;
},
getText: () => buffer.current.getText(),
}),
[],
);
// Read everything from the buffer
const visibleLines = buffer.current.getVisibleLines({
height,
width: effectiveWidth,
});
const [cursorRow, cursorCol] = buffer.current.getCursor();
const scrollRow = (buffer.current as any).scrollRow as number;
const scrollCol = (buffer.current as any).scrollCol as number;
return (
<Box flexDirection="column" key={version}>
{visibleLines.map((lineText, idx) => {
const absoluteRow = scrollRow + idx;
// apply horizontal slice
let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
if (display.length < effectiveWidth) {
display = display.padEnd(effectiveWidth, " ");
}
// Highlight the *character under the caret* (i.e. the one immediately
// to the right of the insertion position) so that the block cursor
// visually matches the logical caret location. This makes the
// highlighted glyph the one that would be replaced by `insert()` and
// *not* the one that would be removed by `backspace()`.
if (absoluteRow === cursorRow) {
const relativeCol = cursorCol - scrollCol;
const highlightCol = relativeCol;
if (highlightCol >= 0 && highlightCol < effectiveWidth) {
const charToHighlight = display[highlightCol] || " ";
const highlighted = chalk.inverse(charToHighlight);
display =
display.slice(0, highlightCol) +
highlighted +
display.slice(highlightCol + 1);
} else if (relativeCol === effectiveWidth) {
// Caret sits just past the right edge; show a block cursor in the
// gutter so the user still sees it.
display = display.slice(0, effectiveWidth - 1) + chalk.inverse(" ");
}
}
return <Text key={idx}>{display}</Text>;
})}
</Box>
);
};
const MultilineTextEditor = React.forwardRef(MultilineTextEditorInner);
export default MultilineTextEditor;