@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 +1 -1
- package/src/commands/project.ts +9 -6
- package/src/core/engine.ts +3 -1
- package/src/i18n-core/locales/en-US.json +2 -3
- package/src/i18n-core/locales/zh-CN.json +2 -3
- 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/tools/exec/bash.ts +4 -4
- package/src/ui/app.tsx +1 -3
- package/src/ui/graft-status.tsx +27 -23
- package/src/ui/input.tsx +45 -156
- package/src/ui/vim-motions.ts +0 -113
package/package.json
CHANGED
package/src/commands/project.ts
CHANGED
|
@@ -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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/core/engine.ts
CHANGED
|
@@ -764,7 +764,9 @@ export class QueryEngine {
|
|
|
764
764
|
}
|
|
765
765
|
|
|
766
766
|
private async *continueWithTools(signal?: AbortSignal): AsyncGenerator<StreamChunk> {
|
|
767
|
-
|
|
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
|
|
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 策略阻止。",
|
package/src/security/gate.ts
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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.
|
|
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
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -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
|
-
//
|
|
33
|
-
|
|
34
|
-
//
|
|
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
|
-
//
|
|
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)
|
package/src/ui/graft-status.tsx
CHANGED
|
@@ -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 {
|
|
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`
|
|
21
|
-
* is read live each render (the app re-renders on
|
|
22
|
-
*
|
|
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(() =>
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
<
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
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 →
|
|
157
|
+
// ── Escape: cancel loading → clear draft ──
|
|
170
158
|
if (key.escape) {
|
|
171
|
-
//
|
|
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
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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.
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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('/')
|
|
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
|
|
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
|
|
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={
|
|
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
|
-
|
|
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 /
|
|
450
|
-
{slashHints.length > 0 &&
|
|
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 &&
|
|
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) => (
|
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
|
-
}
|