/ src / hooks / useTextInput.ts
useTextInput.ts
  1  import { useState } from 'react'
  2  import { type Key } from 'ink'
  3  import { useDoublePress } from './useDoublePress.js'
  4  import { Cursor } from '../utils/Cursor.js'
  5  import {
  6    getImageFromClipboard,
  7    CLIPBOARD_ERROR_MESSAGE,
  8  } from '../utils/imagePaste.js'
  9  
 10  const IMAGE_PLACEHOLDER = '[Image pasted]'
 11  
 12  type MaybeCursor = void | Cursor
 13  type InputHandler = (input: string) => MaybeCursor
 14  type InputMapper = (input: string) => MaybeCursor
 15  function mapInput(input_map: Array<[string, InputHandler]>): InputMapper {
 16    return function (input: string): MaybeCursor {
 17      const handler = new Map(input_map).get(input) ?? (() => {})
 18      return handler(input)
 19    }
 20  }
 21  
 22  type UseTextInputProps = {
 23    value: string
 24    onChange: (value: string) => void
 25    onSubmit?: (value: string) => void
 26    onExit?: () => void
 27    onExitMessage?: (show: boolean, key?: string) => void
 28    onMessage?: (show: boolean, message?: string) => void
 29    onHistoryUp?: () => void
 30    onHistoryDown?: () => void
 31    onHistoryReset?: () => void
 32    focus?: boolean
 33    mask?: string
 34    multiline?: boolean
 35    cursorChar: string
 36    highlightPastedText?: boolean
 37    invert: (text: string) => string
 38    themeText: (text: string) => string
 39    columns: number
 40    onImagePaste?: (base64Image: string) => void
 41    disableCursorMovementForUpDownKeys?: boolean
 42    externalOffset: number
 43    onOffsetChange: (offset: number) => void
 44  }
 45  
 46  type UseTextInputResult = {
 47    renderedValue: string
 48    onInput: (input: string, key: Key) => void
 49    offset: number
 50    setOffset: (offset: number) => void
 51  }
 52  
 53  export function useTextInput({
 54    value: originalValue,
 55    onChange,
 56    onSubmit,
 57    onExit,
 58    onExitMessage,
 59    onMessage,
 60    onHistoryUp,
 61    onHistoryDown,
 62    onHistoryReset,
 63    mask = '',
 64    multiline = false,
 65    cursorChar,
 66    invert,
 67    columns,
 68    onImagePaste,
 69    disableCursorMovementForUpDownKeys = false,
 70    externalOffset,
 71    onOffsetChange,
 72  }: UseTextInputProps): UseTextInputResult {
 73    const offset = externalOffset
 74    const setOffset = onOffsetChange
 75    const cursor = Cursor.fromText(originalValue, columns, offset)
 76    const [imagePasteErrorTimeout, setImagePasteErrorTimeout] =
 77      useState<NodeJS.Timeout | null>(null)
 78  
 79    function maybeClearImagePasteErrorTimeout() {
 80      if (!imagePasteErrorTimeout) {
 81        return
 82      }
 83      clearTimeout(imagePasteErrorTimeout)
 84      setImagePasteErrorTimeout(null)
 85      onMessage?.(false)
 86    }
 87  
 88    const handleCtrlC = useDoublePress(
 89      show => {
 90        maybeClearImagePasteErrorTimeout()
 91        onExitMessage?.(show, 'Ctrl-C')
 92      },
 93      () => onExit?.(),
 94      () => {
 95        if (originalValue) {
 96          onChange('')
 97          onHistoryReset?.()
 98        }
 99      },
100    )
101  
102    // Keep Escape for clearing input
103    const handleEscape = useDoublePress(
104      show => {
105        maybeClearImagePasteErrorTimeout()
106        onMessage?.(!!originalValue && show, `Press Escape again to clear`)
107      },
108      () => {
109        if (originalValue) {
110          onChange('')
111        }
112      },
113    )
114    function clear() {
115      return Cursor.fromText('', columns, 0)
116    }
117  
118    const handleEmptyCtrlD = useDoublePress(
119      show => onExitMessage?.(show, 'Ctrl-D'),
120      () => onExit?.(),
121    )
122  
123    function handleCtrlD(): MaybeCursor {
124      maybeClearImagePasteErrorTimeout()
125      if (cursor.text === '') {
126        // When input is empty, handle double-press
127        handleEmptyCtrlD()
128        return cursor
129      }
130      // When input is not empty, delete forward like iPython
131      return cursor.del()
132    }
133  
134    function tryImagePaste() {
135      const base64Image = getImageFromClipboard()
136      if (base64Image === null) {
137        if (process.platform !== 'darwin') {
138          return cursor
139        }
140        onMessage?.(true, CLIPBOARD_ERROR_MESSAGE)
141        maybeClearImagePasteErrorTimeout()
142        setImagePasteErrorTimeout(
143          // @ts-expect-error: Bun is overloading types here, but we're using the NodeJS runtime
144          setTimeout(() => {
145            onMessage?.(false)
146          }, 4000),
147        )
148        return cursor
149      }
150  
151      onImagePaste?.(base64Image)
152      return cursor.insert(IMAGE_PLACEHOLDER)
153    }
154  
155    const handleCtrl = mapInput([
156      ['a', () => cursor.startOfLine()],
157      ['b', () => cursor.left()],
158      ['c', handleCtrlC],
159      ['d', handleCtrlD],
160      ['e', () => cursor.endOfLine()],
161      ['f', () => cursor.right()],
162      ['h', () => cursor.backspace()],
163      ['k', () => cursor.deleteToLineEnd()],
164      ['l', () => clear()],
165      ['n', () => downOrHistoryDown()],
166      ['p', () => upOrHistoryUp()],
167      ['u', () => cursor.deleteToLineStart()],
168      ['v', tryImagePaste],
169      ['w', () => cursor.deleteWordBefore()],
170    ])
171  
172    const handleMeta = mapInput([
173      ['b', () => cursor.prevWord()],
174      ['f', () => cursor.nextWord()],
175      ['d', () => cursor.deleteWordAfter()],
176    ])
177  
178    function handleEnter(key: Key) {
179      if (
180        multiline &&
181        cursor.offset > 0 &&
182        cursor.text[cursor.offset - 1] === '\\'
183      ) {
184        return cursor.backspace().insert('\n')
185      }
186      if (key.meta) {
187        return cursor.insert('\n')
188      }
189      onSubmit?.(originalValue)
190    }
191  
192    function upOrHistoryUp() {
193      if (disableCursorMovementForUpDownKeys) {
194        onHistoryUp?.()
195        return cursor
196      }
197      const cursorUp = cursor.up()
198      if (cursorUp.equals(cursor)) {
199        // already at beginning
200        onHistoryUp?.()
201      }
202      return cursorUp
203    }
204    function downOrHistoryDown() {
205      if (disableCursorMovementForUpDownKeys) {
206        onHistoryDown?.()
207        return cursor
208      }
209      const cursorDown = cursor.down()
210      if (cursorDown.equals(cursor)) {
211        onHistoryDown?.()
212      }
213      return cursorDown
214    }
215  
216    function mapKey(key: Key): InputMapper {
217      switch (true) {
218        case key.escape:
219          return handleEscape
220        case key.leftArrow && (key.ctrl || key.meta || key.fn):
221          return () => cursor.prevWord()
222        case key.rightArrow && (key.ctrl || key.meta || key.fn):
223          return () => cursor.nextWord()
224        case key.backspace:
225          return key.meta
226            ? () => cursor.deleteWordBefore()
227            : () => cursor.backspace()
228        case key.delete:
229          return key.meta ? () => cursor.deleteToLineEnd() : () => cursor.del()
230        case key.ctrl:
231          return handleCtrl
232        case key.home:
233          return () => cursor.startOfLine()
234        case key.end:
235          return () => cursor.endOfLine()
236        case key.pageDown:
237          return () => cursor.endOfLine()
238        case key.pageUp:
239          return () => cursor.startOfLine()
240        case key.meta:
241          return handleMeta
242        case key.return:
243          return () => handleEnter(key)
244        case key.tab:
245          return () => {}
246        case key.upArrow:
247          return upOrHistoryUp
248        case key.downArrow:
249          return downOrHistoryDown
250        case key.leftArrow:
251          return () => cursor.left()
252        case key.rightArrow:
253          return () => cursor.right()
254      }
255      return function (input: string) {
256        switch (true) {
257          // Home key
258          case input == '\x1b[H' || input == '\x1b[1~':
259            return cursor.startOfLine()
260          // End key
261          case input == '\x1b[F' || input == '\x1b[4~':
262            return cursor.endOfLine()
263          default:
264            return cursor.insert(input.replace(/\r/g, '\n'))
265        }
266      }
267    }
268  
269    function onInput(input: string, key: Key): void {
270      const nextCursor = mapKey(key)(input)
271      if (nextCursor) {
272        if (!cursor.equals(nextCursor)) {
273          setOffset(nextCursor.offset)
274          if (cursor.text != nextCursor.text) {
275            onChange(nextCursor.text)
276          }
277        }
278      }
279    }
280  
281    return {
282      onInput,
283      renderedValue: cursor.render(cursorChar, mask, invert),
284      offset,
285      setOffset,
286    }
287  }