@miphamai/cli 0.55.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.
@@ -0,0 +1,53 @@
1
+ import { readFileSync, existsSync } from 'node:fs'
2
+ import { join, resolve, dirname } from 'node:path'
3
+
4
+ /** Graft's hook-maintained stats cache (`graft/.cache/stats.json`). */
5
+ export interface GraftStats {
6
+ nodeCount: number
7
+ edgeCount: number
8
+ staleCount: number
9
+ dirty: boolean
10
+ syncing: boolean
11
+ lastFile: string | null
12
+ }
13
+
14
+ /**
15
+ * Read graft's stats cache for the given project. Returns null when graft is
16
+ * not built/installed in `cwd` (no `graft/.cache/stats.json`, or an empty
17
+ * graph) — callers render nothing in that case.
18
+ */
19
+ export function readGraftStats(cwd: string): GraftStats | null {
20
+ const statsPath = join(cwd, 'graft', '.cache', 'stats.json')
21
+ if (!existsSync(statsPath)) return null
22
+ try {
23
+ const raw = JSON.parse(readFileSync(statsPath, 'utf-8')) as Record<string, unknown>
24
+ if (typeof raw.nodeCount !== 'number' || raw.nodeCount === 0) return null
25
+ return {
26
+ nodeCount: raw.nodeCount,
27
+ edgeCount: typeof raw.edgeCount === 'number' ? raw.edgeCount : 0,
28
+ staleCount: typeof raw.staleCount === 'number' ? raw.staleCount : 0,
29
+ dirty: raw.dirty === true,
30
+ syncing: raw.syncing === true,
31
+ lastFile: typeof raw.lastFile === 'string' ? raw.lastFile : null,
32
+ }
33
+ } catch {
34
+ return null
35
+ }
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.55.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
@@ -82,6 +82,11 @@ export class SkillsLoader implements Skills {
82
82
  }
83
83
  }
84
84
 
85
+ /** Load a single skill file by path (used by Claude plugins: `skills/<name>/SKILL.md`). */
86
+ loadSkillFile(path: string, type: 'standard' | 'mipham' = 'standard'): void {
87
+ this.tryLoad(path, type)
88
+ }
89
+
85
90
  get(name: string): SkillDefinition | undefined {
86
91
  return this.skills.get(name)
87
92
  }
@@ -195,40 +200,13 @@ export class SkillsLoader implements Skills {
195
200
  }
196
201
 
197
202
  /**
198
- * Recall the skills most relevant to a context (e.g. the session's system
199
- * prompt / project context), mirroring MemoryManager.recall's keyword
200
- * scoring. Name keywords are a strong signal (+3); description keywords are
201
- * weak (+1). Returns up to `limit` skills sorted by relevance.
202
- */
203
- recall(context: string, limit: number = 5): SkillDefinition[] {
204
- const ctxWords = new Set(context.toLowerCase().split(/\s+/))
205
- const scored: Array<{ skill: SkillDefinition; score: number }> = []
206
-
207
- for (const skill of this.list()) {
208
- if (skill.disableModelInvocation) continue
209
- let score = 0
210
- for (const word of skill.name.toLowerCase().split(/[-_\s]+/)) {
211
- if (word.length > 2 && ctxWords.has(word)) score += 3
212
- }
213
- for (const word of skill.description.toLowerCase().split(/\s+/)) {
214
- if (word.length > 3 && ctxWords.has(word)) score += 1
215
- }
216
- if (score > 0) scored.push({ skill, score })
217
- }
218
-
219
- scored.sort((a, b) => b.score - a.score)
220
- return scored.slice(0, limit).map((s) => s.skill)
221
- }
222
-
223
- /**
224
- * Build the system-reminder block for AI auto-triggering.
225
- * With a `context`, only the most relevant skills are injected (selective,
226
- * to save tokens); without one, all skills are listed.
203
+ * Build the system-reminder block listing every skill the AI may invoke via
204
+ * the Skill tool. A single full listing keeps the whole catalog discoverable:
205
+ * at session start there is no query to match against, so a keyword "recall"
206
+ * would silently hide most skills. Capped at `maxTokens` to stay bounded.
227
207
  */
