@miphamai/cli 0.56.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/input.tsx CHANGED
@@ -1,7 +1,6 @@
1
1
  import React, { useState, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
- import { VimMotionEngine, handleSearchBackspace, type VimMode } from './vim-motions.js'
5
4
  import { getCommandList } from './commands.js'
6
5
  import { CommandPicker } from './command-picker.js'
7
6
  import { useI18n } from '../i18n-context'
@@ -20,7 +19,7 @@ interface InputBarProps {
20
19
  onToggleAgentView?: () => void
21
20
  /** Shift+Tab → cycle permission mode */
22
21
  onCyclePermission?: () => void
23
- /** Escape → cancel loading (when input is empty) */
22
+ /** Escape → cancel loading / clear draft */
24
23
  onCancel?: () => void
25
24
  }
26
25
 
@@ -68,6 +67,14 @@ function pick<T>(arr: T[]): T {
68
67
  return arr[Math.floor(Math.random() * arr.length)]!
69
68
  }
70
69
 
70
+ /**
71
+ * 判断一次输入是否为「批量」(长度跳变 >1:粘贴 / IME 替换)。
72
+ * 批量输入需节流防渲染风暴;普通单字符输入应立即显示,无节流延迟。
73
+ */
74
+ export function isBulkInput(prev: string, next: string): boolean {
75
+ return Math.abs(next.length - prev.length) > 1
76
+ }
77
+
71
78
  export function InputBar({
72
79
  onSubmit,
73
80
  isLoading,
@@ -145,15 +152,6 @@ export function InputBar({
145
152
  prevLoading.current = isLoading
146
153
  }, [isLoading])
147
154
 
148
- const [vimMode, setVimMode] = useState<VimMode>('insert')
149
- // NOTE: Dual mode state — React state (vimMode) drives UI re-renders (prompt color,
150
- // placeholder); engine mode (vimEngine.current.mode) drives logic inside useInput so
151
- // the handler always reads the authoritative mode without stale-closure risk.
152
- const vimEngine = useRef(new VimMotionEngine())
153
- const vimPending = useRef<string | null>(null)
154
- const [searchMode, setSearchMode] = useState(false)
155
- const [searchQuery, setSearchQuery] = useState('')
156
-
157
155
  // Track pre-shortcut value so we can revert ink-text-input's Ctrl-key insertions.
158
156
  // ink-text-input inserts 'p'/'f'/'o' for Ctrl+P/F/O (Ink normalizes control chars
159
157
  // to their letter names before passing to useInput).
@@ -163,32 +161,21 @@ export function InputBar({
163
161
  valueBeforeShortcut.current = value
164
162
  }, [value])
165
163
 
166
- // ── Vim motions: intercept keys in normal mode ──
167
-
168
164
  useInput((input, key) => {
169
- // ── Escape: cancel loading → toggle vim mode ──
165
+ // ── Escape: cancel loading → clear draft ──
170
166
  if (key.escape) {
171
- // Cancel search mode if active
172
- if (searchMode) {
173
- setSearchMode(false)
174
- setSearchQuery('')
175
- return
176
- }
177
- // Escape while loading → abort (priority over vim toggle)
167
+ // Escape while loading → abort
178
168
  if (isLoading) {
179
169
  onCancel?.()
180
170
  return
181
171
  }
182
- // Clear any pending multi-key sequence
183
- if (vimPending.current) {
184
- vimPending.current = null
185
- }
186
- setVimMode((prev) => (prev === 'insert' ? 'normal' : 'insert'))
187
- vimEngine.current.mode = vimEngine.current.mode === 'insert' ? 'normal' : 'insert'
172
+ // Idle → clear the draft (the intuitive "cancel")
173
+ setValue('')
174
+ valueRef.current = ''
188
175
  return
189
176
  }
190
177
 
191
- // ── Global hotkeys (work in both insert and normal modes) ──
178
+ // ── Global hotkeys ──
192
179
  // Shift+Tab → cycle permission mode
193
180
  if (key.shift && key.tab) {
194
181
  onCyclePermission?.()
@@ -221,121 +208,43 @@ export function InputBar({
221
208
  return
222
209
  }
223
210
 
224
- // ── Arrow-key history navigation (insert mode only, Claude Code parity) ──
225
- if (vimEngine.current.mode === 'insert' && !searchMode) {
226
- if (key.upArrow || key.downArrow) {
227
- // Ignore if picker is active (command picker handles its own arrows)
228
- if (value.startsWith('/')) return
229
-
230
- if (key.upArrow) {
231
- if (submittedHistory.length === 0) return
232
- // Save current draft the first time we enter history browsing
233
- if (historyIndexRef.current === -1) {
234
- // Flush any pending throttle so its deferred setValue doesn't overwrite
235
- // history navigation (paste → immediate ↑ race). Save the latest value
236
- // from the ref — state `value` is stale inside the throttle window.
237
- if (onChangeTimerRef.current) {
238
- clearTimeout(onChangeTimerRef.current)
239
- onChangeTimerRef.current = null
240
- }
241
- savedDraftRef.current = valueRef.current
211
+ // ── Arrow-key history navigation (Claude Code parity) ──
212
+ if (key.upArrow || key.downArrow) {
213
+ // Ignore if picker is active (command picker handles its own arrows)
214
+ if (value.startsWith('/')) return
215
+
216
+ if (key.upArrow) {
217
+ if (submittedHistory.length === 0) return
218
+ // Save current draft the first time we enter history browsing
219
+ if (historyIndexRef.current === -1) {
220
+ // Flush any pending throttle so its deferred setValue doesn't overwrite
221
+ // history navigation (paste → immediate ↑ race). Save the latest value
222
+ // from the ref — state `value` is stale inside the throttle window.
223
+ if (onChangeTimerRef.current) {
224
+ clearTimeout(onChangeTimerRef.current)
225
+ onChangeTimerRef.current = null
242
226
  }
243
- const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
244
- historyIndexRef.current = newIndex
245
- setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
246
- return
227
+ savedDraftRef.current = valueRef.current
247
228
  }
248
- if (key.downArrow) {
249
- if (historyIndexRef.current === -1) return
250
- const newIndex = historyIndexRef.current - 1
251
- historyIndexRef.current = newIndex
252
- if (newIndex === -1) {
253
- // Back to the original draft
254
- setValue(savedDraftRef.current)
255
- savedDraftRef.current = ''
256
- } else {
257
- setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
258
- }
259
- return
260
- }
261
- }
262
- }
263
-
264
- if (vimEngine.current.mode !== 'normal') return
265
-
266
- // Handle search mode — collect query characters
267
- if (searchMode) {
268
- if (key.return) {
269
- const action = vimEngine.current.handleSearch(value, searchQuery)
270
- if (action.text !== undefined) setValue(action.text)
271
- // NOTE: action.cursor is not settable on ink-text-input — user repositions manually
272
- setSearchMode(false)
273
- setSearchQuery('')
229
+ const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
230
+ historyIndexRef.current = newIndex
231
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
274
232
  return
275
233
  }
276
- if (key.backspace || key.delete) {
277
- const next = handleSearchBackspace(searchQuery)
278
- setSearchQuery(next.query)
279
- if (next.exit) setSearchMode(false)
234
+ if (key.downArrow) {
235
+ if (historyIndexRef.current === -1) return
236
+ const newIndex = historyIndexRef.current - 1
237
+ historyIndexRef.current = newIndex
238
+ if (newIndex === -1) {
239
+ // Back to the original draft
240
+ setValue(savedDraftRef.current)
241
+ savedDraftRef.current = ''
242
+ } else {
243
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
244
+ }
280
245
  return
281
246
  }
282
- // Accumulate printable characters
283
- if (input && input.length === 1 && !key.escape && !key.return) {
284
- setSearchQuery((q) => q + input)
285
- }
286
- return
287
- }
288
-
289
- // Handle pending two-key sequences (dd, yy)
290
- if (vimPending.current !== null) {
291
- if (vimPending.current === 'd' && input === 'd') {
292
- const action = vimEngine.current.handleDD(value)
293
- setValue(action.text ?? value)
294
- } else if (vimPending.current === 'y' && input === 'y') {
295
- vimEngine.current.handleYY(value)
296
- }
297
- // Always clear pending — even when second key doesn't match
298
- vimPending.current = null
299
- return
300
- }
301
-
302
- // Handle p (paste) — pastes clipboard at cursor position
303
- if (input === 'p') {
304
- const action = vimEngine.current.handlePaste(value, value.length)
305
- if (action.text !== undefined) setValue(action.text)
306
- return
307
- }
308
-
309
- // Handle u (undo)
310
- if (input === 'u') {
311
- const action = vimEngine.current.handleUndo(value)
312
- if (action.text !== undefined) setValue(action.text)
313
- return
314
- }
315
-
316
- // Handle / (enter search mode)
317
- if (input === '/') {
318
- setSearchMode(true)
319
- setSearchQuery('')
320
- return
321
247
  }
322
-
323
- // Handle single-key motions (h, j, k, l, w, b, 0, $, d, y)
324
- const action = vimEngine.current.handleNormal(input, value, value.length)
325
- if (!action) return
326
-
327
- if (action.pending) {
328
- vimPending.current = action.pending
329
- return
330
- }
331
-
332
- if (action.text !== undefined) {
333
- setValue(action.text)
334
- }
335
-
336
- // NOTE: action.cursor is returned by motions (h/j/k/l/w/b/0/$) but
337
- // ink-text-input does not expose a programmatic cursor-position API.
338
- // The cursor hint is informational only; the user repositions manually.
339
248
  })
340
249
 
341
250
  // ── Command picker state ──
@@ -344,7 +253,7 @@ export function InputBar({
344
253
 
345
254
  // Auto-activate picker when user types "/"
346
255
  useEffect(() => {
347
- if (value.startsWith('/') && !prevValueRef.current.startsWith('/') && vimMode === 'insert') {
256
+ if (value.startsWith('/') && !prevValueRef.current.startsWith('/')) {
348
257
  setPickerActive(true)
349
258
  }
350
259
  // Dismiss picker when user clears the / prefix
@@ -352,7 +261,7 @@ export function InputBar({
352
261
  setPickerActive(false)
353
262
  }
354
263
  prevValueRef.current = value
355
- }, [value, vimMode])
264
+ }, [value])
356
265
 
357
266
  // Keep valueRef in sync with state (so useInput handlers read latest value)
358
267
  useEffect(() => {
@@ -365,9 +274,15 @@ export function InputBar({
365
274
  clearTimeout(onChangeTimerRef.current)
366
275
  onChangeTimerRef.current = null
367
276
  }
368
- // Use latest value from ref (may be ahead of state during throttle)
369
- const finalValue = val || valueRef.current
370
- if (!finalValue.trim() || isLoading) return
277
+ // Use the latest value from the ref — state lags behind during throttle, and
278
+ // the onSubmit `val` is the stale controlled prop in that window.
279
+ const finalValue = valueRef.current || val
280
+ if (!finalValue.trim()) return
281
+ // Submitting while a response streams interrupts it (Claude Code parity)
282
+ // instead of silently dropping the input.
283
+ if (isLoading) {
284
+ onCancel?.()
285
+ }
371
286
  // Save to message history for arrow-key navigation
372
287
  setSubmittedHistory((prev) => [...prev, finalValue])
373
288
  historyIndexRef.current = -1
@@ -379,7 +294,7 @@ export function InputBar({
379
294
  }
380
295
 
381
296
  // ── Picker mode: CommandPicker overlay ──
382
- if (pickerActive && vimMode === 'insert') {
297
+ if (pickerActive) {
383
298
  return (
384
299
  <Box flexDirection="column" marginTop={1}>
385
300
  <CommandPicker
@@ -403,15 +318,11 @@ export function InputBar({
403
318
  <Box flexDirection="column" marginTop={1}>
404
319
  <Box>
405
320
  <Box marginRight={1}>
406
- <Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
407
- {vimMode === 'normal' ? ':' : '>'}
408
- </Text>
321
+ <Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
409
322
  </Box>
410
323
  <TextInput
411
324
  value={value}
412
325
  onChange={(val) => {
413
- // Block text changes during search mode — keys go to search query
414
- if (searchMode) return
415
326
  // Reset history browsing when user starts typing
416
327
  if (historyIndexRef.current !== -1) {
417
328
  historyIndexRef.current = -1
@@ -420,11 +331,18 @@ export function InputBar({
420
331
  // Normalize newlines → spaces. ink-text-input is single-line; multi-line
421
332
  // paste would trap arrow-key navigation on the first line.
422
333
  const normalized = val.replace(/\n/g, ' ')
423
- // Throttle: first keystroke renders immediately, then batch at ~30fps.
424
- // During paste, the terminal may send hundreds of characters in rapid
425
- // succession — without throttling, each triggers a React render+Ink write
426
- // cycle that starves the event loop and freezes the UI.
334
+ // 批量输入(paste/IME 替换)节流防渲染风暴;普通单字符输入立即显示,
335
+ // 避免 33ms trailing 的「慢半拍」尾巴。
336
+ const bulk = isBulkInput(valueRef.current, normalized)
427
337
  valueRef.current = normalized
338
+ if (!bulk) {
339
+ if (onChangeTimerRef.current) {
340
+ clearTimeout(onChangeTimerRef.current)
341
+ onChangeTimerRef.current = null
342
+ }
343
+ setValue(normalized)
344
+ return
345
+ }
428
346
  if (onChangeTimerRef.current) return // timer pending, latest value in ref
429
347
  setValue(normalized)
430
348
  onChangeTimerRef.current = setTimeout(() => {
@@ -434,20 +352,12 @@ export function InputBar({
434
352
  }}
435
353
  onSubmit={handleSubmit}
436
354
  placeholder={
437
- searchMode
438
- ? `/${searchQuery}`
439
- : vimMode === 'normal'
440
- ? t('ui.input.vim_help')
441
- : isLoading
442
- ? `${verb}...`
443
- : completionVerb
444
- ? completionVerb
445
- : t('ui.input.placeholder')
355
+ isLoading ? `${verb}...` : completionVerb ? completionVerb : t('ui.input.placeholder')
446
356
  }
447
357
  />
448
358
  </Box>
449
- {/* Slash command hints — shown when typing / in INSERT mode (only when picker is NOT active) */}
450
- {slashHints.length > 0 && vimMode === 'insert' && !pickerActive && (
359
+ {/* Slash command hints — shown when typing / (only when picker is NOT active) */}
360
+ {slashHints.length > 0 && !pickerActive && (
451
361
  <Box marginTop={1} flexDirection="column" gap={1}>
452
362
  <Text dimColor>{t('ui.slash_hints.label')} </Text>
453
363
  {slashHints.map((cmd, _i) => (
@@ -468,7 +378,7 @@ export function InputBar({
468
378
  </Box>
469
379
  )}
470
380
  {/* @mention hints — active sessions (cross-session messaging) */}
471
- {mentionHints.length > 0 && vimMode === 'insert' && !pickerActive && (
381
+ {mentionHints.length > 0 && !pickerActive && (
472
382
  <Box marginTop={1} flexDirection="column" gap={1}>
473
383
  <Text dimColor>{t('ui.mention_hints.label')} </Text>
474
384
  {mentionHints.map((name) => (
@@ -1,113 +0,0 @@
1
- export type VimMode = 'insert' | 'normal'
2
-
3
- export class VimMotionEngine {
4
- mode: VimMode = 'insert'
5
- private clipboard = ''
6
- private undoStack: string[] = []
7
-
8
- /** Handle a keypress in normal mode. Returns new cursor position delta and any text mutation. */
9
- handleNormal(key: string, text: string, cursor: number): VimAction | null {
10
- switch (key) {
11
- case 'h':
12
- return { cursor: cursor - 1 }
13
- case 'j':
14
- return { cursor: text.length } // end of line (like $)
15
- case 'k':
16
- return { cursor: 0 } // start of line (like 0)
17
- case 'l':
18
- return { cursor: cursor + 1 }
19
- case '0':
20
- return { cursor: 0 }
21
- case '$':
22
- return { cursor: text.length }
23
- case 'w':
24
- return { cursor: this.nextWord(text, cursor) }
25
- case 'b':
26
- return { cursor: this.prevWord(text, cursor) }
27
- case 'd':
28
- return { pending: 'd' }
29
- case 'y':
30
- return { pending: 'y' }
31
- default:
32
- return null
33
- }
34
- }
35
-
36
- /** Handle dd — delete entire line */
37
- handleDD(text: string): VimAction {
38
- this.clipboard = text
39
- this.pushUndo(text)
40
- return { text: '', cursor: 0 }
41
- }
42
-
43
- /** Handle yy — yank entire line */
44
- handleYY(text: string): VimAction {
45
- this.clipboard = text
46
- return { cursor: 0 } // no change
47
- }
48
-
49
- /** Handle p — paste after cursor */
50
- handlePaste(text: string, cursor: number): VimAction {
51
- const newText = text.slice(0, cursor) + this.clipboard + text.slice(cursor)
52
- this.pushUndo(text)
53
- return { text: newText, cursor: cursor + this.clipboard.length }
54
- }
55
-
56
- /** Handle u — undo */
57
- handleUndo(text: string): VimAction {
58
- const prev = this.undoStack.pop()
59
- if (prev !== undefined) {
60
- return { text: prev, cursor: prev.length }
61
- }
62
- return { cursor: text.length }
63
- }
64
-
65
- /** Handle /search */
66
- handleSearch(text: string, query: string): VimAction {
67
- const idx = text.indexOf(query)
68
- return idx >= 0 ? { cursor: idx } : { cursor: 0 }
69
- }
70
-
71
- private nextWord(text: string, cursor: number): number {
72
- // Skip word characters, then skip whitespace
73
- let i = cursor
74
- while (i < text.length && text[i] !== ' ') i++
75
- while (i < text.length && text[i] === ' ') i++
76
- return i
77
- }
78
-
79
- private prevWord(text: string, cursor: number): number {
80
- let i = cursor - 1
81
- while (i > 0 && text[i] === ' ') i--
82
- while (i > 0 && text[i] !== ' ') i--
83
- return i > 0 ? i + 1 : 0
84
- }
85
-
86
- private pushUndo(text: string): void {
87
- this.undoStack.push(text)
88
- if (this.undoStack.length > 50) this.undoStack.shift()
89
- }
90
- }
91
-
92
- export interface VimAction {
93
- text?: string
94
- cursor?: number
95
- pending?: string
96
- }
97
-
98
- export interface SearchBackspaceResult {
99
- query: string
100
- /** true → the `/` prefix should be removed by exiting search mode */
101
- exit: boolean
102
- }
103
-
104
- /**
105
- * Backspace while in `/` search mode.
106
- * Trims the query; when the query is already empty, signals that search mode
107
- * should exit so the `/` placeholder disappears (the user can "delete" the
108
- * slash instead of being trapped).
109
- */
110
- export function handleSearchBackspace(query: string): SearchBackspaceResult {
111
- if (query.length === 0) return { query: '', exit: true }
112
- return { query: query.slice(0, -1), exit: false }
113
- }