@miphamai/cli 0.56.0 → 0.57.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.56.0",
3
+ "version": "0.57.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -115,17 +115,20 @@ Mode: ${ctx.config.permission}
115
115
  Messages: ${msgs.length} in context
116
116
  Tools: ${ctx.engine.getTools().size} available
117
117
 
118
- Permission levels:
119
- auto — run tools automatically without asking
120
- ask — prompt before each tool execution (default)
121
- bypass — skip all permission checks (use with caution)
118
+ Switch mode with Shift+Tab. Modes (least → most permissive):
119
+ default — per-tool defaults (Bash/Write/Edit ask first)
120
+ acceptEdits — reads + edits free; Bash auto-runs read/check commands
121
+ plan — reads only (Read/Grep/Glob); nothing writes or runs
122
+ auto — run everything without asking (hooks are the safety gate)
123
+ dontAsk — only allowlisted tools free; everything else asks
124
+ bypassPermissions — skip ALL permission checks (⚠️ use with caution)
122
125
 
123
- Change with /config permission <level>.
126
+ To let Bash run without asking: press Shift+Tab until the status line shows
127
+ "acceptEdits" (or "auto"), then send your message again.
124
128
 
125
129
  Current directory permissions:
126
130
  CWD: ${process.cwd()}
127
131
 
128
- To add a directory: use /add-dir (coming soon).
129
132
  Tool execution is sandboxed to the project directory by default.`,
130
133
  }
131
134
  }
@@ -764,7 +764,9 @@ export class QueryEngine {
764
764
  }
765
765
 
766
766
  private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
767
- const MAX_TURNS = 20
767
+ // Tool-calling round cap. 20 was too low for real multi-step tasks — the model
768
+ // hit "max turns" and dropped pending tools mid-task. 100 stays bounded.
769
+ const MAX_TURNS = 100
768
770
  const toolDefs = this.getToolDefinitions()
769
771
 
770
772
  for (let turn = 0; turn < MAX_TURNS; turn++) {
@@ -804,8 +804,7 @@
804
804
  "assistant": { "role_label": "Mipham Code" },
805
805
  "user": { "role_label": "User" },
806
806
  "input": {
807
- "placeholder": "Type a message (Esc to cancel)...",
808
- "vim_help": "[NORMAL] h/j/k/l w/b 0/$ dd yy p u / (Esc to insert)"
807
+ "placeholder": "Type a message (Esc to clear)..."
809
808
  },
810
809
  "slash_hints": {
811
810
  "label": "Commands:",
@@ -942,7 +941,7 @@
942
941
  "mcp": { "name": "MCP", "description": "Manage MCP server connections" }
943
942
  },
944
943
  "errors": {
945
- "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed.",
944
+ "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
946
945
  "tool_blocked": "Tool \"{name}\" blocked by hook",
947
946
  "user_input_blocked": "User input blocked by hook.",
948
947
  "dlp_blocked": "Request blocked by DLP policy.",
@@ -804,8 +804,7 @@
804
804
  "assistant": { "role_label": "Mipham Code" },
805
805
  "user": { "role_label": "用户" },
806
806
  "input": {
807
- "placeholder": "输入消息(Esc 取消)...",
808
- "vim_help": "[NORMAL] h/j/k/l w/b 0/$ dd yy p u /(Esc 插入)"
807
+ "placeholder": "输入消息(Esc 清空)..."
809
808
  },
810
809
  "slash_hints": {
811
810
  "label": "命令:",
@@ -942,7 +941,7 @@
942
941
  "mcp": { "name": "MCP", "description": "管理 MCP 服务器连接" }
943
942
  },
944
943
  "errors": {
945
- "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。",
944
+ "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
946
945
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
947
946
  "user_input_blocked": "用户输入被钩子拦截。",
948
947
  "dlp_blocked": "请求被 DLP 策略阻止。",
@@ -28,8 +28,9 @@ const PROMPT_INJECTION_PATTERNS: Array<{ regex: RegExp; label: string }> = [
28
28
  ]
29
29
 
30
30
  const DANGEROUS_BASH_PATTERNS: Array<{ regex: RegExp; label: string }> = [
31
- { regex: /\$\(.+\)/, label: 'command-substitution' },
32
- { regex: /`[^`]+`/, label: 'backtick-substitution' },
31
+ // NOTE: command substitution ($( … ) / `…`) is legitimate Bash syntax and is NOT
32
+ // blocked here — its dangerous content (a pipe to sh/bash, a chained rm/cat/sh,
33
+ // …) is still caught by the pipe/chain patterns below.
33
34
  { regex: /;\s*(rm|cat|sh|bash)\b/, label: 'command-chain-injection' },
