From e2c3611c637f6d18fd5f6354e496b89cdb6bd173 Mon Sep 17 00:00:00 2001
From: Jacob Richman <jacob314@gmail.com>
Date: Tue, 13 May 2025 11:24:04 -0700
Subject: Multiline editor (#302)

Co-authored-by: Taylor Mullen <ntaylormullen@google.com>
---
 .../src/ui/components/shared/multiline-editor.tsx  | 276 +++++++++++++++++++++
 1 file changed, 276 insertions(+)
 create mode 100644 packages/cli/src/ui/components/shared/multiline-editor.tsx

(limited to 'packages/cli/src/ui/components/shared/multiline-editor.tsx')

diff --git a/packages/cli/src/ui/components/shared/multiline-editor.tsx b/packages/cli/src/ui/components/shared/multiline-editor.tsx
new file mode 100644
index 00000000..c388064a
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/multiline-editor.tsx
@@ -0,0 +1,276 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { TextBuffer } from './text-buffer.js';
+import chalk from 'chalk';
+import { Box, Text, useInput, useStdin, Key } from 'ink';
+import React, { useState, useCallback } from 'react';
+import { useTerminalSize } from '../../hooks/useTerminalSize.js';
+import { Colors } from '../../colors.js';
+
+export interface MultilineTextEditorProps {
+  // Initial contents.
+  readonly initialText?: string;
+
+  // Placeholder text.
+  readonly placeholder?: 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;
+
+  // Called when the user attempts to navigate past the start of the editor
+  // with the up arrow.
+  readonly navigateUp?: () => void;
+
+  // Called when the user attempts to navigate past the end of the editor
+  // with the down arrow.
+  readonly navigateDown?: () => void;
+
+  // Called on all key events to allow the caller. Returns true if the
+  // event was handled and should not be passed to the editor.
+  readonly inputPreprocessor?: (input: string, key: Key) => boolean;
+
+  // Optional initial cursor position (character offset)
+  readonly initialCursorOffset?: number;
+
+  readonly widthUsedByParent: number;
+
+  readonly widthFraction?: number;
+}
+
+export const MultilineTextEditor = ({
+  initialText = '',
+  placeholder = '',
+  width,
+  height = 10,
+  onSubmit,
+  focus = true,
+  onChange,
+  initialCursorOffset,
+  widthUsedByParent,
+  widthFraction = 1,
+  navigateUp,
+  navigateDown,
+  inputPreprocessor,
+}: MultilineTextEditorProps): React.ReactElement => {
+  const [buffer, setBuffer] = useState(
+    () => new TextBuffer(initialText, initialCursorOffset),
+  );
+
+  const terminalSize = useTerminalSize();
+  const effectiveWidth = Math.max(
+    20,
+    width ??
+      Math.round(terminalSize.columns * widthFraction) - widthUsedByParent,
+  );
+
+  const { stdin, setRawMode } = useStdin();
+
+  // TODO(jacobr): make TextBuffer immutable rather than this hack to act
+  // like it is immutable.
+  const updateBufferState = useCallback(
+    (mutator: (currentBuffer: TextBuffer) => void) => {
+      setBuffer((currentBuffer) => {
+        mutator(currentBuffer);
+        // Create a new instance from the mutated buffer to trigger re-render
+        return TextBuffer.fromBuffer(currentBuffer);
+      });
+    },
+    [],
+  );
+
+  const openExternalEditor = useCallback(async () => {
+    const wasRaw = stdin?.isRaw ?? false;
+    try {
+      setRawMode?.(false);
+      // openInExternalEditor mutates the buffer instance
+      await buffer.openInExternalEditor();
+    } catch (err) {
+      console.error('[MultilineTextEditor] external editor error', err);
+    } finally {
+      if (wasRaw) {
+        setRawMode?.(true);
+      }
+      // Update state with the mutated buffer to trigger re-render
+      setBuffer(TextBuffer.fromBuffer(buffer));
+    }
+  }, [buffer, stdin, setRawMode, setBuffer]);
+
+  useInput(
+    (input, key) => {
+      if (!focus) {
+        return;
+      }
+
+      if (inputPreprocessor?.(input, key) === true) {
+        return;
+      }
+
+      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'
+      ) {
+        console.log('[MultilineTextEditor] event', { input, key });
+      }
+
+      let bufferMutated = false;
+
+      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]);
+          const hasCtrl = Math.floor(mod / 4) % 2 === 1;
+          if (hasCtrl) {
+            if (onSubmit) {
+              onSubmit(buffer.getText());
+            }
+          } else {
+            buffer.newline();
+            bufferMutated = true;
+          }
+          if (bufferMutated) {
+            updateBufferState((_) => {}); // Trigger re-render if mutated
+          }
+          return;
+        }
+      }
+
+      if (input.startsWith('[27;') && input.endsWith('~')) {
+        const m = input.match(/^\[27;([0-9]+);13~$/);
+        if (m) {
+          const mod = Number(m[1]);
+          const hasCtrl = Math.floor(mod / 4) % 2 === 1;
+          if (hasCtrl) {
+            if (onSubmit) {
+              onSubmit(buffer.getText());
+            }
+          } else {
+            buffer.newline();
+            bufferMutated = true;
+          }
+          if (bufferMutated) {
+            updateBufferState((_) => {}); // Trigger re-render if mutated
+          }
+          return;
+        }
+      }
+
+      if (input === '\n') {
+        buffer.newline();
+        updateBufferState((_) => {});
+        return;
+      }
+
+      if (input === '\r') {
+        if (onSubmit) {
+          onSubmit(buffer.getText());
+        }
+        return;
+      }
+
+      if (key.upArrow) {
+        if (buffer.getCursor()[0] === 0 && navigateUp) {
+          navigateUp();
+          return;
+        }
+      }
+
+      if (key.downArrow) {
+        if (
+          buffer.getCursor()[0] === buffer.getText().split('\n').length - 1 &&
+          navigateDown
+        ) {
+          navigateDown();
+          return;
+        }
+      }
+
+      const modifiedByHandleInput = buffer.handleInput(
+        input,
+        key as Record<string, boolean>,
+        { height, width: effectiveWidth },
+      );
+
+      if (modifiedByHandleInput) {
+        updateBufferState((_) => {});
+      }
+
+      const newText = buffer.getText();
+      if (onChange) {
+        onChange(newText);
+      }
+    },
+    { isActive: focus },
+  );
+
+  const visibleLines = buffer.getVisibleLines({
+    height,
+    width: effectiveWidth,
+  });
+  const [cursorRow, cursorCol] = buffer.getCursor();
+  const scrollRow = buffer.getScrollRow();
+  const scrollCol = buffer.getScrollCol();
+
+  return (
+    <Box flexDirection="column">
+      {buffer.getText().length === 0 && placeholder ? (
+        <Text color={Colors.SubtleComment}>{placeholder}</Text>
+      ) : (
+        visibleLines.map((lineText, idx) => {
+          const absoluteRow = scrollRow + idx;
+          let display = lineText.slice(scrollCol, scrollCol + effectiveWidth);
+          if (display.length < effectiveWidth) {
+            display = display.padEnd(effectiveWidth, ' ');
+          }
+
+          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) {
+              display =
+                display.slice(0, effectiveWidth - 1) + chalk.inverse(' ');
+            }
+          }
+          return <Text key={idx}>{display}</Text>;
+        })
+      )}
+    </Box>
+  );
+};
-- 
cgit v1.2.3

