@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.
- package/bin/mipham.ts +21 -2
- package/package.json +1 -1
- package/src/commands/project.ts +9 -6
- package/src/core/engine.ts +7 -1
- package/src/i18n-core/locales/en-US.json +2 -3
- package/src/i18n-core/locales/zh-CN.json +2 -3
- package/src/index.tsx +1 -1
- package/src/plugin/claude-plugin.ts +174 -0
- package/src/plugin/plugin-loader.ts +25 -0
- package/src/plugin/plugin-manager.ts +46 -17
- package/src/plugin/plugin-validator.ts +58 -11
- package/src/security/gate.ts +3 -2
- package/src/shared/deleted-cwd.ts +26 -0
- package/src/shared/graft-savings.ts +34 -0
- package/src/shared/graft-stats.ts +53 -0
- package/src/shared/package-info.ts +1 -1
- package/src/skills/loader.ts +11 -33
- package/src/skills/seam.ts +1 -1
- package/src/tools/agent/memory.ts +20 -2
- package/src/tools/exec/bash.ts +4 -4
- package/src/ui/app.tsx +16 -3
- package/src/ui/graft-status.tsx +63 -0
- package/src/ui/input.tsx +45 -156
- package/src/ui/vim-motions.ts +0 -113
|
@@ -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.
|
|
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/skills/loader.ts
CHANGED
|
@@ -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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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(
|
|
229
|
-
const selected =
|
|
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
|
|
package/src/skills/seam.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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`)
|
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
|
@@ -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
|
-
//
|
|
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
|
|
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) => (
|