34
35
  { regex: /curl.+\|\s*(sh|bash)\b/, label: 'curl-pipe-shell' },
35
36
  { regex: /\|\s*(sh|bash)\b/, label: 'pipe-to-shell' },
@@ -1,5 +1,5 @@
1
1
  import { readFileSync, existsSync } from 'node:fs'
2
- import { join } from 'node:path'
2
+ import { join, resolve, dirname } from 'node:path'
3
3
 
4
4
  /** Graft's hook-maintained stats cache (`graft/.cache/stats.json`). */
5
5
  export interface GraftStats {
@@ -34,3 +34,20 @@ export function readGraftStats(cwd: string): GraftStats | null {
34
34
  return null
35
35
  }
36
36
  }
37
+
38
+ /**
39
+ * Find the nearest graft graph by walking up from `cwd` to the filesystem root.
40
+ * Graft builds its cache at the repo root (`graft/.cache/stats.json`), so this
41
+ * also resolves when `cwd` is a subdirectory of a graft-indexed repo — not only
42
+ * the repo root itself.
43
+ */
44
+ export function findGraftStats(cwd: string): GraftStats | null {
45
+ let dir = resolve(cwd)
46
+ for (;;) {
47
+ const stats = readGraftStats(dir)
48
+ if (stats) return stats
49
+ const parent = dirname(dir)
50
+ if (parent === dir) return null
51
+ dir = parent
52
+ }
53
+ }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.56.0' as const
12
+ export const PACKAGE_VERSION = '0.57.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -29,10 +29,10 @@ const BLOCKED_PATTERNS = [
29
29
  />\s*\/dev\/sd[a-z]/,
30
30
  // SSH private key theft
31
31
  /\bcat\s+.*\/\.ssh\/id_/,
