@miphamai/cli 0.73.1 → 0.75.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/core/eval-harness.ts +16 -0
- package/src/core/git-pr.ts +51 -0
- package/src/core/instructions.ts +9 -0
- package/src/i18n-core/locales/en-US.json +10 -0
- package/src/i18n-core/locales/zh-CN.json +10 -0
- package/src/shared/package-info.ts +1 -1
- package/src/skills/usage.ts +34 -0
- package/src/tools/agent/skill.ts +3 -0
- package/src/ui/app.tsx +19 -1
- package/src/ui/commands.ts +69 -0
- package/src/ui/input.tsx +88 -66
package/package.json
CHANGED
package/src/core/eval-harness.ts
CHANGED
|
@@ -70,6 +70,7 @@ export const ANCHOR_CONTRACT_IDS: ReadonlySet<string> = new Set([
|
|
|
70
70
|
'red-team-zero-gaps',
|
|
71
71
|
'producer-rule-shape',
|
|
72
72
|
'producer-rule-idempotent',
|
|
73
|
+
'self-report-diagnostic',
|
|
73
74
|
])
|
|
74
75
|
|
|
75
76
|
/** 细粒度防回退:返回 role==='anchor' 且已 FAIL 的契约 id。空 = 无 anchor 回退。 */
|
|
@@ -333,6 +334,21 @@ export function runEval(): EvalReport {
|
|
|
333
334
|
results.push({ ...judgeBehaviorTask(task, ruleEngine), role: 'target' })
|
|
334
335
|
}
|
|
335
336
|
|
|
337
|
+
// ── 自报分数只作诊断:评分路径无 LLM,分数来自 ground-truth 契约而非模型自报 ──
|
|
338
|
+
// anchor 锁死「评分组件不暴露 LLM 的 chat 能力」。4 个组件(ruleEngine/constitution/
|
|
339
|
+
// errorDB/preflight)都是确定性组件(runEval 同步评分)。若未来有人把 LLM 注入评分
|
|
340
|
+
// 路径(给组件挂 chat / Llm 接口),此契约立即 FAIL,anchor gate 拒绝固化——即「自报
|
|
341
|
+
// 分数不可信」的机器可判定信号。
|
|
342
|
+
const scoringComponents = [ruleEngine, constitution, errorDB, preflight]
|
|
343
|
+
const llmInjected = scoringComponents.some(
|
|
344
|
+
(c) => typeof (c as { chat?: unknown }).chat === 'function',
|
|
345
|
+
)
|
|
346
|
+
results.push({
|
|
347
|
+
id: 'self-report-diagnostic',
|
|
348
|
+
description: '评分无 LLM:机制哨兵组件不暴露 chat 能力(分数只来自 ground-truth,非模型自报)',
|
|
349
|
+
passed: !llmInjected,
|
|
350
|
+
})
|
|
351
|
+
|
|
336
352
|
// 角色标注:anchor 走集中清单(门保护面单一真源),target 已在上方循环内联。
|
|
337
353
|
for (const r of results) {
|
|
338
354
|
if (ANCHOR_CONTRACT_IDS.has(r.id)) r.role = 'anchor'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { exec } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
export interface GitPr {
|
|
4
|
+
number: number
|
|
5
|
+
state: 'OPEN' | 'CLOSED' | 'MERGED'
|
|
6
|
+
isDraft: boolean
|
|
7
|
+
reviewDecision: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type PrColor = 'white' | 'green' | 'yellow' | 'magenta' | 'gray'
|
|
11
|
+
|
|
12
|
+
/** Parse `gh pr list --json number,state,isDraft,reviewDecision` output into the first PR.
|
|
13
|
+
* Bad input / empty list / missing number → null. */
|
|
14
|
+
export function parseGitPr(json: string): GitPr | null {
|
|
15
|
+
try {
|
|
16
|
+
const arr = JSON.parse(json)
|
|
17
|
+
if (!Array.isArray(arr) || arr.length === 0) return null
|
|
18
|
+
const first = arr[0]
|
|
19
|
+
if (!first || typeof first.number !== 'number') return null
|
|
20
|
+
return {
|
|
21
|
+
number: first.number,
|
|
22
|
+
state: first.state ?? 'OPEN',
|
|
23
|
+
isDraft: Boolean(first.isDraft),
|
|
24
|
+
reviewDecision: first.reviewDecision ?? '',
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Map PR state to an Ink color — mirrors Claude Code's status-line PR badge:
|
|
32
|
+
* merged → magenta, closed/draft → gray, approved → green, changes-requested → yellow,
|
|
33
|
+
* otherwise (open, no review) → white. */
|
|
34
|
+
export function prColor(pr: GitPr): PrColor {
|
|
35
|
+
if (pr.state === 'MERGED') return 'magenta'
|
|
36
|
+
if (pr.state === 'CLOSED' || pr.isDraft) return 'gray'
|
|
37
|
+
if (pr.reviewDecision === 'APPROVED') return 'green'
|
|
38
|
+
if (pr.reviewDecision === 'CHANGES_REQUESTED') return 'yellow'
|
|
39
|
+
return 'white'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Detect the PR whose head is `branch`. gh unavailable / no PR / non-zero exit → null.
|
|
43
|
+
* Async so the status line never blocks startup. */
|
|
44
|
+
export function resolveGitPr(branch: string): Promise<GitPr | null> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
const cmd = `gh pr list --head "${branch}" --state all --json number,state,isDraft,reviewDecision`
|
|
47
|
+
exec(cmd, { timeout: 3000 }, (err, stdout) => {
|
|
48
|
+
resolve(err ? null : parseGitPr(stdout))
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
}
|
package/src/core/instructions.ts
CHANGED
|
@@ -275,6 +275,15 @@ say so and read it first, rather than answering hastily and retracting
|
|
|
275
275
|
afterwards. This applies to every code question, not only research or
|
|
276
276
|
borrow-analysis tasks.`)
|
|
277
277
|
|
|
278
|
+
// CRSI code-review 合并门 — 合并 PR 前必须先 review,勿靠「碰巧触发」
|
|
279
|
+
parts.push(`## Code-Review Merge Gate
|
|
280
|
+
|
|
281
|
+
Before merging a PR or committing changes that have not been
|
|
282
|
+
code-reviewed, run a code review first (the /code-review command or the
|
|
283
|
+
code-review skill) and address its findings. Do not rely on "the user
|
|
284
|
+
happened to ask" to trigger a review — review proactively as a fixed
|
|
285
|
+
step before merge.`)
|
|
286
|
+
|
|
278
287
|
// CRSI 教训召回 — 把 crsi-lessons.md 的教训精华注入,让模型「写后召回」而非只写不读
|
|
279
288
|
const lessonsBlock = buildCrsiLessonsBlock(this.crsiLessonSummaries)
|
|
280
289
|
if (lessonsBlock) parts.push(lessonsBlock)
|
|
@@ -613,6 +613,16 @@
|
|
|
613
613
|
"audit_clean": " ✓ no code-derivable sections found",
|
|
614
614
|
"audit_found": "Add the sections above to the `prompt-exclude` frontmatter to stop sending them to the model."
|
|
615
615
|
},
|
|
616
|
+
"skillDoctor": {
|
|
617
|
+
"title": "── Skill Doctor ──",
|
|
618
|
+
"unavailable": " (skills info unavailable)",
|
|
619
|
+
"summary": "Loaded {total} skills — ~{tokens} tokens in context. {unused} never used (~{unusedTokens} tokens reclaimable).",
|
|
620
|
+
"unused_header": "Never used:",
|
|
621
|
+
"used_header": "Recently used:",
|
|
622
|
+
"never": "never used",
|
|
623
|
+
"days_ago": "{days}d ago",
|
|
624
|
+
"today": "today"
|
|
625
|
+
},
|
|
616
626
|
"fix": {
|
|
617
627
|
"title": "── Fix ──",
|
|
618
628
|
"dryrun_banner": "[dry-run] preview — no changes written",
|
|
@@ -613,6 +613,16 @@
|
|
|
613
613
|
"audit_clean": " ✓ 未发现可从代码推断的冗余章节",
|
|
614
614
|
"audit_found": "将上述章节加入 `prompt-exclude` frontmatter,可停止发送给模型、节省 token。"
|
|
615
615
|
},
|
|
616
|
+
"skillDoctor": {
|
|
617
|
+
"title": "── 技能体检 ──",
|
|
618
|
+
"unavailable": " (技能信息不可用)",
|
|
619
|
+
"summary": "已加载 {total} 个技能 — 占 context ~{tokens} tokens。{unused} 个从未使用(可省 ~{unusedTokens} tokens)。",
|
|
620
|
+
"unused_header": "从未使用:",
|
|
621
|
+
"used_header": "最近使用:",
|
|
622
|
+
"never": "从未使用",
|
|
623
|
+
"days_ago": "{days} 天前",
|
|
624
|
+
"today": "今天"
|
|
625
|
+
},
|
|
616
626
|
"fix": {
|
|
617
627
|
"title": "── 修复 ──",
|
|
618
628
|
"dryrun_banner": "[dry-run] 预览模式 — 不落盘",
|
|
@@ -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.75.0' as const
|
|
13
13
|
|
|
14
14
|
/** npm install 全局安装命令 */
|
|
15
15
|
export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { readFileSync, mkdirSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { homedir } from 'node:os'
|
|
4
|
+
import { atomicWriteFileSync } from '../shared/atomic-write'
|
|
5
|
+
|
|
6
|
+
// Skill 使用记录:name → 最近调用时间戳(epoch ms)。持久化到 ~/.mipham/skill-usage.json,
|
|
7
|
+
// 供 /skill-doctor 识别「从未被调用」的 skill(跨会话累积,按证据 prune)。
|
|
8
|
+
const USAGE_DIR = join(homedir(), '.mipham')
|
|
9
|
+
const USAGE_FILE = join(USAGE_DIR, 'skill-usage.json')
|
|
10
|
+
|
|
11
|
+
/** 读 skill 使用记录。文件缺失或损坏(非法 JSON / 类型不符)→ 空 Map,绝不抛错。 */
|
|
12
|
+
export function loadSkillUsage(): Map<string, number> {
|
|
13
|
+
try {
|
|
14
|
+
const raw = readFileSync(USAGE_FILE, 'utf-8')
|
|
15
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>
|
|
16
|
+
const map = new Map<string, number>()
|
|
17
|
+
for (const [name, ts] of Object.entries(parsed)) {
|
|
18
|
+
if (typeof ts === 'number') map.set(name, ts)
|
|
19
|
+
}
|
|
20
|
+
return map
|
|
21
|
+
} catch {
|
|
22
|
+
return new Map()
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 记录一次 skill 调用。原子写(temp + rename),读者要么见旧值要么见新值,绝不读到半截。 */
|
|
27
|
+
export function recordSkillUsage(name: string, now = Date.now()): void {
|
|
28
|
+
const map = loadSkillUsage()
|
|
29
|
+
map.set(name, now)
|
|
30
|
+
const obj: Record<string, number> = {}
|
|
31
|
+
for (const [k, v] of map) obj[k] = v
|
|
32
|
+
mkdirSync(USAGE_DIR, { recursive: true })
|
|
33
|
+
atomicWriteFileSync(USAGE_FILE, JSON.stringify(obj))
|
|
34
|
+
}
|
package/src/tools/agent/skill.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { executeForkedSkill } from '../../skills/fork-executor'
|
|
|
3
3
|
import { sanitizeSkillBody } from '../../skills/sanitizer'
|
|
4
4
|
import { ensureSkillAssets } from '../../skills/skill-assets'
|
|
5
5
|
import { checkRequiredBins } from '../../skills/bin-check'
|
|
6
|
+
import { recordSkillUsage } from '../../skills/usage'
|
|
6
7
|
|
|
7
8
|
export const skillTool: ToolDefinition = {
|
|
8
9
|
name: 'Skill',
|
|
@@ -82,6 +83,7 @@ export const skillTool: ToolDefinition = {
|
|
|
82
83
|
ctx.llm,
|
|
83
84
|
)
|
|
84
85
|
// Return to AI as internal context
|
|
86
|
+
recordSkillUsage(skillName)
|
|
85
87
|
return { success: true, content: `[Forked skill "${skillName}" result]:\n${result}` }
|
|
86
88
|
} catch (err) {
|
|
87
89
|
return {
|
|
@@ -108,6 +110,7 @@ export const skillTool: ToolDefinition = {
|
|
|
108
110
|
bodyText,
|
|
109
111
|
].filter(Boolean)
|
|
110
112
|
|
|
113
|
+
recordSkillUsage(skillName)
|
|
111
114
|
return { success: true, content: lines.join('\n') }
|
|
112
115
|
}
|
|
113
116
|
|
package/src/ui/app.tsx
CHANGED
|
@@ -9,6 +9,7 @@ import type { RemoteEngine } from '../daemon/remote-engine'
|
|
|
9
9
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
10
10
|
import type { Llm } from '../providers/llm'
|
|
11
11
|
import { AUTOCOMPLETE_MAX_CONTEXT, type RecentMessage } from '../core/autocomplete'
|
|
12
|
+
import { resolveGitPr, prColor, type GitPr } from '../core/git-pr'
|
|
12
13
|
import type { SkillsLoader } from '../skills/loader'
|
|
13
14
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
14
15
|
import { setPreference } from '../config/preferences'
|
|
@@ -200,6 +201,18 @@ export function App({
|
|
|
200
201
|
return null
|
|
201
202
|
}
|
|
202
203
|
})
|
|
204
|
+
// Current branch's PR (head = branch) — async detect; gh unavailable / no PR → null.
|
|
205
|
+
const [gitPr, setGitPr] = useState<GitPr | null>(null)
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
if (!gitBranch) return
|
|
208
|
+
let cancelled = false
|
|
209
|
+
resolveGitPr(gitBranch).then((pr) => {
|
|
210
|
+
if (!cancelled) setGitPr(pr)
|
|
211
|
+
})
|
|
212
|
+
return () => {
|
|
213
|
+
cancelled = true
|
|
214
|
+
}
|
|
215
|
+
}, [gitBranch])
|
|
203
216
|
|
|
204
217
|
// 启动后台查新版(非阻塞;离线静默失败)
|
|
205
218
|
useEffect(() => {
|
|
@@ -1249,7 +1262,12 @@ export function App({
|
|
|
1249
1262
|
</Box>
|
|
1250
1263
|
|
|
1251
1264
|
{/* Git branch — dim, bottom-most, mirrors Claude Code's "⏺ main" */}
|
|
1252
|
-
{gitBranch &&
|
|
1265
|
+
{gitBranch && (
|
|
1266
|
+
<Box>
|
|
1267
|
+
<Text dimColor>⏺ {gitBranch}</Text>
|
|
1268
|
+
{gitPr && <Text color={prColor(gitPr)}> · PR #{gitPr.number}</Text>}
|
|
1269
|
+
</Box>
|
|
1270
|
+
)}
|
|
1253
1271
|
</>
|
|
1254
1272
|
)}
|
|
1255
1273
|
</Box>
|
package/src/ui/commands.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { QueryEngine } from '../core/engine'
|
|
|
8
8
|
import type { MiphamConfig } from '../shared/index.ts'
|
|
9
9
|
import { formatContextWindow } from '../shared/format'
|
|
10
10
|
import type { SkillsLoader } from '../skills/loader'
|
|
11
|
+
import { loadSkillUsage } from '../skills/usage'
|
|
11
12
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
12
13
|
import type { Message } from '../shared/types.js'
|
|
13
14
|
import type { UpdateStatus } from '../shared/update'
|
|
@@ -3198,6 +3199,71 @@ const doctorCmd: CommandHandler = async (ctx) => {
|
|
|
3198
3199
|
return { content: lines.join('\n') }
|
|
3199
3200
|
}
|
|
3200
3201
|
|
|
3202
|
+
const skillDoctorCmd: CommandHandler = async (ctx) => {
|
|
3203
|
+
const t = resolveT(ctx)
|
|
3204
|
+
const lines: string[] = [t('commands.skillDoctor.title'), '']
|
|
3205
|
+
|
|
3206
|
+
const loader = ctx.skillsLoader
|
|
3207
|
+
if (!loader) {
|
|
3208
|
+
lines.push(t('commands.skillDoctor.unavailable'))
|
|
3209
|
+
return { content: lines.join('\n') }
|
|
3210
|
+
}
|
|
3211
|
+
|
|
3212
|
+
const skills = loader.list()
|
|
3213
|
+
const usage = loadSkillUsage()
|
|
3214
|
+
|
|
3215
|
+
// Per-skill context cost = the reminder entry estimate (matches buildSystemReminder).
|
|
3216
|
+
const rows = skills.map((s) => {
|
|
3217
|
+
const entry = `- ${s.name}: ${s.description}`
|
|
3218
|
+
return {
|
|
3219
|
+
name: s.name,
|
|
3220
|
+
type: s.type,
|
|
3221
|
+
tokens: Math.ceil(entry.length / 4) + 1,
|
|
3222
|
+
lastUsed: usage.get(s.name),
|
|
3223
|
+
}
|
|
3224
|
+
})
|
|
3225
|
+
|
|
3226
|
+
const unused = rows.filter((r) => r.lastUsed === undefined)
|
|
3227
|
+
const used = rows
|
|
3228
|
+
.filter((r) => r.lastUsed !== undefined)
|
|
3229
|
+
.sort((a, b) => b.lastUsed! - a.lastUsed!)
|
|
3230
|
+
const totalTokens = rows.reduce((sum, r) => sum + r.tokens, 0)
|
|
3231
|
+
const unusedTokens = unused.reduce((sum, r) => sum + r.tokens, 0)
|
|
3232
|
+
|
|
3233
|
+
const age = (ts: number): string => {
|
|
3234
|
+
const days = Math.floor((Date.now() - ts) / 86400000)
|
|
3235
|
+
if (days <= 0) return t('commands.skillDoctor.today')
|
|
3236
|
+
return t('commands.skillDoctor.days_ago', { days: String(days) })
|
|
3237
|
+
}
|
|
3238
|
+
|
|
3239
|
+
lines.push(
|
|
3240
|
+
t('commands.skillDoctor.summary', {
|
|
3241
|
+
total: String(skills.length),
|
|
3242
|
+
tokens: String(totalTokens),
|
|
3243
|
+
unused: String(unused.length),
|
|
3244
|
+
unusedTokens: String(unusedTokens),
|
|
3245
|
+
}),
|
|
3246
|
+
)
|
|
3247
|
+
lines.push('')
|
|
3248
|
+
|
|
3249
|
+
if (unused.length > 0) {
|
|
3250
|
+
lines.push(t('commands.skillDoctor.unused_header'))
|
|
3251
|
+
for (const r of unused) {
|
|
3252
|
+
lines.push(` • ${r.name} (${r.type}) — ~${r.tokens} tokens`)
|
|
3253
|
+
}
|
|
3254
|
+
lines.push('')
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
if (used.length > 0) {
|
|
3258
|
+
lines.push(t('commands.skillDoctor.used_header'))
|
|
3259
|
+
for (const r of used) {
|
|
3260
|
+
lines.push(` • ${r.name} (${r.type}) — ${age(r.lastUsed!)}`)
|
|
3261
|
+
}
|
|
3262
|
+
}
|
|
3263
|
+
|
|
3264
|
+
return { content: lines.join('\n') }
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3201
3267
|
const fixCmd: CommandHandler = async (ctx, args) => {
|
|
3202
3268
|
const t = resolveT(ctx)
|
|
3203
3269
|
const { readFileSync, writeFileSync } = await import('node:fs')
|
|
@@ -5143,6 +5209,7 @@ const commandsListCmd: CommandHandler = () => {
|
|
|
5143
5209
|
'/save': 'Session & Identity',
|
|
5144
5210
|
'/export': 'Session & Identity',
|
|
5145
5211
|
'/doctor': 'Session & Identity',
|
|
5212
|
+
'/skill-doctor': 'Session & Identity',
|
|
5146
5213
|
'/dream': 'Session & Identity',
|
|
5147
5214
|
'/constitution': 'Session & Identity',
|
|
5148
5215
|
'/bug-report': 'Session & Identity',
|
|
@@ -5388,6 +5455,7 @@ registry.set('/pr-comments', prCommentsCmd)
|
|
|
5388
5455
|
|
|
5389
5456
|
// Session Management
|
|
5390
5457
|
registry.set('/doctor', doctorCmd)
|
|
5458
|
+
registry.set('/skill-doctor', skillDoctorCmd)
|
|
5391
5459
|
registry.set('/fix', fixCmd)
|
|
5392
5460
|
registry.set('/export', exportCmd)
|
|
5393
5461
|
registry.set('/resume', resumeCmd)
|
|
@@ -5493,6 +5561,7 @@ const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
5493
5561
|
'/save': 'Save conversation to Obsidian wiki (skill: save-to-wiki)',
|
|
5494
5562
|
'/export': 'Export conversation to file',
|
|
5495
5563
|
'/doctor': 'System diagnostics',
|
|
5564
|
+
'/skill-doctor': 'Show unused skills and their context cost',
|
|
5496
5565
|
'/fix': 'Deterministic self-repair: doctor/config/cache',
|
|
5497
5566
|
'/dream': 'Background memory consolidation',
|
|
5498
5567
|
'/constitution': 'View or reload constitutional principles',
|
package/src/ui/input.tsx
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import React, { useState, useEffect, useRef, useMemo } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
|
-
import TextInput from 'ink-text-input'
|
|
4
3
|
import { getCommandList } from './commands.js'
|
|
5
4
|
import { CommandPicker } from './command-picker.js'
|
|
6
5
|
import { useI18n } from '../i18n-context'
|
|
@@ -80,11 +79,11 @@ function pick<T>(arr: T[]): T {
|
|
|
80
79
|
}
|
|
81
80
|
|
|
82
81
|
/**
|
|
83
|
-
*
|
|
84
|
-
*
|
|
82
|
+
* 归一化输入:多行粘贴折叠成单行(CR/LF/Tab → 空格)。
|
|
83
|
+
* ink-text-input 是单行组件,嵌入的 \r 会拉回行首覆盖显示、\n 会 trap 光标。
|
|
85
84
|
*/
|
|
86
|
-
export function
|
|
87
|
-
return
|
|
85
|
+
export function normalizeInput(input: string): string {
|
|
86
|
+
return input.replace(/[\r\n\t]+/g, ' ')
|
|
88
87
|
}
|
|
89
88
|
|
|
90
89
|
/** True when typing a leading `/` should auto-open the slash-command picker. */
|
|
@@ -92,6 +91,81 @@ export function shouldAutoOpenPicker(value: string, prevValue: string, enabled:
|
|
|
92
91
|
return enabled && value.startsWith('/') && !prevValue.startsWith('/')
|
|
93
92
|
}
|
|
94
93
|
|
|
94
|
+
/**
|
|
95
|
+
* Mipham 自有的单行文本输入,替代 ink-text-input。
|
|
96
|
+
*
|
|
97
|
+
* 为什么不用 ink-text-input:它把粘贴按「光标偏移切片插入」逐块处理,而
|
|
98
|
+
* cursorOffset 与受控 value 都来自渲染闭包——Ink 会把长粘贴按 stdin read()
|
|
99
|
+
* 边界拆成多块,同一轮 synchronous flush 里后续块读到的仍是旧值,于是「覆盖
|
|
100
|
+
* 前块 / 插到中段」,表现为粘贴内容乱序、丢失、冻住。
|
|
101
|
+
*
|
|
102
|
+
* 这里用 ref 做同步真值:每块按当前 ref 原子追加(光标恒在末尾),不依赖
|
|
103
|
+
* React 渲染时序,分块粘贴自然累积成完整文本。
|
|
104
|
+
*/
|
|
105
|
+
function MiphamTextInput({
|
|
106
|
+
value,
|
|
107
|
+
onChange,
|
|
108
|
+
onSubmit,
|
|
109
|
+
placeholder = '',
|
|
110
|
+
focus = true,
|
|
111
|
+
}: {
|
|
112
|
+
value: string
|
|
113
|
+
onChange: (next: string) => void
|
|
114
|
+
onSubmit: (value: string) => void
|
|
115
|
+
placeholder?: string
|
|
116
|
+
focus?: boolean
|
|
117
|
+
}) {
|
|
118
|
+
// 同步真值:valueRef 永远是最新文本;受控 value 仅在渲染时落后于 ref。
|
|
119
|
+
const valueRef = useRef(value)
|
|
120
|
+
|
|
121
|
+
// 外部改动(箭头历史回填、提交清空)时,把真值对齐回受控 prop。
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
valueRef.current = value
|
|
124
|
+
}, [value])
|
|
125
|
+
|
|
126
|
+
useInput(
|
|
127
|
+
(input, key) => {
|
|
128
|
+
if (!focus) return
|
|
129
|
+
// 上行/下行/制表/Ctrl 由 InputBar 自己的 useInput 处理(历史导航、热键)。
|
|
130
|
+
if (key.upArrow || key.downArrow || key.tab || (key.shift && key.tab) || key.ctrl) return
|
|
131
|
+
|
|
132
|
+
if (key.return) {
|
|
133
|
+
onSubmit(valueRef.current)
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
if (key.backspace || key.delete) {
|
|
137
|
+
if (valueRef.current.length > 0) {
|
|
138
|
+
const next = valueRef.current.slice(0, -1)
|
|
139
|
+
valueRef.current = next
|
|
140
|
+
onChange(next)
|
|
141
|
+
}
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 打字 / 粘贴:归一化控制符后原子追加(光标恒在末尾)。
|
|
146
|
+
const cleaned = normalizeInput(input)
|
|
147
|
+
if (!cleaned) return
|
|
148
|
+
const next = valueRef.current + cleaned
|
|
149
|
+
valueRef.current = next
|
|
150
|
+
onChange(next)
|
|
151
|
+
},
|
|
152
|
+
{ isActive: focus },
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Text>
|
|
157
|
+
{value.length === 0 && placeholder ? (
|
|
158
|
+
<Text dimColor>{placeholder}</Text>
|
|
159
|
+
) : (
|
|
160
|
+
<>
|
|
161
|
+
{value}
|
|
162
|
+
<Text inverse> </Text>
|
|
163
|
+
</>
|
|
164
|
+
)}
|
|
165
|
+
</Text>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
95
169
|
export function InputBar({
|
|
96
170
|
onSubmit,
|
|
97
171
|
isLoading,
|
|
@@ -110,10 +184,7 @@ export function InputBar({
|
|
|
110
184
|
const { t } = useI18n()
|
|
111
185
|
const [value, setValue] = useState('')
|
|
112
186
|
// Ref mirror of value — used by useInput to read latest without stale closure.
|
|
113
|
-
// Also used by throttled onChange to hold the latest pending value.
|
|
114
187
|
const valueRef = useRef(value)
|
|
115
|
-
// Throttle timer for onChange — prevents React render floods during paste.
|
|
116
|
-
const onChangeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
117
188
|
const [verb, setVerb] = useState(() => t(pick(LOADING_KEYS)))
|
|
118
189
|
const [completionVerb, setCompletionVerb] = useState<string | null>(null)
|
|
119
190
|
const prevLoading = useRef(isLoading)
|
|
@@ -190,15 +261,6 @@ export function InputBar({
|
|
|
190
261
|
prevLoading.current = isLoading
|
|
191
262
|
}, [isLoading])
|
|
192
263
|
|
|
193
|
-
// Track pre-shortcut value so we can revert ink-text-input's Ctrl-key insertions.
|
|
194
|
-
// ink-text-input inserts 'p'/'f'/'o' for Ctrl+P/F/O (Ink normalizes control chars
|
|
195
|
-
// to their letter names before passing to useInput).
|
|
196
|
-
const valueBeforeShortcut = useRef(value)
|
|
197
|
-
// Keep the ref in sync with state on every change that isn't a Ctrl shortcut revert.
|
|
198
|
-
useEffect(() => {
|
|
199
|
-
valueBeforeShortcut.current = value
|
|
200
|
-
}, [value])
|
|
201
|
-
|
|
202
264
|
useInput((input, key) => {
|
|
203
265
|
// ── Escape: cancel loading → clear draft ──
|
|
204
266
|
if (key.escape) {
|
|
@@ -220,9 +282,9 @@ export function InputBar({
|
|
|
220
282
|
onCyclePermission?.()
|
|
221
283
|
return
|
|
222
284
|
}
|
|
223
|
-
// Tab → 接受 ghost-text
|
|
285
|
+
// Tab → 接受 ghost-text 建议
|
|
224
286
|
if (key.tab && !key.shift && suggestion) {
|
|
225
|
-
const next =
|
|
287
|
+
const next = valueRef.current + suggestion
|
|
226
288
|
setValue(next)
|
|
227
289
|
valueRef.current = next
|
|
228
290
|
setSuggestion(null)
|
|
@@ -230,27 +292,22 @@ export function InputBar({
|
|
|
230
292
|
}
|
|
231
293
|
// Ctrl+P → toggle model picker
|
|
232
294
|
// NOTE: Ink passes input=keypress.name (just 'p') when ctrl is true, not raw \x10.
|
|
233
|
-
// ink-text-input inserts 'p' as literal text — revert it.
|
|
234
295
|
if (key.ctrl && input === 'p') {
|
|
235
|
-
setValue(valueBeforeShortcut.current)
|
|
236
296
|
onTogglePicker?.()
|
|
237
297
|
return
|
|
238
298
|
}
|
|
239
299
|
// Ctrl+F → toggle focus mode
|
|
240
300
|
if (key.ctrl && input === 'f') {
|
|
241
|
-
setValue(valueBeforeShortcut.current)
|
|
242
301
|
onToggleFocus?.()
|
|
243
302
|
return
|
|
244
303
|
}
|
|
245
304
|
// Ctrl+O → expand/collapse last tool call
|
|
246
305
|
if (key.ctrl && input === 'o') {
|
|
247
|
-
setValue(valueBeforeShortcut.current)
|
|
248
306
|
onToggleExpand?.()
|
|
249
307
|
return
|
|
250
308
|
}
|
|
251
309
|
// Ctrl+G → toggle agent view dashboard
|
|
252
310
|
if (key.ctrl && input === 'g') {
|
|
253
|
-
setValue(valueBeforeShortcut.current)
|
|
254
311
|
onToggleAgentView?.()
|
|
255
312
|
return
|
|
256
313
|
}
|
|
@@ -267,13 +324,7 @@ export function InputBar({
|
|
|
267
324
|
if (submittedHistory.length === 0) return
|
|
268
325
|
// Save current draft the first time we enter history browsing
|
|
269
326
|
if (historyIndexRef.current === -1) {
|
|
270
|
-
//
|
|
271
|
-
// history navigation (paste → immediate ↑ race). Save the latest value
|
|
272
|
-
// from the ref — state `value` is stale inside the throttle window.
|
|
273
|
-
if (onChangeTimerRef.current) {
|
|
274
|
-
clearTimeout(onChangeTimerRef.current)
|
|
275
|
-
onChangeTimerRef.current = null
|
|
276
|
-
}
|
|
327
|
+
// Save the latest value from the ref — state `value` may lag a render.
|
|
277
328
|
savedDraftRef.current = valueRef.current
|
|
278
329
|
}
|
|
279
330
|
const newIndex = Math.min(historyIndexRef.current + 1, submittedHistory.length - 1)
|
|
@@ -319,13 +370,7 @@ export function InputBar({
|
|
|
319
370
|
}, [value])
|
|
320
371
|
|
|
321
372
|
const handleSubmit = (val: string) => {
|
|
322
|
-
//
|
|
323
|
-
if (onChangeTimerRef.current) {
|
|
324
|
-
clearTimeout(onChangeTimerRef.current)
|
|
325
|
-
onChangeTimerRef.current = null
|
|
326
|
-
}
|
|
327
|
-
// Use the latest value from the ref — state lags behind during throttle, and
|
|
328
|
-
// the onSubmit `val` is the stale controlled prop in that window.
|
|
373
|
+
// Use the latest value from the ref; `val` is the value passed by MiphamTextInput.
|
|
329
374
|
const finalValue = valueRef.current || val
|
|
330
375
|
if (!finalValue.trim()) return
|
|
331
376
|
// Submitting while a response streams interrupts it (Claude Code parity)
|
|
@@ -371,7 +416,7 @@ export function InputBar({
|
|
|
371
416
|
<Box marginRight={1}>
|
|
372
417
|
<Text color={isLoading ? 'yellow' : 'cyan'}>{'>'}</Text>
|
|
373
418
|
</Box>
|
|
374
|
-
<
|
|
419
|
+
<MiphamTextInput
|
|
375
420
|
value={value}
|
|
376
421
|
onChange={(val) => {
|
|
377
422
|
// Reset history browsing when user starts typing
|
|
@@ -379,9 +424,6 @@ export function InputBar({
|
|
|
379
424
|
historyIndexRef.current = -1
|
|
380
425
|
savedDraftRef.current = ''
|
|
381
426
|
}
|
|
382
|
-
// Normalize newlines → spaces. ink-text-input is single-line; multi-line
|
|
383
|
-
// paste would trap arrow-key navigation on the first line.
|
|
384
|
-
const normalized = val.replace(/\n/g, ' ')
|
|
385
427
|
// ── Ghost-text 自动补全:每次输入清 suggestion + 重排防抖 ──
|
|
386
428
|
setSuggestion(null)
|
|
387
429
|
const suggestionReqId = ++suggestionReqIdRef.current
|
|
@@ -389,16 +431,12 @@ export function InputBar({
|
|
|
389
431
|
clearTimeout(suggestionTimerRef.current)
|
|
390
432
|
suggestionTimerRef.current = null
|
|
391
433
|
}
|
|
392
|
-
if (
|
|
393
|
-
llm &&
|
|
394
|
-
autocompleteEnabled &&
|
|
395
|
-
shouldAutocomplete(normalized, isLoading, pickerActive)
|
|
396
|
-
) {
|
|
434
|
+
if (llm && autocompleteEnabled && shouldAutocomplete(val, isLoading, pickerActive)) {
|
|
397
435
|
suggestionTimerRef.current = setTimeout(() => {
|
|
398
436
|
requestSuggestion(
|
|
399
437
|
llm,
|
|
400
438
|
recentMessages ?? [],
|
|
401
|
-
|
|
439
|
+
val,
|
|
402
440
|
() => suggestionReqId !== suggestionReqIdRef.current,
|
|
403
441
|
)
|
|
404
442
|
.then((completion) => {
|
|
@@ -409,24 +447,8 @@ export function InputBar({
|
|
|
409
447
|
})
|
|
410
448
|
}, autocompleteDebounceMs)
|
|
411
449
|
}
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
const bulk = isBulkInput(valueRef.current, normalized)
|
|
415
|
-
valueRef.current = normalized
|
|
416
|
-
if (!bulk) {
|
|
417
|
-
if (onChangeTimerRef.current) {
|
|
418
|
-
clearTimeout(onChangeTimerRef.current)
|
|
419
|
-
onChangeTimerRef.current = null
|
|
420
|
-
}
|
|
421
|
-
setValue(normalized)
|
|
422
|
-
return
|
|
423
|
-
}
|
|
424
|
-
if (onChangeTimerRef.current) return // timer pending, latest value in ref
|
|
425
|
-
setValue(normalized)
|
|
426
|
-
onChangeTimerRef.current = setTimeout(() => {
|
|
427
|
-
onChangeTimerRef.current = null
|
|
428
|
-
setValue(valueRef.current)
|
|
429
|
-
}, 33) // ~30fps
|
|
450
|
+
valueRef.current = val
|
|
451
|
+
setValue(val)
|
|
430
452
|
}}
|
|
431
453
|
onSubmit={handleSubmit}
|
|
432
454
|
placeholder={
|