@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/bin/mipham.ts +35 -0
- package/package.json +1 -1
- package/skills/standard/mipham-code-setup.SKILL.md +5 -9
- package/src/agent/sub-agent.ts +0 -3
- package/src/agent/types.ts +1 -2
- package/src/commands/project.ts +8 -7
- package/src/config/defaults.ts +1 -1
- package/src/core/crsi-producer.ts +41 -0
- package/src/core/engine.ts +19 -1
- package/src/core/instructions.ts +35 -3
- package/src/core/permission-config.ts +0 -2
- package/src/core/permission.ts +5 -60
- package/src/core/project-scaffold.ts +158 -0
- package/src/daemon/server.ts +0 -2
- package/src/i18n-core/locales/en-US.json +3 -5
- package/src/i18n-core/locales/zh-CN.json +3 -5
- package/src/index.tsx +8 -0
- package/src/security/gate.ts +3 -2
- package/src/shared/graft-stats.ts +18 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +5 -6
- package/src/skills/bundled-skills.ts +1 -1
- package/src/tools/exec/bash.ts +4 -4
- package/src/ui/app.tsx +53 -34
- package/src/ui/graft-status.tsx +27 -23
- package/src/ui/input.tsx +73 -163
- package/src/ui/vim-motions.ts +0 -113
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
|
|
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 →
|
|
165
|
+
// ── Escape: cancel loading → clear draft ──
|
|
170
166
|
if (key.escape) {
|
|
171
|
-
//
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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 (
|
|
225
|
-
if (
|
|
226
|
-
if (
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
244
|
-
historyIndexRef.current = newIndex
|
|
245
|
-
setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
|
|
246
|
-
return
|
|
227
|
+
savedDraftRef.current = valueRef.current
|
|
247
228
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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('/')
|
|
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
|
|
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
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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={
|
|
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
|
-
//
|
|
424
|
-
//
|
|
425
|
-
|
|
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
|
-
|
|
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 /
|
|
450
|
-
{slashHints.length > 0 &&
|
|
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 &&
|
|
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) => (
|
package/src/ui/vim-motions.ts
DELETED
|
@@ -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
|
-
}
|