32
- // Shell command substitution (commonly used for obfuscation/evasion)
33
- /\$\(/,
34
- // Backtick command substitution
35
- /`[^`]+`/,
32
+ // NOTE: command substitution ($( … ) / `…`) is a legitimate Bash feature, so it is
33
+ // NOT blocked wholesale. The dangerous content inside a substitution (curl|sh,
34
+ // base64 -d, python -c, eval, bash -c, source, …) is still caught by the specific
35
+ // patterns below, which test the whole command string including inside $().
36
36
  // Interpreter code execution (bypass vector) — covers python, python2, python3
37
37
  /\bpython[23]?\s+-c\b/,
38
38
  /\bpython[23]?\s+-m\b/,
package/src/ui/app.tsx CHANGED
@@ -791,7 +791,6 @@ export function App({
791
791
 
792
792
  useInput((_input, key) => {
793
793
  // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
794
- // Vim mode toggle is handled by InputBar
795
794
  if (key.escape) {
796
795
  if (apiKeyPrompt) {
797
796
  setApiKeyPrompt(null)
@@ -806,8 +805,7 @@ export function App({
806
805
  abortRef.current.abort()
807
806
  return
808
807
  }
809
- // When input is empty and not loading, let InputBar handle Escape
810
- // (it toggles vim mode or clears pending sequences)
808
+ // Otherwise let InputBar handle Escape (clear draft)
811
809
  return
812
810
  }
813
811
  // All other global hotkeys (Shift+Tab, Ctrl+P, Ctrl+F, Ctrl+O)
@@ -1,7 +1,7 @@
1
1
  import React, { useState } from 'react'
2
2
  import { Box, Text } from 'ink'
3
3
  import { basename } from 'node:path'
4
- import { readGraftStats, type GraftStats } from '../shared/graft-stats'
4
+ import { findGraftStats, type GraftStats } from '../shared/graft-stats'
5
5
  import { getGraftSavings } from '../shared/graft-savings'
6
6
 
7
7
  /** Freshness segment mirroring graft's own statusline (see @nanonets/graft format.js). */
@@ -17,16 +17,18 @@ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
17
17
  * Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
18
18
  * ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
19
19
  * ▸ ctx X% · last: file
20
- * Reads `graft/.cache/stats.json` once at mount; the session's tok-saved total
21
- * is read live each render (the app re-renders on the agent tick). Returns
22
- * null (renders nothing) when graft isn't built here.
20
+ * Reads `graft/.cache/stats.json` (walking up to the repo root) once at mount;
21
+ * the session's tok-saved total is read live each render (the app re-renders on
22
+ * the agent tick). Renders nothing only when there's no graft graph AND no known
23
+ * ctx% — the ctx% line shows even when graft isn't built in this directory.
23
24
  */
24
25
  export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
25
- const [stats] = useState(() => readGraftStats(cwd))
26
- if (!stats) return null
27
- const fresh = freshnessSegment(stats)
26
+ const [stats] = useState(() => findGraftStats(cwd))
27
+ const showCtx = typeof ctxPct === 'number'
28
+ if (!stats && !showCtx) return null
29
+ const fresh = stats ? freshnessSegment(stats) : null
28
30
  const saved = getGraftSavings()
29
- const lastFile = stats.lastFile ? basename(stats.lastFile) : null
31
+ const lastFile = stats?.lastFile ? basename(stats.lastFile) : null
30
32
 
31
33
  const bottom: string[] = []
32
34
  if (typeof ctxPct === 'number') bottom.push(`ctx ${ctxPct}%`)
@@ -34,21 +36,23 @@ export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number
34
36
 
35
37
  return (
36
38
  <Box flexDirection="column">
37
- <Box>
38
- <Text dimColor>◤ </Text>
39
- <Text color="blue">graft</Text>
40
- <Text dimColor>
41
- {' '}
42
- · {stats.nodeCount} nodes / {stats.edgeCount} edges ·{' '}
43
- </Text>
44
- <Text color={fresh.color}>{fresh.label}</Text>
45
- {saved > 0 && (
46
- <>
47
- <Text dimColor> · </Text>
48
- <Text color="blue">~{saved.toLocaleString()} tok saved</Text>
49
- </>
50
- )}
51
- </Box>
39
+ {stats && fresh && (
40
+ <Box>
41
+ <Text dimColor>◤ </Text>
42
+ <Text color="blue">graft</Text>
43
+ <Text dimColor>
44
+ {' '}
45
+ · {stats.nodeCount} nodes / {stats.edgeCount} edges ·{' '}
46
+ </Text>
47
+ <Text color={fresh.color}>{fresh.label}</Text>
48
+ {saved > 0 && (
49
+ <>
50
+ <Text dimColor> · </Text>
51
+ <Text color="blue">~{saved.toLocaleString()} tok saved</Text>
52
+ </>
53
+ )}
54
+ </Box>
55
+ )}
52
56
  {bottom.length > 0 && (
53
57
  <Box>
54
58
  <Text dimColor>▸ {bottom.join(' · ')}</Text>
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
 
@@ -145,15 +144,6 @@ export function InputBar({
145
144
  prevLoading.current = isLoading
146
145
  }, [isLoading])
147
146
 
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
147
  // Track pre-shortcut value so we can revert ink-text-input's Ctrl-key insertions.
158
148
  // ink-text-input inserts 'p'/'f'/'o' for Ctrl+P/F/O (Ink normalizes control chars
159
149
  // to their letter names before passing to useInput).
@@ -163,32 +153,21 @@ export function InputBar({
163
153
  valueBeforeShortcut.current = value
164
154
  }, [value])
165
155
 
166
- // ── Vim motions: intercept keys in normal mode ──
167
-
168
156
  useInput((input, key) => {
169
- // ── Escape: cancel loading → toggle vim mode ──
157
+ // ── Escape: cancel loading → clear draft ──
170
158
  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)
159
+ // Escape while loading → abort
178
160
  if (isLoading) {
179
161
  onCancel?.()
180
162
  return
181
163
  }
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'
164
+ // Idle → clear the draft (the intuitive "cancel")
165
+ setValue('')
166
+ valueRef.current = ''
188
167
  return
189
168
  }
190
169
 
191
- // ── Global hotkeys (work in both insert and normal modes) ──
170
+ // ── Global hotkeys ──
192
171
  // Shift+Tab → cycle permission mode
193
172
  if (key.shift && key.tab) {
194
173
  onCyclePermission?.()
@@ -221,121 +200,43 @@ export function InputBar({
221
200
  return
222
201
  }
223
202
 
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
242
- }
243
- const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
244
- historyIndexRef.current = newIndex
245
- setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
246
- return
247
- }
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]!)
203
+ // ── Arrow-key history navigation (Claude Code parity) ──
204
+ if (key.upArrow || key.downArrow) {
205
+ // Ignore if picker is active (command picker handles its own arrows)
206
+ if (value.startsWith('/')) return
207
+
208
+ if (key.upArrow) {
209
+ if (submittedHistory.length === 0) return
210
+ // Save current draft the first time we enter history browsing
211
+ if (historyIndexRef.current === -1) {
212
+ // Flush any pending throttle so its deferred setValue doesn't overwrite
213
+ // history navigation (paste → immediate ↑ race). Save the latest value
214
+ // from the ref — state `value` is stale inside the throttle window.
215
+ if (onChangeTimerRef.current) {
216
+ clearTimeout(onChangeTimerRef.current)
217
+ onChangeTimerRef.current = null
258
218
  }
259
- return
219
+ savedDraftRef.current = valueRef.current
260
220
  }
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('')
221
+ const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
222
+ historyIndexRef.current = newIndex
223
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
274
224
  return
275
225
  }
276
- if (key.backspace || key.delete) {
277
- const next = handleSearchBackspace(searchQuery)
278
- setSearchQuery(next.query)
279
- if (next.exit) setSearchMode(false)
226
+ if (key.downArrow) {
227
+ if (historyIndexRef.current === -1) return
228
+ const newIndex = historyIndexRef.current - 1
229
+ historyIndexRef.current = newIndex
230
+ if (newIndex === -1) {
231
+ // Back to the original draft
232
+ setValue(savedDraftRef.current)
233
+ savedDraftRef.current = ''
234
+ } else {
235
+ setValue(submittedHistory[submittedHistory.length - 1 - newIndex]!)
236
+ }
280
237
  return
281
238
  }
282
- // Accumulate printable characters
283
- if (input && input.length === 1 && !key.escape && !key.return) {
284
- setSearchQuery((q) => q + input)
285
- }
286
- return
287
239
  }
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
- }
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
240
  })
340
241
 
341
242
  // ── Command picker state ──
@@ -344,7 +245,7 @@ export function InputBar({
344
245
 
345
246
  // Auto-activate picker when user types "/"
346
247
  useEffect(() => {
347
- if (value.startsWith('/') && !prevValueRef.current.startsWith('/') && vimMode === 'insert') {
248
+ if (value.startsWith('/') && !prevValueRef.current.startsWith('/')) {
348
249
  setPickerActive(true)
349
250
  }
350
251
  // Dismiss picker when user clears the / prefix
@@ -352,7 +253,7 @@ export function InputBar({
352
253
  setPickerActive(false)
353
254
  }
354
255
  prevValueRef.current = value
355
- }, [value, vimMode])
256
+ }, [value])
356
257
 
357
258
  // Keep valueRef in sync with state (so useInput handlers read latest value)
358
259
  useEffect(() => {
@@ -379,7 +280,7 @@ export function InputBar({
379
280
  }
380
281
 
381
282
  // ── Picker mode: CommandPicker overlay ──
382
- if (pickerActive && vimMode === 'insert') {
283
+ if (pickerActive) {
383
284
  return (
384
285
  <Box flexDirection="column" marginTop={1}>
385
286
  <CommandPicker
@@ -403,15 +304,11 @@ export function InputBar({
403
304
  <Box flexDirection="column" marginTop={1}>
404
305
  <Box>
405
306
  <Box marginRight={1}>
406
- <Text color={vimMode === 'normal' ? 'magenta' : isLoading ? 'yellow' : 'cyan'}>
407
- {vimMode === 'normal' ? ':' : '>'}
408
- </Text>
307
+ <Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
409
308
  </Box>
410
309
  <TextInput
411
310
  value={value}
412
311
  onChange={(val) => {
413
- // Block text changes during search mode — keys go to search query
414
- if (searchMode) return
415
312
  // Reset history browsing when user starts typing
416
313
  if (historyIndexRef.current !== -1) {
417
314
  historyIndexRef.current = -1
@@ -434,20 +331,12 @@ export function InputBar({
434
331
  }}
435
332
  onSubmit={handleSubmit}
436
333
  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')
334
+ isLoading ? `${verb}...` : completionVerb ? completionVerb : t('ui.input.placeholder')
446
335
  }
447
336
  />
448
337
  </Box>
449
- {/* Slash command hints — shown when typing / in INSERT mode (only when picker is NOT active) */}
450
- {slashHints.length > 0 && vimMode === 'insert' && !pickerActive && (
338
+ {/* Slash command hints — shown when typing / (only when picker is NOT active) */}
339
+ {slashHints.length > 0 && !pickerActive && (
451
340
  <Box marginTop={1} flexDirection="column" gap={1}>
452
341
  <Text dimColor>{t('ui.slash_hints.label')} </Text>
453
342
  {slashHints.map((cmd, _i) => (
@@ -468,7 +357,7 @@ export function InputBar({
468
357
  </Box>
469
358
  )}
470
359
  {/* @mention hints — active sessions (cross-session messaging) */}
471
- {mentionHints.length > 0 && vimMode === 'insert' && !pickerActive && (
360
+ {mentionHints.length > 0 && !pickerActive && (
472
361
  <Box marginTop={1} flexDirection="column" gap={1}>
473
362
  <Text dimColor>{t('ui.mention_hints.label')} </Text>
474
363
  {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
- }