From e2c3611c637f6d18fd5f6354e496b89cdb6bd173 Mon Sep 17 00:00:00 2001 From: Jacob Richman Date: Tue, 13 May 2025 11:24:04 -0700 Subject: [PATCH] Multiline editor (#302) Co-authored-by: Taylor Mullen --- packages/cli/src/ui/App.tsx | 44 +- .../cli/src/ui/components/InputPrompt.tsx | 90 +- .../ui/components/shared/multiline-editor.tsx | 276 +++++ .../src/ui/components/shared/text-buffer.ts | 1049 +++++++++++++++++ packages/cli/src/ui/hooks/useInputHistory.ts | 123 +- packages/cli/src/ui/hooks/useTerminalSize.ts | 32 + 6 files changed, 1501 insertions(+), 113 deletions(-) create mode 100644 packages/cli/src/ui/components/shared/multiline-editor.tsx create mode 100644 packages/cli/src/ui/components/shared/text-buffer.ts create mode 100644 packages/cli/src/ui/hooks/useTerminalSize.ts 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({ + 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) => { {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>; - inputKey: number; - setInputKey: React.Dispatch>; + 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 = ({ 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 = ({ 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 = ({ } 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 = ({ 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 ( > - { // 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). }} /> 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 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, + { 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 ( + + {buffer.getText().length === 0 && placeholder ? ( + {placeholder} + ) : ( + 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 {display}; + }) + )} + + ); +}; 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 { + 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, + 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>; + currentQuery: string; // Renamed from query to avoid confusion + onChangeAndMoveCursor: (value: string) => void; } interface UseInputHistoryReturn { - query: string; - setQuery: React.Dispatch>; handleSubmit: (value: string) => void; - inputKey: number; - setInputKey: React.Dispatch>; + navigateUp: () => boolean; + navigateDown: () => boolean; } export function useInputHistory({ userMessages, onSubmit, isActive, - query, - setQuery, + currentQuery, + onChangeAndMoveCursor: setQueryAndMoveCursor, }: UseInputHistoryProps): UseInputHistoryReturn { const [historyIndex, setHistoryIndex] = useState(-1); const [originalQueryBeforeNav, setOriginalQueryBeforeNav] = useState(''); - const [inputKey, setInputKey] = useState(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; - } + const navigateUp = useCallback(() => { + if (!isActive) return false; + if (userMessages.length === 0) return false; - let didNavigate = false; + 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 + } - if (key.upArrow) { - if (userMessages.length === 0) 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, + ]); - let nextIndex = historyIndex; - if (historyIndex === -1) { - setOriginalQueryBeforeNav(query); - nextIndex = 0; - } else if (historyIndex < userMessages.length - 1) { - nextIndex = historyIndex + 1; - } else { - return; - } + const navigateDown = useCallback(() => { + if (!isActive) return false; + if (historyIndex === -1) return false; // Not currently navigating history - 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 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; +}