/ src / components / TextInput.tsx
TextInput.tsx
  1  import React from 'react'
  2  import { Text, useInput } from 'ink'
  3  import chalk from 'chalk'
  4  import { useTextInput } from '../hooks/useTextInput.js'
  5  import { getTheme } from '../utils/theme.js'
  6  import { type Key } from 'ink'
  7  
  8  export type Props = {
  9    /**
 10     * Optional callback for handling history navigation on up arrow at start of input
 11     */
 12    readonly onHistoryUp?: () => void
 13  
 14    /**
 15     * Optional callback for handling history navigation on down arrow at end of input
 16     */
 17    readonly onHistoryDown?: () => void
 18  
 19    /**
 20     * Text to display when `value` is empty.
 21     */
 22    readonly placeholder?: string
 23  
 24    /**
 25     * Allow multi-line input via line ending with backslash (default: `true`)
 26     */
 27    readonly multiline?: boolean
 28  
 29    /**
 30     * Listen to user's input. Useful in case there are multiple input components
 31     * at the same time and input must be "routed" to a specific component.
 32     */
 33    readonly focus?: boolean
 34  
 35    /**
 36     * Replace all chars and mask the value. Useful for password inputs.
 37     */
 38    readonly mask?: string
 39  
 40    /**
 41     * Whether to show cursor and allow navigation inside text input with arrow keys.
 42     */
 43    readonly showCursor?: boolean
 44  
 45    /**
 46     * Highlight pasted text
 47     */
 48    readonly highlightPastedText?: boolean
 49  
 50    /**
 51     * Value to display in a text input.
 52     */
 53    readonly value: string
 54  
 55    /**
 56     * Function to call when value updates.
 57     */
 58    readonly onChange: (value: string) => void
 59  
 60    /**
 61     * Function to call when `Enter` is pressed, where first argument is a value of the input.
 62     */
 63    readonly onSubmit?: (value: string) => void
 64  
 65    /**
 66     * Function to call when Ctrl+C is pressed to exit.
 67     */
 68    readonly onExit?: () => void
 69  
 70    /**
 71     * Optional callback to show exit message
 72     */
 73    readonly onExitMessage?: (show: boolean, key?: string) => void
 74  
 75    /**
 76     * Optional callback to show custom message
 77     */
 78    readonly onMessage?: (show: boolean, message?: string) => void
 79  
 80    /**
 81     * Optional callback to reset history position
 82     */
 83    readonly onHistoryReset?: () => void
 84  
 85    /**
 86     * Number of columns to wrap text at
 87     */
 88    readonly columns: number
 89  
 90    /**
 91     * Optional callback when an image is pasted
 92     */
 93    readonly onImagePaste?: (base64Image: string) => void
 94  
 95    /**
 96     * Optional callback when a large text (over 800 chars) is pasted
 97     */
 98    readonly onPaste?: (text: string) => void
 99  
100    /**
101     * Whether the input is dimmed and non-interactive
102     */
103    readonly isDimmed?: boolean
104  
105    /**
106     * Whether to disable cursor movement for up/down arrow keys
107     */
108    readonly disableCursorMovementForUpDownKeys?: boolean
109  
110    readonly cursorOffset: number
111  
112    /**
113     * Callback to set the offset of the cursor
114     */
115    onChangeCursorOffset: (offset: number) => void
116  }
117  
118  export default function TextInput({
119    value: originalValue,
120    placeholder = '',
121    focus = true,
122    mask,
123    multiline = false,
124    highlightPastedText = false,
125    showCursor = true,
126    onChange,
127    onSubmit,
128    onExit,
129    onHistoryUp,
130    onHistoryDown,
131    onExitMessage,
132    onMessage,
133    onHistoryReset,
134    columns,
135    onImagePaste,
136    onPaste,
137    isDimmed = false,
138    disableCursorMovementForUpDownKeys = false,
139    cursorOffset,
140    onChangeCursorOffset,
141  }: Props): JSX.Element {
142    const { onInput, renderedValue } = useTextInput({
143      value: originalValue,
144      onChange,
145      onSubmit,
146      onExit,
147      onExitMessage,
148      onMessage,
149      onHistoryReset,
150      onHistoryUp,
151      onHistoryDown,
152      focus,
153      mask,
154      multiline,
155      cursorChar: showCursor ? ' ' : '',
156      highlightPastedText,
157      invert: chalk.inverse,
158      themeText: (text: string) => chalk.hex(getTheme().text)(text),
159      columns,
160      onImagePaste,
161      disableCursorMovementForUpDownKeys,
162      externalOffset: cursorOffset,
163      onOffsetChange: onChangeCursorOffset,
164    })
165  
166    // Paste detection state
167    const [pasteState, setPasteState] = React.useState<{
168      chunks: string[]
169      timeoutId: ReturnType<typeof setTimeout> | null
170    }>({ chunks: [], timeoutId: null })
171  
172    const resetPasteTimeout = (
173      currentTimeoutId: ReturnType<typeof setTimeout> | null,
174    ) => {
175      if (currentTimeoutId) {
176        clearTimeout(currentTimeoutId)
177      }
178      return setTimeout(() => {
179        setPasteState(({ chunks }) => {
180          const pastedText = chunks.join('')
181          // Schedule callback after current render to avoid state updates during render
182          Promise.resolve().then(() => onPaste!(pastedText))
183          return { chunks: [], timeoutId: null }
184        })
185      }, 100)
186    }
187  
188    const wrappedOnInput = (input: string, key: Key): void => {
189      // Handle pastes (>800 chars)
190      // Usually we get one or two input characters at a time. If we
191      // get a bunch, the user has probably pasted.
192      // Unfortunately node batches long pastes, so it's possible
193      // that we would see e.g. 1024 characters and then just a few
194      // more in the next frame that belong with the original paste.
195      // This batching number is not consistent.
196      if (onPaste && (input.length > 800 || pasteState.timeoutId)) {
197        setPasteState(({ chunks, timeoutId }) => {
198          return {
199            chunks: [...chunks, input],
200            timeoutId: resetPasteTimeout(timeoutId),
201          }
202        })
203        return
204      }
205  
206      onInput(input, key)
207    }
208  
209    useInput(wrappedOnInput, { isActive: focus })
210  
211    let renderedPlaceholder = placeholder
212      ? chalk.hex(getTheme().secondaryText)(placeholder)
213      : undefined
214  
215    // Fake mouse cursor, because we like punishment
216    if (showCursor && focus) {
217      renderedPlaceholder =
218        placeholder.length > 0
219          ? chalk.inverse(placeholder[0]) +
220            chalk.hex(getTheme().secondaryText)(placeholder.slice(1))
221          : chalk.inverse(' ')
222    }
223  
224    const showPlaceholder = originalValue.length == 0 && placeholder
225    return (
226      <Text wrap="truncate-end" dimColor={isDimmed}>
227        {showPlaceholder ? renderedPlaceholder : renderedValue}
228      </Text>
229    )
230  }