/ utils / earlyInput.ts
earlyInput.ts
  1  /**
  2   * Early Input Capture
  3   *
  4   * This module captures terminal input that is typed before the REPL is fully
  5   * initialized. Users often type `claude` and immediately start typing their
  6   * prompt, but those early keystrokes would otherwise be lost during startup.
  7   *
  8   * Usage:
  9   * 1. Call startCapturingEarlyInput() as early as possible in cli.tsx
 10   * 2. When REPL is ready, call consumeEarlyInput() to get any buffered text
 11   * 3. stopCapturingEarlyInput() is called automatically when input is consumed
 12   */
 13  
 14  import { lastGrapheme } from './intl.js'
 15  
 16  // Buffer for early input characters
 17  let earlyInputBuffer = ''
 18  // Flag to track if we're currently capturing
 19  let isCapturing = false
 20  // Reference to the readable handler so we can remove it later
 21  let readableHandler: (() => void) | null = null
 22  
 23  /**
 24   * Start capturing stdin data early, before the REPL is initialized.
 25   * Should be called as early as possible in the startup sequence.
 26   *
 27   * Only captures if stdin is a TTY (interactive terminal).
 28   */
 29  export function startCapturingEarlyInput(): void {
 30    // Only capture in interactive mode: stdin must be a TTY, and we must not
 31    // be in print mode. Raw mode disables ISIG (terminal Ctrl+C → SIGINT),
 32    // which would make -p uninterruptible.
 33    if (
 34      !process.stdin.isTTY ||
 35      isCapturing ||
 36      process.argv.includes('-p') ||
 37      process.argv.includes('--print')
 38    ) {
 39      return
 40    }
 41  
 42    isCapturing = true
 43    earlyInputBuffer = ''
 44  
 45    // Set stdin to raw mode and use 'readable' event like Ink does
 46    // This ensures compatibility with how the REPL will handle stdin later
 47    try {
 48      process.stdin.setEncoding('utf8')
 49      process.stdin.setRawMode(true)
 50      process.stdin.ref()
 51  
 52      readableHandler = () => {
 53        let chunk = process.stdin.read()
 54        while (chunk !== null) {
 55          if (typeof chunk === 'string') {
 56            processChunk(chunk)
 57          }
 58          chunk = process.stdin.read()
 59        }
 60      }
 61  
 62      process.stdin.on('readable', readableHandler)
 63    } catch {
 64      // If we can't set raw mode, just silently continue without early capture
 65      isCapturing = false
 66    }
 67  }
 68  
 69  /**
 70   * Process a chunk of input data
 71   */
 72  function processChunk(str: string): void {
 73    let i = 0
 74    while (i < str.length) {
 75      const char = str[i]!
 76      const code = char.charCodeAt(0)
 77  
 78      // Ctrl+C (code 3) - stop capturing and exit immediately.
 79      // We use process.exit here instead of gracefulShutdown because at this
 80      // early stage of startup, the shutdown machinery isn't initialized yet.
 81      if (code === 3) {
 82        stopCapturingEarlyInput()
 83        // eslint-disable-next-line custom-rules/no-process-exit
 84        process.exit(130) // Standard exit code for Ctrl+C
 85        return
 86      }
 87  
 88      // Ctrl+D (code 4) - EOF, stop capturing
 89      if (code === 4) {
 90        stopCapturingEarlyInput()
 91        return
 92      }
 93  
 94      // Backspace (code 127 or 8) - remove last grapheme cluster
 95      if (code === 127 || code === 8) {
 96        if (earlyInputBuffer.length > 0) {
 97          const last = lastGrapheme(earlyInputBuffer)
 98          earlyInputBuffer = earlyInputBuffer.slice(0, -(last.length || 1))
 99        }
100        i++
101        continue
102      }
103  
104      // Skip escape sequences (arrow keys, function keys, focus events, etc.)
105      // All escape sequences start with ESC (0x1B) and end with a byte in 0x40-0x7E
106      if (code === 27) {
107        i++ // Skip the ESC character
108        // Skip until the terminating byte (@ to ~) or end of string
109        while (
110          i < str.length &&
111          !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126)
112        ) {
113          i++
114        }
115        if (i < str.length) i++ // Skip the terminating byte
116        continue
117      }
118  
119      // Skip other control characters (except tab and newline)
120      if (code < 32 && code !== 9 && code !== 10 && code !== 13) {
121        i++
122        continue
123      }
124  
125      // Convert carriage return to newline
126      if (code === 13) {
127        earlyInputBuffer += '\n'
128        i++
129        continue
130      }
131  
132      // Add printable characters and allowed control chars to buffer
133      earlyInputBuffer += char
134      i++
135    }
136  }
137  
138  /**
139   * Stop capturing early input.
140   * Called automatically when input is consumed, or can be called manually.
141   */
142  export function stopCapturingEarlyInput(): void {
143    if (!isCapturing) {
144      return
145    }
146  
147    isCapturing = false
148  
149    if (readableHandler) {
150      process.stdin.removeListener('readable', readableHandler)
151      readableHandler = null
152    }
153  
154    // Don't reset stdin state - the REPL's Ink App will manage stdin state.
155    // If we call setRawMode(false) here, it can interfere with the REPL's
156    // own stdin setup which happens around the same time.
157  }
158  
159  /**
160   * Consume any early input that was captured.
161   * Returns the captured input and clears the buffer.
162   * Automatically stops capturing when called.
163   */
164  export function consumeEarlyInput(): string {
165    stopCapturingEarlyInput()
166    const input = earlyInputBuffer.trim()
167    earlyInputBuffer = ''
168    return input
169  }
170  
171  /**
172   * Check if there is any early input available without consuming it.
173   */
174  export function hasEarlyInput(): boolean {
175    return earlyInputBuffer.trim().length > 0
176  }
177  
178  /**
179   * Seed the early input buffer with text that will appear pre-filled
180   * in the prompt input when the REPL renders. Does not auto-submit.
181   */
182  export function seedEarlyInput(text: string): void {
183    earlyInputBuffer = text
184  }
185  
186  /**
187   * Check if early input capture is currently active.
188   */
189  export function isCapturingEarlyInput(): boolean {
190    return isCapturing
191  }