summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/App.tsx44
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx90
-rw-r--r--packages/cli/src/ui/components/shared/multiline-editor.tsx276
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts1049
-rw-r--r--packages/cli/src/ui/hooks/useInputHistory.ts123
-rw-r--r--packages/cli/src/ui/hooks/useTerminalSize.ts32
6 files changed, 1501 insertions, 113 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index a32902e5..7b81ada1 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -13,7 +13,7 @@ import { useInputHistory } from './hooks/useInputHistory.js';
import { useThemeCommand } from './hooks/useThemeCommand.js';
import { Header } from './components/Header.js';
import { LoadingIndicator } from './components/LoadingIndicator.js';
-import { InputPrompt } from './components/InputPrompt.js';
+import { EditorState, InputPrompt } from './components/InputPrompt.js';
import { Footer } from './components/Footer.js';
import { ThemeDialog } from './components/ThemeDialog.js';
import { useStartupWarnings } from './hooks/useAppEffects.js';
@@ -97,8 +97,22 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
const isInputActive = streamingState === StreamingState.Idle && !initError;
- // query and setQuery are now managed by useState here
const [query, setQuery] = useState('');
+ const [editorState, setEditorState] = useState<EditorState>({
+ key: 0,
+ initialCursorOffset: undefined,
+ });
+
+ const onChangeAndMoveCursor = useCallback(
+ (value: string) => {
+ setQuery(value);
+ setEditorState((s) => ({
+ key: s.key + 1,
+ initialCursorOffset: value.length,
+ }));
+ },
+ [setQuery, setEditorState],
+ );
const completion = useCompletion(
query,
@@ -107,20 +121,16 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
slashCommands,
);
- const {
- handleSubmit: handleHistorySubmit,
- inputKey,
- setInputKey,
- } = useInputHistory({
+ const inputHistory = useInputHistory({
userMessages,
onSubmit: (value) => {
// Adapt onSubmit to use the lifted setQuery
handleFinalSubmit(value);
- setQuery(''); // Clear query from the App's state
+ onChangeAndMoveCursor('');
},
isActive: isInputActive && !completion.showSuggestions,
- query,
- setQuery,
+ currentQuery: query,
+ onChangeAndMoveCursor,
});
// --- Render Logic ---
@@ -223,15 +233,17 @@ export const App = ({ config, settings, cliVersion }: AppProps) => {
<InputPrompt
query={query}
- setQuery={setQuery}
- inputKey={inputKey}
- setInputKey={setInputKey}
- onSubmit={handleHistorySubmit}
+ onChange={setQuery}
+ onChangeAndMoveCursor={onChangeAndMoveCursor}
+ editorState={editorState}
+ onSubmit={inputHistory.handleSubmit}
showSuggestions={completion.showSuggestions}
suggestions={completion.suggestions}
activeSuggestionIndex={completion.activeSuggestionIndex}
- navigateUp={completion.navigateUp}
- navigateDown={completion.navigateDown}
+ navigateHistoryUp={inputHistory.navigateUp}
+ navigateHistoryDown={inputHistory.navigateDown}
+ navigateSuggestionUp={completion.navigateUp}
+ navigateSuggestionDown={completion.navigateDown}
resetCompletion={completion.resetCompletionState}
/>
{completion.showSuggestions && (
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 20d4bcdf..072fe074 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -5,40 +5,47 @@
*/
import React, { useCallback } from 'react';
-import { Text, Box, useInput, useFocus, Key } from 'ink';
-import TextInput from 'ink-text-input';
+import { Text, Box, Key } from 'ink';
import { Colors } from '../colors.js';
import { Suggestion } from './SuggestionsDisplay.js';
+import { MultilineTextEditor } from './shared/multiline-editor.js';
interface InputPromptProps {
query: string;
- setQuery: React.Dispatch<React.SetStateAction<string>>;
- inputKey: number;
- setInputKey: React.Dispatch<React.SetStateAction<number>>;
+ onChange: (value: string) => void;
+ onChangeAndMoveCursor: (value: string) => void;
+ editorState: EditorState;
onSubmit: (value: string) => void;
showSuggestions: boolean;
suggestions: Suggestion[];
activeSuggestionIndex: number;
- navigateUp: () => void;
- navigateDown: () => void;
resetCompletion: () => void;
+ navigateHistoryUp: () => void;
+ navigateHistoryDown: () => void;
+ navigateSuggestionUp: () => void;
+ navigateSuggestionDown: () => void;
+}
+
+export interface EditorState {
+ key: number;
+ initialCursorOffset?: number;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
query,
- setQuery,
- inputKey,
- setInputKey,
+ onChange,
+ onChangeAndMoveCursor,
+ editorState,
onSubmit,
showSuggestions,
suggestions,
activeSuggestionIndex,
- navigateUp,
- navigateDown,
+ navigateHistoryUp,
+ navigateHistoryDown,
+ navigateSuggestionUp,
+ navigateSuggestionDown,
resetCompletion,
}) => {
- const { isFocused } = useFocus({ autoFocus: true });
-
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
@@ -52,7 +59,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const slashIndex = query.indexOf('/');
const base = query.substring(0, slashIndex + 1);
const newValue = base + selectedSuggestion.value;
- setQuery(newValue);
+ onChangeAndMoveCursor(newValue);
} else {
// Handle @ command completion
const atIndex = query.lastIndexOf('@');
@@ -73,32 +80,30 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
}
const newValue = base + selectedSuggestion.value;
- setQuery(newValue);
+ onChangeAndMoveCursor(newValue);
}
resetCompletion(); // Hide suggestions after selection
- setInputKey((k) => k + 1); // Increment key to force re-render and cursor reset
},
- [query, setQuery, suggestions, resetCompletion, setInputKey],
+ [query, suggestions, resetCompletion, onChangeAndMoveCursor],
);
- useInput(
+ const inputPreprocessor = useCallback(
(input: string, key: Key) => {
- if (!isFocused) {
- return;
- }
-
if (showSuggestions) {
if (key.upArrow) {
- navigateUp();
+ navigateSuggestionUp();
+ return true;
} else if (key.downArrow) {
- navigateDown();
+ navigateSuggestionDown();
+ return true;
} else if (key.tab) {
if (suggestions.length > 0) {
const targetIndex =
activeSuggestionIndex === -1 ? 0 : activeSuggestionIndex;
if (targetIndex < suggestions.length) {
handleAutocomplete(targetIndex);
+ return true;
}
}
} else if (key.return) {
@@ -109,34 +114,51 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
onSubmit(query);
}
}
+ return true;
} else if (key.escape) {
resetCompletion();
+ return true;
}
}
- // Enter key when suggestions are NOT showing is handled by TextInput's onSubmit prop below
+ return false;
},
- { isActive: true },
+ [
+ handleAutocomplete,
+ navigateSuggestionDown,
+ navigateSuggestionUp,
+ query,
+ suggestions,
+ showSuggestions,
+ resetCompletion,
+ activeSuggestionIndex,
+ onSubmit,
+ ],
);
return (
<Box borderStyle="round" borderColor={Colors.AccentBlue} paddingX={1}>
<Text color={Colors.AccentPurple}>&gt; </Text>
<Box flexGrow={1}>
- <TextInput
- key={inputKey.toString()}
- value={query}
- onChange={setQuery}
+ <MultilineTextEditor
+ key={editorState.key.toString()}
+ initialCursorOffset={editorState.initialCursorOffset}
+ initialText={query}
+ onChange={onChange}
placeholder="Enter your message or use tools (e.g., @src/file.txt)..."
+ /* Account for width used by the box and &gt; */
+ navigateUp={navigateHistoryUp}
+ navigateDown={navigateHistoryDown}
+ inputPreprocessor={inputPreprocessor}
+ widthUsedByParent={3}
+ widthFraction={0.9}
onSubmit={() => {
// This onSubmit is for the TextInput component itself.
// It should only fire if suggestions are NOT showing,
- // as useInput handles Enter when suggestions are visible.
+ // as inputPreprocessor handles Enter when suggestions are visible.
const trimmedQuery = query.trim();
if (!showSuggestions && trimmedQuery) {
onSubmit(trimmedQuery);
}
- // If suggestions ARE showing, useInput's Enter handler
- // would have already dealt with it (either completing or submitting).
}}
/>
</Box>
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>
+ );
+};
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
new file mode 100644
index 00000000..98a1ca37
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -0,0 +1,1049 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { spawnSync } from 'child_process';
+import fs from 'fs';
+import os from 'os';
+import pathMod from 'path';
+
+export type Direction =
+ | 'left'
+ | 'right'
+ | 'up'
+ | 'down'
+ | 'wordLeft'
+ | 'wordRight'
+ | 'home'
+ | 'end';
+
+// Simple helper for word‑wise ops.
+function isWordChar(ch: string | undefined): boolean {
+ if (ch === undefined) {
+ return false;
+ }
+ return !/[\s,.;!?]/.test(ch);
+}
+
+export interface Viewport {
+ height: number;
+ width: number;
+}
+
+function clamp(v: number, min: number, max: number): number {
+ return v < min ? min : v > max ? max : v;
+}
+
+/*
+ * -------------------------------------------------------------------------
+ * Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
+ * code units so that surrogate‑pair emoji count as one "column".)
+ * ---------------------------------------------------------------------- */
+
+function toCodePoints(str: string): string[] {
+ // [...str] or Array.from both iterate by UTF‑32 code point, handling
+ // surrogate pairs correctly.
+ return Array.from(str);
+}
+
+function cpLen(str: string): number {
+ return toCodePoints(str).length;
+}
+
+function cpSlice(str: string, start: number, end?: number): string {
+ // Slice by code‑point indices and re‑join.
+ const arr = toCodePoints(str).slice(start, end);
+ return arr.join('');
+}
+
+/* -------------------------------------------------------------------------
+ * Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
+ * ---------------------------------------------------------------------- */
+
+// Enable verbose logging only when requested via env var.
+const DEBUG =
+ process.env['TEXTBUFFER_DEBUG'] === '1' ||
+ process.env['TEXTBUFFER_DEBUG'] === 'true';
+
+function dbg(...args: unknown[]): void {
+ if (DEBUG) {
+ console.log('[TextBuffer]', ...args);
+ }
+}
+
+/* ────────────────────────────────────────────────────────────────────────── */
+
+export class TextBuffer {
+ private lines: string[];
+ private cursorRow = 0;
+ private cursorCol = 0;
+ private scrollRow = 0;
+ private scrollCol = 0;
+
+ /**
+ * When the user moves the caret vertically we try to keep their original
+ * horizontal column even when passing through shorter lines. We remember
+ * that *preferred* column in this field while the user is still travelling
+ * vertically. Any explicit horizontal movement resets the preference.
+ */
+ private preferredCol: number | null = null;
+
+ /* a single integer that bumps every time text changes */
+ private version = 0;
+
+ /* ------------------------------------------------------------------
+ * History & clipboard
+ * ---------------------------------------------------------------- */
+ private undoStack: Array<{ lines: string[]; row: number; col: number }> = [];
+ private redoStack: Array<{ lines: string[]; row: number; col: number }> = [];
+ private historyLimit = 100;
+
+ private clipboard: string | null = null;
+ private selectionAnchor: [number, number] | null = null;
+
+ /**
+ * Creates a new TextBuffer with the given text
+ *
+ * @param text Initial text content for the buffer
+ * @param initialCursorOffset Initial cursor position as character offset
+ */
+ constructor(text: string = '', initialCursorOffset = 0) {
+ this.lines = text.split('\n');
+ if (this.lines.length === 0) {
+ this.lines = [''];
+ }
+ this.setCursorOffset(initialCursorOffset);
+ }
+
+ /**
+ * Creates a new TextBuffer that is a copy of an existing one
+ *
+ * @param source The source TextBuffer to copy
+ * @returns A new TextBuffer instance with the same content and state
+ */
+ static fromBuffer(source: TextBuffer): TextBuffer {
+ const buffer = new TextBuffer('');
+
+ // Copy all properties
+ buffer.lines = source.lines.slice();
+ buffer.cursorRow = source.cursorRow;
+ buffer.cursorCol = source.cursorCol;
+ buffer.scrollRow = source.scrollRow;
+ buffer.scrollCol = source.scrollCol;
+ buffer.preferredCol = source.preferredCol;
+ buffer.version = source.version + 1;
+
+ // Deep copy history stacks
+ buffer.undoStack = source.undoStack.slice();
+ buffer.redoStack = source.redoStack.slice();
+ buffer.historyLimit = source.historyLimit;
+ buffer.clipboard = source.clipboard;
+ buffer.selectionAnchor = source.selectionAnchor
+ ? [...source.selectionAnchor]
+ : null;
+
+ return buffer;
+ }
+
+ /* =====================================================================
+ * External editor integration (git‑style $EDITOR workflow)
+ * =================================================================== */
+
+ /**
+ * Opens the current buffer contents in the user’s preferred terminal text
+ * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
+ * until the editor exits, then reloads the file and replaces the in‑memory
+ * buffer with whatever the user saved.
+ *
+ * The operation is treated as a single undoable edit – we snapshot the
+ * previous state *once* before launching the editor so one `undo()` will
+ * revert the entire change set.
+ *
+ * Note: We purposefully rely on the *synchronous* spawn API so that the
+ * calling process genuinely waits for the editor to close before
+ * continuing. This mirrors Git’s behaviour and simplifies downstream
+ * control‑flow (callers can simply `await` the Promise).
+ */
+ async openInExternalEditor(opts: { editor?: string } = {}): Promise<void> {
+ const editor =
+ opts.editor ??
+ process.env['VISUAL'] ??
+ process.env['EDITOR'] ??
+ (process.platform === 'win32' ? 'notepad' : 'vi');
+
+ // Prepare a temporary file with the current contents. We use mkdtempSync
+ // to obtain an isolated directory and avoid name collisions.
+ const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'codex-edit-'));
+ const filePath = pathMod.join(tmpDir, 'buffer.txt');
+
+ fs.writeFileSync(filePath, this.getText(), 'utf8');
+
+ // One snapshot for undo semantics *before* we mutate anything.
+ this.pushUndo();
+
+ // The child inherits stdio so the user can interact with the editor as if
+ // they had launched it directly.
+ const { status, error } = spawnSync(editor, [filePath], {
+ stdio: 'inherit',
+ });
+
+ if (error) {
+ throw error;
+ }
+ if (typeof status === 'number' && status !== 0) {
+ throw new Error(`External editor exited with status ${status}`);
+ }
+
+ // Read the edited contents back in – normalise line endings to \n.
+ let newText = fs.readFileSync(filePath, 'utf8');
+ newText = newText.replace(/\r\n?/g, '\n');
+
+ // Update buffer.
+ this.lines = newText.split('\n');
+ if (this.lines.length === 0) {
+ this.lines = [''];
+ }
+
+ // Position the caret at EOF.
+ this.cursorRow = this.lines.length - 1;
+ this.cursorCol = cpLen(this.line(this.cursorRow));
+
+ // Reset scroll offsets so the new end is visible.
+ this.scrollRow = Math.max(0, this.cursorRow - 1);
+ this.scrollCol = 0;
+
+ this.version++;
+ }
+
+ /* =======================================================================
+ * Geometry helpers
+ * ===================================================================== */
+ private line(r: number): string {
+ return this.lines[r] ?? '';
+ }
+ private lineLen(r: number): number {
+ return cpLen(this.line(r));
+ }
+
+ private ensureCursorInRange(): void {
+ this.cursorRow = clamp(this.cursorRow, 0, this.lines.length - 1);
+ this.cursorCol = clamp(this.cursorCol, 0, this.lineLen(this.cursorRow));
+ }
+
+ /**
+ * Sets the cursor position based on a character offset from the start of the document.
+ */
+ private setCursorOffset(offset: number): boolean {
+ // Reset preferred column since this is an explicit horizontal movement
+ this.preferredCol = null;
+
+ let remainingChars = offset;
+ let row = 0;
+
+ // Count characters line by line until we find the right position
+ while (row < this.lines.length) {
+ const lineLength = this.lineLen(row);
+ // Add 1 for the newline character (except for the last line)
+ const totalChars = lineLength + (row < this.lines.length - 1 ? 1 : 0);
+
+ if (remainingChars <= lineLength) {
+ this.cursorRow = row;
+ this.cursorCol = remainingChars;
+ return true;
+ }
+
+ // Move to next line, subtract this line's characters plus newline
+ remainingChars -= totalChars;
+ row++;
+ }
+
+ // If we get here, the index was too large
+ return false;
+ }
+
+ /* =====================================================================
+ * History helpers
+ * =================================================================== */
+ private snapshot() {
+ return {
+ lines: this.lines.slice(),
+ row: this.cursorRow,
+ col: this.cursorCol,
+ };
+ }
+
+ private pushUndo() {
+ dbg('pushUndo', { cursor: this.getCursor(), text: this.getText() });
+ this.undoStack.push(this.snapshot());
+ if (this.undoStack.length > this.historyLimit) {
+ this.undoStack.shift();
+ }
+ // once we mutate we clear redo
+ this.redoStack.length = 0;
+ }
+
+ /**
+ * Restore a snapshot and return true if restoration happened.
+ */
+ private restore(
+ state: { lines: string[]; row: number; col: number } | undefined,
+ ): boolean {
+ if (!state) {
+ return false;
+ }
+ this.lines = state.lines.slice();
+ this.cursorRow = state.row;
+ this.cursorCol = state.col;
+ this.ensureCursorInRange();
+ return true;
+ }
+
+ /* =======================================================================
+ * Scrolling helpers
+ * ===================================================================== */
+ private ensureCursorVisible(vp: Viewport) {
+ const { height, width } = vp;
+
+ if (this.cursorRow < this.scrollRow) {
+ this.scrollRow = this.cursorRow;
+ } else if (this.cursorRow >= this.scrollRow + height) {
+ this.scrollRow = this.cursorRow - height + 1;
+ }
+
+ if (this.cursorCol < this.scrollCol) {
+ this.scrollCol = this.cursorCol;
+ } else if (this.cursorCol >= this.scrollCol + width) {
+ this.scrollCol = this.cursorCol - width + 1;
+ }
+ }
+
+ /* =======================================================================
+ * Public read‑only accessors
+ * ===================================================================== */
+ getVersion(): number {
+ return this.version;
+ }
+ getCursor(): [number, number] {
+ return [this.cursorRow, this.cursorCol];
+ }
+ getScrollRow(): number {
+ return this.scrollRow;
+ }
+ getScrollCol(): number {
+ return this.scrollCol;
+ }
+
+ getVisibleLines(vp: Viewport): string[] {
+ // Whenever the viewport dimensions change (e.g. on a terminal resize) we
+ // need to re‑evaluate whether the current scroll offset still keeps the
+ // caret visible. Calling `ensureCursorVisible` here guarantees that mere
+ // re‑renders – even when not triggered by user input – will adjust the
+ // horizontal and vertical scroll positions so the cursor remains in view.
+ this.ensureCursorVisible(vp);
+
+ return this.lines.slice(this.scrollRow, this.scrollRow + vp.height);
+ }
+ getText(): string {
+ return this.lines.join('\n');
+ }
+ getLines(): string[] {
+ return this.lines.slice();
+ }
+
+ /* =====================================================================
+ * History public API – undo / redo
+ * =================================================================== */
+ undo(): boolean {
+ const state = this.undoStack.pop();
+ if (!state) {
+ return false;
+ }
+ // push current to redo before restore
+ this.redoStack.push(this.snapshot());
+ this.restore(state);
+ this.version++;
+ return true;
+ }
+
+ redo(): boolean {
+ const state = this.redoStack.pop();
+ if (!state) {
+ return false;
+ }
+ // push current to undo before restore
+ this.undoStack.push(this.snapshot());
+ this.restore(state);
+ this.version++;
+ return true;
+ }
+
+ /* =======================================================================
+ * Editing operations
+ * ===================================================================== */
+ /**
+ * Insert a single character or string without newlines. If the string
+ * contains a newline we delegate to insertStr so that line splitting
+ * logic is shared.
+ */
+ insert(ch: string): void {
+ // Handle pasted blocks that may contain newline sequences (\n, \r or
+ // Windows‑style \r\n). Delegate to `insertStr` so the splitting logic is
+ // centralised.
+ if (/[\n\r]/.test(ch)) {
+ this.insertStr(ch);
+ return;
+ }
+
+ dbg('insert', { ch, beforeCursor: this.getCursor() });
+
+ this.pushUndo();
+
+ const line = this.line(this.cursorRow);
+ this.lines[this.cursorRow] =
+ cpSlice(line, 0, this.cursorCol) + ch + cpSlice(line, this.cursorCol);
+ this.cursorCol += ch.length;
+ this.version++;
+
+ dbg('insert:after', {
+ cursor: this.getCursor(),
+ line: this.line(this.cursorRow),
+ });
+ }
+
+ newline(): void {
+ dbg('newline', { beforeCursor: this.getCursor() });
+ this.pushUndo();
+
+ const l = this.line(this.cursorRow);
+ const before = cpSlice(l, 0, this.cursorCol);
+ const after = cpSlice(l, this.cursorCol);
+
+ this.lines[this.cursorRow] = before;
+ this.lines.splice(this.cursorRow + 1, 0, after);
+
+ this.cursorRow += 1;
+ this.cursorCol = 0;
+ this.version++;
+
+ dbg('newline:after', {
+ cursor: this.getCursor(),
+ lines: [this.line(this.cursorRow - 1), this.line(this.cursorRow)],
+ });
+ }
+
+ backspace(): void {
+ dbg('backspace', { beforeCursor: this.getCursor() });
+ if (this.cursorCol === 0 && this.cursorRow === 0) {
+ return;
+ } // nothing to delete
+
+ this.pushUndo();
+
+ if (this.cursorCol > 0) {
+ const line = this.line(this.cursorRow);
+ this.lines[this.cursorRow] =
+ cpSlice(line, 0, this.cursorCol - 1) + cpSlice(line, this.cursorCol);
+ this.cursorCol--;
+ } else if (this.cursorRow > 0) {
+ // merge with previous
+ const prev = this.line(this.cursorRow - 1);
+ const cur = this.line(this.cursorRow);
+ const newCol = cpLen(prev);
+ this.lines[this.cursorRow - 1] = prev + cur;
+ this.lines.splice(this.cursorRow, 1);
+ this.cursorRow--;
+ this.cursorCol = newCol;
+ }
+ this.version++;
+
+ dbg('backspace:after', {
+ cursor: this.getCursor(),
+ line: this.line(this.cursorRow),
+ });
+ }
+
+ del(): void {
+ dbg('delete', { beforeCursor: this.getCursor() });
+ const line = this.line(this.cursorRow);
+ if (this.cursorCol < this.lineLen(this.cursorRow)) {
+ this.pushUndo();
+ this.lines[this.cursorRow] =
+ cpSlice(line, 0, this.cursorCol) + cpSlice(line, this.cursorCol + 1);
+ } else if (this.cursorRow < this.lines.length - 1) {
+ this.pushUndo();
+ const next = this.line(this.cursorRow + 1);
+ this.lines[this.cursorRow] = line + next;
+ this.lines.splice(this.cursorRow + 1, 1);
+ }
+ this.version++;
+
+ dbg('delete:after', {
+ cursor: this.getCursor(),
+ line: this.line(this.cursorRow),
+ });
+ }
+
+ /**
+ * Replaces the entire buffer content with the provided text.
+ * The operation is undoable.
+ *
+ * @param text The new text content for the buffer.
+ */
+ setText(text: string): void {
+ dbg('setText', { text });
+ this.pushUndo(); // Snapshot before replacing everything
+
+ // Normalize line endings and split into lines
+ this.lines = text.replace(/\r\n?/g, '\n').split('\n');
+ if (this.lines.length === 0) {
+ // Ensure there's always at least one line, even if empty
+ this.lines = [''];
+ }
+
+ // Reset cursor to the end of the new text
+ this.cursorRow = this.lines.length - 1;
+ this.cursorCol = this.lineLen(this.cursorRow);
+
+ // Reset scroll positions and preferred column
+ this.scrollRow = 0;
+ this.scrollCol = 0;
+ this.preferredCol = null;
+
+ this.version++; // Bump version to indicate change
+
+ this.ensureCursorInRange(); // Ensure cursor is valid after replacement
+ // ensureCursorVisible will be called on next render via getVisibleLines
+
+ dbg('setText:after', { cursor: this.getCursor(), text: this.getText() });
+ }
+ /**
+ * Replaces the text within the specified range with new text.
+ * Handles both single-line and multi-line ranges. asdf jas
+ *
+ * @param startRow The starting row index (inclusive).
+ * @param startCol The starting column index (inclusive, code-point based).
+ * @param endRow The ending row index (inclusive).
+ * @param endCol The ending column index (exclusive, code-point based).
+ * @param text The new text to insert.
+ * @returns True if the buffer was modified, false otherwise.
+ */
+ replaceRange(
+ startRow: number,
+ startCol: number,
+ endRow: number,
+ endCol: number,
+ text: string,
+ ): boolean {
+ // Ensure range is valid and ordered (start <= end)
+ // Basic validation, more robust checks might be needed
+ if (
+ startRow > endRow ||
+ (startRow === endRow && startCol > endCol) ||
+ startRow < 0 ||
+ startCol < 0 ||
+ endRow >= this.lines.length
+ // endCol check needs line length, done below
+ ) {
+ console.error('Invalid range provided to replaceRange');
+ return false; // Or throw an error
+ }
+
+ dbg('replaceRange', {
+ start: [startRow, startCol],
+ end: [endRow, endCol],
+ text,
+ });
+ this.pushUndo(); // Snapshot before modification
+
+ const startLine = this.line(startRow);
+ const endLine = this.line(endRow);
+
+ // Clamp columns to valid positions within their respective lines
+ startCol = clamp(startCol, 0, this.lineLen(startRow));
+ endCol = clamp(endCol, 0, this.lineLen(endRow));
+
+ // 1. Perform the deletion part
+ const prefix = cpSlice(startLine, 0, startCol);
+ const suffix = cpSlice(endLine, endCol);
+
+ // Remove lines between startRow (exclusive) and endRow (inclusive)
+ if (startRow < endRow) {
+ this.lines.splice(startRow + 1, endRow - startRow);
+ }
+
+ // Replace the startRow line with the combined prefix and suffix
+ this.lines[startRow] = prefix + suffix;
+
+ // 2. Position cursor at the start of the replaced range
+ this.cursorRow = startRow;
+ this.cursorCol = startCol;
+ this.preferredCol = null; // Reset preferred column after modification
+
+ // 3. Insert the new text
+ const inserted = this.insertStr(text); // insertStr handles cursor update & version++
+
+ // Ensure version is bumped even if inserted text was empty
+ if (!inserted && text === '') {
+ this.version++;
+ }
+
+ this.ensureCursorInRange(); // Ensure cursor is valid after potential deletion/insertion
+ // ensureCursorVisible will be called on next render via getVisibleLines
+
+ dbg('replaceRange:after', {
+ cursor: this.getCursor(),
+ text: this.getText(),
+ });
+ return true; // Assume modification happened (pushUndo was called)
+ }
+
+ /* ------------------------------------------------------------------
+ * Word‑wise deletion helpers – exposed publicly so tests (and future
+ * key‑bindings) can invoke them directly.
+ * ---------------------------------------------------------------- */
+
+ /** Delete the word to the *left* of the caret, mirroring common
+ * Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
+ * whitespace *and* the word characters immediately preceding the caret are
+ * removed. If the caret is already at column‑0 this becomes a no-op. */
+ deleteWordLeft(): void {
+ dbg('deleteWordLeft', { beforeCursor: this.getCursor() });
+
+ if (this.cursorCol === 0 && this.cursorRow === 0) {
+ return;
+ } // Nothing to delete
+
+ // When at column‑0 but *not* on the first row we merge with the previous
+ // line – matching the behaviour of `backspace` for uniform UX.
+ if (this.cursorCol === 0) {
+ this.backspace();
+ return;
+ }
+
+ this.pushUndo();
+
+ const line = this.line(this.cursorRow);
+ const arr = toCodePoints(line);
+
+ // If the cursor is just after a space (or several spaces), we only delete the separators
+ // then, on the next call, the previous word. We should never delete the entire line.
+ let start = this.cursorCol;
+ let onlySpaces = true;
+ for (let i = 0; i < start; i++) {
+ if (isWordChar(arr[i])) {
+ onlySpaces = false;
+ break;
+ }
+ }
+
+ // If the line contains only spaces up to the cursor, delete just one space
+ if (onlySpaces && start > 0) {
+ start--;
+ } else {
+ // Step 1 – skip over any separators sitting *immediately* to the left of the caret
+ while (start > 0 && !isWordChar(arr[start - 1])) {
+ start--;
+ }
+ // Step 2 – skip the word characters themselves
+ while (start > 0 && isWordChar(arr[start - 1])) {
+ start--;
+ }
+ }
+
+ this.lines[this.cursorRow] =
+ cpSlice(line, 0, start) + cpSlice(line, this.cursorCol);
+ this.cursorCol = start;
+ this.version++;
+
+ dbg('deleteWordLeft:after', {
+ cursor: this.getCursor(),
+ line: this.line(this.cursorRow),
+ });
+ }
+
+ /** Delete the word to the *right* of the caret, akin to many editors'
+ * Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
+ * follows the caret and the next contiguous run of word characters. */
+ deleteWordRight(): void {
+ dbg('deleteWordRight', { beforeCursor: this.getCursor() });
+
+ const line = this.line(this.cursorRow);
+ const arr = toCodePoints(line);
+ if (
+ this.cursorCol >= arr.length &&
+ this.cursorRow === this.lines.length - 1
+ ) {
+ return;
+ } // nothing to delete
+
+ // At end‑of‑line ➜ merge with next row (mirrors `del` behaviour).
+ if (this.cursorCol >= arr.length) {
+ this.del();
+ return;
+ }
+
+ this.pushUndo();
+
+ let end = this.cursorCol;
+
+ // Skip separators *first* so that consecutive calls gradually chew
+ // through whitespace then whole words.
+ while (end < arr.length && !isWordChar(arr[end])) {
+ end++;
+ }
+
+ // Skip the word characters.
+ while (end < arr.length && isWordChar(arr[end])) {
+ end++;
+ }
+
+ this.lines[this.cursorRow] =
+ cpSlice(line, 0, this.cursorCol) + cpSlice(line, end);
+ // caret stays in place
+ this.version++;
+
+ dbg('deleteWordRight:after', {
+ cursor: this.getCursor(),
+ line: this.line(this.cursorRow),
+ });
+ }
+
+ move(dir: Direction): void {
+ const before = this.getCursor();
+ switch (dir) {
+ case 'left':
+ this.preferredCol = null;
+ if (this.cursorCol > 0) {
+ this.cursorCol--;
+ } else if (this.cursorRow > 0) {
+ this.cursorRow--;
+ this.cursorCol = this.lineLen(this.cursorRow);
+ }
+ break;
+ case 'right':
+ this.preferredCol = null;
+ if (this.cursorCol < this.lineLen(this.cursorRow)) {
+ this.cursorCol++;
+ } else if (this.cursorRow < this.lines.length - 1) {
+ this.cursorRow++;
+ this.cursorCol = 0;
+ }
+ break;
+ case 'up':
+ if (this.cursorRow > 0) {
+ if (this.preferredCol == null) {
+ this.preferredCol = this.cursorCol;
+ }
+ this.cursorRow--;
+ this.cursorCol = clamp(
+ this.preferredCol,
+ 0,
+ this.lineLen(this.cursorRow),
+ );
+ }
+ break;
+ case 'down':
+ if (this.cursorRow < this.lines.length - 1) {
+ if (this.preferredCol == null) {
+ this.preferredCol = this.cursorCol;
+ }
+ this.cursorRow++;
+ this.cursorCol = clamp(
+ this.preferredCol,
+ 0,
+ this.lineLen(this.cursorRow),
+ );
+ }
+ break;
+ case 'home':
+ this.preferredCol = null;
+ this.cursorCol = 0;
+ break;
+ case 'end':
+ this.preferredCol = null;
+ this.cursorCol = this.lineLen(this.cursorRow);
+ break;
+ case 'wordLeft': {
+ this.preferredCol = null;
+ const regex = /[\s,.;!?]+/g;
+ const slice = cpSlice(
+ this.line(this.cursorRow),
+ 0,
+ this.cursorCol,
+ ).replace(/[\s,.;!?]+$/, '');
+ let lastIdx = 0;
+ let m;
+ while ((m = regex.exec(slice)) != null) {
+ lastIdx = m.index;
+ }
+ const last = cpLen(slice.slice(0, lastIdx));
+ this.cursorCol = last === 0 ? 0 : last + 1;
+ break;
+ }
+ case 'wordRight': {
+ this.preferredCol = null;
+ const regex = /[\s,.;!?]+/g;
+ const l = this.line(this.cursorRow);
+ let moved = false;
+ let m;
+ while ((m = regex.exec(l)) != null) {
+ const cpIdx = cpLen(l.slice(0, m.index));
+ if (cpIdx > this.cursorCol) {
+ // We want to land *at the beginning* of the separator run so that a
+ // subsequent move("right") behaves naturally.
+ this.cursorCol = cpIdx;
+ moved = true;
+ break;
+ }
+ }
+ if (!moved) {
+ // No boundary to the right – jump to EOL.
+ this.cursorCol = this.lineLen(this.cursorRow);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+
+ if (DEBUG) {
+ dbg('move', { dir, before, after: this.getCursor() });
+ }
+
+ /*
+ * If the user performed any movement other than a consecutive vertical
+ * traversal we clear the preferred column so the next vertical run starts
+ * afresh. The cases that keep the preference already returned earlier.
+ */
+ if (dir !== 'up' && dir !== 'down') {
+ this.preferredCol = null;
+ }
+ }
+
+ /* =====================================================================
+ * Higher‑level helpers
+ * =================================================================== */
+
+ /**
+ * Insert an arbitrary string, possibly containing internal newlines.
+ * Returns true if the buffer was modified.
+ */
+ insertStr(str: string): boolean {
+ dbg('insertStr', { str, beforeCursor: this.getCursor() });
+ if (str === '') {
+ return false;
+ }
+
+ // Normalise all newline conventions (\r, \n, \r\n) to a single '\n'.
+ const normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+
+ // Fast path: resulted in single‑line string ➜ delegate back to insert
+ if (!normalised.includes('\n')) {
+ this.insert(normalised);
+ return true;
+ }
+
+ this.pushUndo();
+
+ const parts = normalised.split('\n');
+ const before = cpSlice(this.line(this.cursorRow), 0, this.cursorCol);
+ const after = cpSlice(this.line(this.cursorRow), this.cursorCol);
+
+ // Replace current line with first part combined with before text
+ this.lines[this.cursorRow] = before + parts[0];
+
+ // Middle lines (if any) are inserted verbatim after current row
+ if (parts.length > 2) {
+ const middle = parts.slice(1, -1);
+ this.lines.splice(this.cursorRow + 1, 0, ...middle);
+ }
+
+ // Smart handling of the *final* inserted part:
+ // • When the caret is mid‑line we preserve existing behaviour – merge
+ // the last part with the text to the **right** of the caret so that
+ // inserting in the middle of a line keeps the remainder on the same
+ // row (e.g. "he|llo" → paste "x\ny" ⇒ "he x", "y llo").
+ // • When the caret is at column‑0 we instead treat the current line as
+ // a *separate* row that follows the inserted block. This mirrors
+ // common editor behaviour and avoids the unintuitive merge that led
+ // to "cd"+"ef" → "cdef" in the failing tests.
+
+ // Append the last part combined with original after text as a new line
+ const last = parts[parts.length - 1] + after;
+ this.lines.splice(this.cursorRow + (parts.length - 1), 0, last);
+
+ // Update cursor position to end of last inserted part (before 'after')
+ this.cursorRow += parts.length - 1;
+ // `parts` is guaranteed to have at least one element here because
+ // `split("\n")` always returns an array with ≥1 entry. Tell the
+ // compiler so we can pass a plain `string` to `cpLen`.
+ this.cursorCol = cpLen(parts[parts.length - 1]!);
+
+ this.version++;
+ return true;
+ }
+
+ /* =====================================================================
+ * Selection & clipboard helpers (minimal)
+ * =================================================================== */
+
+ startSelection(): void {
+ this.selectionAnchor = [this.cursorRow, this.cursorCol];
+ }
+
+ endSelection(): void {
+ // no-op for now, kept for API symmetry
+ // we rely on anchor + current cursor to compute selection
+ }
+
+ /** Extract selected text. Returns null if no valid selection. */
+ private getSelectedText(): string | null {
+ if (!this.selectionAnchor) {
+ return null;
+ }
+ const [ar, ac] = this.selectionAnchor;
+ const [br, bc] = [this.cursorRow, this.cursorCol];
+
+ // Determine ordering
+ if (ar === br && ac === bc) {
+ return null;
+ } // empty selection
+
+ const topBefore = ar < br || (ar === br && ac < bc);
+ const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
+
+ if (sr === er) {
+ return cpSlice(this.line(sr), sc, ec);
+ }
+
+ const parts: string[] = [];
+ parts.push(cpSlice(this.line(sr), sc));
+ for (let r = sr + 1; r < er; r++) {
+ parts.push(this.line(r));
+ }
+ parts.push(cpSlice(this.line(er), 0, ec));
+ return parts.join('\n');
+ }
+
+ copy(): string | null {
+ const txt = this.getSelectedText();
+ if (txt == null) {
+ return null;
+ }
+ this.clipboard = txt;
+ return txt;
+ }
+
+ paste(): boolean {
+ if (this.clipboard == null) {
+ return false;
+ }
+ return this.insertStr(this.clipboard);
+ }
+
+ /* =======================================================================
+ * High level "handleInput" – receives what Ink gives us
+ * Returns true when buffer mutated (=> re‑render)
+ * ===================================================================== */
+ handleInput(
+ input: string | undefined,
+ key: Record<string, boolean>,
+ vp: Viewport,
+ ): boolean {
+ if (DEBUG) {
+ dbg('handleInput', { input, key, cursor: this.getCursor() });
+ }
+ const beforeVer = this.version;
+ const [beforeRow, beforeCol] = this.getCursor();
+
+ if (key['escape']) {
+ return false;
+ }
+
+ /* new line — Ink sets either `key.return` *or* passes a literal "\n" */
+ if (key['return'] || input === '\r' || input === '\n') {
+ this.newline();
+ } else if (
+ key['leftArrow'] &&
+ !key['meta'] &&
+ !key['ctrl'] &&
+ !key['alt']
+ ) {
+ /* navigation */
+ this.move('left');
+ } else if (
+ key['rightArrow'] &&
+ !key['meta'] &&
+ !key['ctrl'] &&
+ !key['alt']
+ ) {
+ this.move('right');
+ } else if (key['upArrow']) {
+ this.move('up');
+ } else if (key['downArrow']) {
+ this.move('down');
+ } else if ((key['meta'] || key['ctrl'] || key['alt']) && key['leftArrow']) {
+ this.move('wordLeft');
+ } else if (
+ (key['meta'] || key['ctrl'] || key['alt']) &&
+ key['rightArrow']
+ ) {
+ this.move('wordRight');
+ } else if (key['home']) {
+ this.move('home');
+ } else if (key['end']) {
+ this.move('end');
+ }
+ /* delete */
+ // In raw terminal mode many frameworks (Ink included) surface a physical
+ // Backspace key‑press as the single DEL (0x7f) byte placed in `input` with
+ // no `key.backspace` flag set. Treat that byte exactly like an ordinary
+ // Backspace for parity with textarea.rs and to make interactive tests
+ // feedable through the simpler `(ch, {}, vp)` path.
+ else if (
+ (key['meta'] || key['ctrl'] || key['alt']) &&
+ (key['backspace'] || input === '\x7f')
+ ) {
+ this.deleteWordLeft();
+ } else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete']) {
+ this.deleteWordRight();
+ } else if (
+ key['backspace'] ||
+ input === '\x7f' ||
+ (key['delete'] && !key['shift'])
+ ) {
+ // Treat un‑modified "delete" (the common Mac backspace key) as a
+ // standard backspace. Holding Shift+Delete continues to perform a
+ // forward deletion so we don't lose that capability on keyboards that
+ // expose both behaviours.
+ this.backspace();
+ }
+ // Forward deletion (Fn+Delete on macOS, or Delete key with Shift held after
+ // the branch above) – remove the character *under / to the right* of the
+ // caret, merging lines when at EOL similar to many editors.
+ else if (key['delete']) {
+ this.del();
+ } else if (input && !key['ctrl'] && !key['meta']) {
+ this.insert(input);
+ }
+
+ /* printable */
+
+ /* clamp + scroll */
+ this.ensureCursorInRange();
+ this.ensureCursorVisible(vp);
+
+ const cursorMoved =
+ this.cursorRow !== beforeRow || this.cursorCol !== beforeCol;
+
+ if (DEBUG) {
+ dbg('handleInput:after', {
+ cursor: this.getCursor(),
+ text: this.getText(),
+ });
+ }
+ return this.version !== beforeVer || cursorMoved;
+ }
+}
diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts
index f8c873f1..90947662 100644
--- a/packages/cli/src/ui/hooks/useInputHistory.ts
+++ b/packages/cli/src/ui/hooks/useInputHistory.ts
@@ -4,36 +4,32 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useCallback, useState } from 'react';
-import { useInput } from 'ink';
+import { useState, useCallback } from 'react';
interface UseInputHistoryProps {
userMessages: readonly string[];
onSubmit: (value: string) => void;
isActive: boolean;
- query: string;
- setQuery: React.Dispatch<React.SetStateAction<string>>;
+ currentQuery: string; // Renamed from query to avoid confusion
+ onChangeAndMoveCursor: (value: string) => void;
}
interface UseInputHistoryReturn {
- query: string;
- setQuery: React.Dispatch<React.SetStateAction<string>>;
handleSubmit: (value: string) => void;
- inputKey: number;
- setInputKey: React.Dispatch<React.SetStateAction<number>>;
+ navigateUp: () => boolean;
+ navigateDown: () => boolean;
}
export function useInputHistory({
userMessages,
onSubmit,
isActive,
- query,
- setQuery,
+ currentQuery,
+ onChangeAndMoveCursor: setQueryAndMoveCursor,
}: UseInputHistoryProps): UseInputHistoryReturn {
const [historyIndex, setHistoryIndex] = useState<number>(-1);
const [originalQueryBeforeNav, setOriginalQueryBeforeNav] =
useState<string>('');
- const [inputKey, setInputKey] = useState<number>(0);
const resetHistoryNav = useCallback(() => {
setHistoryIndex(-1);
@@ -44,71 +40,72 @@ export function useInputHistory({
(value: string) => {
const trimmedValue = value.trim();
if (trimmedValue) {
- onSubmit(trimmedValue); // This will call handleFinalSubmit, which then calls setQuery('') from App.tsx
+ onSubmit(trimmedValue); // Parent handles clearing the query
}
resetHistoryNav();
},
[onSubmit, resetHistoryNav],
);
- useInput(
- (input, key) => {
- if (!isActive) {
- return;
- }
-
- let didNavigate = false;
+ const navigateUp = useCallback(() => {
+ if (!isActive) return false;
+ if (userMessages.length === 0) return false;
- if (key.upArrow) {
- if (userMessages.length === 0) return;
+ let nextIndex = historyIndex;
+ if (historyIndex === -1) {
+ // Store the current query from the parent before navigating
+ setOriginalQueryBeforeNav(currentQuery);
+ nextIndex = 0;
+ } else if (historyIndex < userMessages.length - 1) {
+ nextIndex = historyIndex + 1;
+ } else {
+ return false; // Already at the oldest message
+ }
- let nextIndex = historyIndex;
- if (historyIndex === -1) {
- setOriginalQueryBeforeNav(query);
- nextIndex = 0;
- } else if (historyIndex < userMessages.length - 1) {
- nextIndex = historyIndex + 1;
- } else {
- return;
- }
+ if (nextIndex !== historyIndex) {
+ setHistoryIndex(nextIndex);
+ const newValue = userMessages[userMessages.length - 1 - nextIndex];
+ setQueryAndMoveCursor(newValue); // Call the prop passed from parent
+ return true;
+ }
+ return false;
+ }, [
+ historyIndex,
+ setHistoryIndex,
+ setQueryAndMoveCursor,
+ userMessages,
+ isActive,
+ currentQuery, // Use currentQuery from props
+ setOriginalQueryBeforeNav,
+ ]);
- if (nextIndex !== historyIndex) {
- setHistoryIndex(nextIndex);
- const newValue = userMessages[userMessages.length - 1 - nextIndex];
- setQuery(newValue);
- setInputKey((k) => k + 1);
- didNavigate = true;
- }
- } else if (key.downArrow) {
- if (historyIndex === -1) return;
+ const navigateDown = useCallback(() => {
+ if (!isActive) return false;
+ if (historyIndex === -1) return false; // Not currently navigating history
- const nextIndex = historyIndex - 1;
- setHistoryIndex(nextIndex);
+ const nextIndex = historyIndex - 1;
+ setHistoryIndex(nextIndex);
- if (nextIndex === -1) {
- setQuery(originalQueryBeforeNav);
- } else {
- const newValue = userMessages[userMessages.length - 1 - nextIndex];
- setQuery(newValue);
- }
- setInputKey((k) => k + 1);
- didNavigate = true;
- } else {
- if (historyIndex !== -1 && !didNavigate) {
- if (input || key.backspace || key.delete) {
- resetHistoryNav();
- }
- }
- }
- },
- { isActive },
- );
+ if (nextIndex === -1) {
+ // Reached the end of history navigation, restore original query
+ setQueryAndMoveCursor(originalQueryBeforeNav);
+ } else {
+ const newValue = userMessages[userMessages.length - 1 - nextIndex];
+ setQueryAndMoveCursor(newValue);
+ }
+ return true;
+ }, [
+ historyIndex,
+ setHistoryIndex,
+ originalQueryBeforeNav,
+ setQueryAndMoveCursor,
+ userMessages,
+ isActive,
+ ]);
return {
- query,
- setQuery,
handleSubmit,
- inputKey,
- setInputKey,
+ navigateUp,
+ navigateDown,
};
}
diff --git a/packages/cli/src/ui/hooks/useTerminalSize.ts b/packages/cli/src/ui/hooks/useTerminalSize.ts
new file mode 100644
index 00000000..f043a118
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useTerminalSize.ts
@@ -0,0 +1,32 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useState } from 'react';
+
+const TERMINAL_PADDING_X = 8;
+
+export function useTerminalSize(): { columns: number; rows: number } {
+ const [size, setSize] = useState({
+ columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
+ rows: process.stdout.rows || 20,
+ });
+
+ useEffect(() => {
+ function updateSize() {
+ setSize({
+ columns: (process.stdout.columns || 60) - TERMINAL_PADDING_X,
+ rows: process.stdout.rows || 20,
+ });
+ }
+
+ process.stdout.on('resize', updateSize);
+ return () => {
+ process.stdout.off('resize', updateSize);
+ };
+ }, []);
+
+ return size;
+}