228
- buildSystemReminder(context?: string, maxTokens: number = 5000): string {
229
- const selected = context
230
- ? this.recall(context)
231
- : this.list().filter((s) => !s.disableModelInvocation)
208
+ buildSystemReminder(maxTokens: number = 5000): string {
209
+ const selected = this.list().filter((s) => !s.disableModelInvocation)
232
210
 
233
211
  if (selected.length === 0) return ''
234
212
 
@@ -6,7 +6,7 @@ export interface Skills {
6
6
  get(name: string): SkillDefinition | undefined
7
7
  list(): SkillDefinition[]
8
8
  has(name: string): boolean
9
- buildSystemReminder(context?: string, maxTokens?: number): string
9
+ buildSystemReminder(maxTokens?: number): string
10
10
  }
11
11
 
12
12
  /** 缝键:ctx.skills。 */
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync } from
2
2
  import { join } from 'node:path'
3
3
  import { homedir } from 'node:os'
4
4
  import type { ToolDefinition } from '../../shared/index.ts'
5
+ import { MemoryManager } from '../../core/memory/memory-manager'
5
6
 
6
7
  const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
7
8
 
@@ -24,7 +25,7 @@ ${content}
24
25
 
25
26
  export const memoryTool: ToolDefinition = {
26
27
  name: 'Memory',
27
- description: 'Read and write persistent memory files in ~/.mipham/memory/.',
28
+ description: 'Read, write, list, and search persistent memory files in ~/.mipham/memory/.',
28
29
  category: 'agent',
29
30
  permission: 'auto',
30
31
  parameters: {
@@ -32,11 +33,12 @@ export const memoryTool: ToolDefinition = {
32
33
  properties: {
33
34
  action: {
34
35
  type: 'string',
35
- enum: ['read', 'write', 'list'],
36
+ enum: ['read', 'write', 'list', 'search'],
36
37
  description: 'Action to perform',
37
38
  },
38
39
  name: { type: 'string', description: 'Memory file name (slug)' },
39
40
  content: { type: 'string', description: 'Content to write (for write action)' },
41
+ query: { type: 'string', description: 'Search query (for search action)' },
40
42
  },
41
43
  required: ['action'],
42
44
  },
@@ -49,6 +51,22 @@ export const memoryTool: ToolDefinition = {
49
51
  return { success: true, content: files.join('\n') || '(no memories)' }
50
52
  }
51
53
 
54
+ // Semantic-less recall (keyword + wikilink + time-decay scoring) — lets the
55
+ // AI retrieve relevant memories mid-conversation, not only at session start.
56
+ if (action === 'search') {
57
+ const query = (params.query as string)?.trim()
58
+ if (!query) return { success: false, content: '', error: 'query is required' }
59
+ const mm = new MemoryManager(MEMORY_DIR)
60
+ mm.loadAll()
61
+ const results = mm.recall(query, 10)
62
+ if (results.length === 0) return { success: true, content: '(no matching memories)' }
63
+ const lines = results.map((entry) => {
64
+ const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
65
+ return `- ${entry.name}: ${entry.description || snippet}\n ${snippet}`
66
+ })
67
+ return { success: true, content: lines.join('\n') }
68
+ }
69
+
52
70
  const name = params.name as string
53
71
  if (!name) return { success: false, content: '', error: 'name is required' }
54
72
  const filePath = join(MEMORY_DIR, `${name}.md`)
@@ -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
@@ -22,6 +22,18 @@ import { ChatPanel } from './chat'
22
22
  import { InputBar } from './input'
23
23
  import { ModelPicker } from './picker'
24
24
  import { AgentFooter, type AgentEntry } from './agent-footer'
25
+ import { GraftStatusLine } from './graft-status'
26
+
27
+ /** Current context-window usage % — undefined when unknown (remote stub). */
28
+ function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
29
+ const ctx = engine.getContext() as {
30
+ getEstimatedTokens(): number
31
+ getMaxTokens?(): number
32
+ }
33
+ const max = ctx.getMaxTokens?.()
34
+ if (!max || max <= 0) return undefined
35
+ return Math.round((ctx.getEstimatedTokens() / max) * 100)
36
+ }
25
37
  import { AgentViewDashboard } from '../agent-view/dashboard'
26
38
  import type { AgentViewManager } from '../agent-view/agent-view-manager'
27
39
  import { WorkflowProgress } from './workflow-progress.js'
@@ -779,7 +791,6 @@ export function App({
779
791
 
780
792
  useInput((_input, key) => {
781
793
  // Escape: close apiKeyPrompt → close picker → abort loading (does NOT exit app)
782
- // Vim mode toggle is handled by InputBar
783
794
  if (key.escape) {
784
795
  if (apiKeyPrompt) {
785
796
  setApiKeyPrompt(null)
@@ -794,8 +805,7 @@ export function App({
794
805
  abortRef.current.abort()
795
806
  return
796
807
  }
797
- // When input is empty and not loading, let InputBar handle Escape
798
- // (it toggles vim mode or clears pending sequences)
808
+ // Otherwise let InputBar handle Escape (clear draft)
799
809
  return
800
810
  }
801
811
  // All other global hotkeys (Shift+Tab, Ctrl+P, Ctrl+F, Ctrl+O)
@@ -965,6 +975,9 @@ export function App({
965
975
  agentProgress={agentProgress}
966
976
  />
967
977
 
978
+ {/* graft status line — mirrors graft's own "◤ graft · …" bar */}
979
+ <GraftStatusLine cwd={process.cwd()} ctxPct={contextUsagePct(engine)} />
980
+
968
981
  {/* Status line — Claude Code style */}
969
982
  <Box marginTop={1} flexDirection="column">
970
983
  <Box flexDirection="row">
@@ -0,0 +1,63 @@
1
+ import React, { useState } from 'react'
2
+ import { Box, Text } from 'ink'
3
+ import { basename } from 'node:path'
4
+ import { findGraftStats, type GraftStats } from '../shared/graft-stats'
5
+ import { getGraftSavings } from '../shared/graft-savings'
6
+
7
+ /** Freshness segment mirroring graft's own statusline (see @nanonets/graft format.js). */
8
+ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
9
+ if (stats.syncing) return { label: 'syncing…', color: 'yellow' }
10
+ if (stats.dirty && stats.staleCount > 0)
11
+ return { label: `⚠ ${stats.staleCount} stale`, color: 'yellow' }
12
+ if (stats.dirty) return { label: '⚠ stale', color: 'yellow' }
13
+ return { label: '✓ synced', color: 'blue' }
14
+ }
15
+
16
+ /**
17
+ * Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
18
+ * ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
19
+ * ▸ ctx X% · last: file
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.
24
+ */
25
+ export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
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
30
+ const saved = getGraftSavings()
31
+ const lastFile = stats?.lastFile ? basename(stats.lastFile) : null
32
+
33
+ const bottom: string[] = []
34
+ if (typeof ctxPct === 'number') bottom.push(`ctx ${ctxPct}%`)
35
+ if (lastFile) bottom.push(`last: ${lastFile}`)
36
+
37
+ return (
38
+ <Box flexDirection="column">
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
+ )}
56
+ {bottom.length > 0 && (
57
+ <Box>
58
+ <Text dimColor>▸ {bottom.join(' · ')}</Text>
59
+ </Box>
60
+ )}
61
+ </Box>
62
+ )
63
+ }
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) => (