@miphamai/cli 0.60.0 → 0.61.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/config/defaults.ts +3 -1
- package/src/core/crsi-modify.ts +27 -1
- package/src/core/crsi-producer.ts +16 -2
- package/src/core/crsi-sandbox.ts +18 -0
- package/src/core/eval-harness.ts +11 -1
- package/src/core/instructions.ts +63 -27
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +6 -2
- package/src/ui/app.tsx +19 -11
- package/src/ui/command-picker.tsx +20 -13
- package/src/ui/command-token.ts +10 -0
- package/src/ui/commands.ts +11 -4
- package/src/ui/input.tsx +11 -3
package/package.json
CHANGED
package/src/config/defaults.ts
CHANGED
|
@@ -13,7 +13,9 @@ export const DEFAULT_CONFIG: MiphamConfig = {
|
|
|
13
13
|
defaultProvider: 'deepseek',
|
|
14
14
|
defaultModel: 'deepseek-v4-pro',
|
|
15
15
|
permission: 'default',
|
|
16
|
-
showThinking: '
|
|
16
|
+
showThinking: 'off',
|
|
17
|
+
showSchedulingNotices: false,
|
|
18
|
+
showCommandPicker: false,
|
|
17
19
|
// Org 级权限限制(可选):forbiddenModes 禁指定模式 / maxAllowedMode 封顶层级;
|
|
18
20
|
// 请求被禁模式时 fail-closed 降级(如 forbiddenModes:['bypassPermissions'])。
|
|
19
21
|
// permissionRestrictions: { forbiddenModes: ['bypassPermissions'] },
|
package/src/core/crsi-modify.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { randomUUID } from 'node:crypto'
|
|
16
|
-
import { CrsiSandbox } from './crsi-sandbox'
|
|
16
|
+
import { CrsiSandbox, validateBlastRadius } from './crsi-sandbox'
|
|
17
17
|
import type { CrsiModificationResult } from './crsi-sandbox'
|
|
18
18
|
import { runEval, appendEvalScore, getLastEvalScore } from './eval-harness'
|
|
19
19
|
|
|
@@ -28,6 +28,13 @@ export interface CrsiProposal {
|
|
|
28
28
|
originalContent?: string
|
|
29
29
|
/** 触发此改动的 CRSI insight id */
|
|
30
30
|
crsiInsightId?: string
|
|
31
|
+
/**
|
|
32
|
+
* 完整覆盖(blast radius):此改动触及的**所有**代码路径(文件列表)。
|
|
33
|
+
*
|
|
34
|
+
* 源于 2026-08-26 教训:修「思考转储」只接实时指示器、漏历史行冲刷路径——
|
|
35
|
+
* 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,否则 fail-closed 拒绝。
|
|
36
|
+
*/
|
|
37
|
+
blastRadius?: string[]
|
|
31
38
|
}
|
|
32
39
|
|
|
33
40
|
// ── Pending proposal registry (两阶段闸门) ──
|
|
@@ -42,6 +49,25 @@ export function runCrsiModification(
|
|
|
42
49
|
proposal: CrsiProposal,
|
|
43
50
|
sandbox: CrsiSandbox = new CrsiSandbox(),
|
|
44
51
|
): CrsiModificationResult {
|
|
52
|
+
// 完整覆盖闸:自修改必须声明 blastRadius,否则 fail-closed(在 worktree 之前,零副作用)。
|
|
53
|
+
const blastRadiusError = validateBlastRadius(proposal)
|
|
54
|
+
if (blastRadiusError) {
|
|
55
|
+
return {
|
|
56
|
+
modification: {
|
|
57
|
+
id: 'crsi-mod-rejected-blast-radius',
|
|
58
|
+
description: proposal.description,
|
|
59
|
+
filePath: proposal.filePath,
|
|
60
|
+
newContent: proposal.newContent,
|
|
61
|
+
originalContent: proposal.originalContent ?? '',
|
|
62
|
+
crsiInsightId: proposal.crsiInsightId,
|
|
63
|
+
timestamp: new Date().toISOString(),
|
|
64
|
+
},
|
|
65
|
+
applied: false,
|
|
66
|
+
phase: 'failed',
|
|
67
|
+
error: blastRadiusError,
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
45
71
|
sandbox.createWorktree()
|
|
46
72
|
|
|
47
73
|
const applied = sandbox.applyModification({
|
|
@@ -127,7 +127,13 @@ export function produceCrsiProposal(
|
|
|
127
127
|
metaRules: MetaRule[],
|
|
128
128
|
currentLessons: string,
|
|
129
129
|
timestamp: string,
|
|
130
|
-
): {
|
|
130
|
+
): {
|
|
131
|
+
description: string
|
|
132
|
+
filePath: string
|
|
133
|
+
newContent: string
|
|
134
|
+
originalContent: string
|
|
135
|
+
blastRadius: string[]
|
|
136
|
+
} | null {
|
|
131
137
|
const signal = selectCrsiSignal(insights, metaRules)
|
|
132
138
|
if (!signal) return null
|
|
133
139
|
|
|
@@ -142,6 +148,7 @@ export function produceCrsiProposal(
|
|
|
142
148
|
filePath: LESSONS_FILE,
|
|
143
149
|
newContent,
|
|
144
150
|
originalContent: currentLessons,
|
|
151
|
+
blastRadius: [LESSONS_FILE],
|
|
145
152
|
}
|
|
146
153
|
}
|
|
147
154
|
|
|
@@ -215,7 +222,13 @@ export function renderManagedRuleSource(signal: CrsiSignal): string | null {
|
|
|
215
222
|
export function produceRuleProposal(
|
|
216
223
|
signal: CrsiSignal,
|
|
217
224
|
currentManagedRules: string,
|
|
218
|
-
): {
|
|
225
|
+
): {
|
|
226
|
+
description: string
|
|
227
|
+
filePath: string
|
|
228
|
+
newContent: string
|
|
229
|
+
originalContent: string
|
|
230
|
+
blastRadius: string[]
|
|
231
|
+
} | null {
|
|
219
232
|
const ruleSource = renderManagedRuleSource(signal)
|
|
220
233
|
if (!ruleSource) return null
|
|
221
234
|
|
|
@@ -232,6 +245,7 @@ export function produceRuleProposal(
|
|
|
232
245
|
filePath: MANAGED_RULES_FILE,
|
|
233
246
|
newContent,
|
|
234
247
|
originalContent: currentManagedRules,
|
|
248
|
+
blastRadius: [MANAGED_RULES_FILE],
|
|
235
249
|
}
|
|
236
250
|
}
|
|
237
251
|
|
package/src/core/crsi-sandbox.ts
CHANGED
|
@@ -122,6 +122,24 @@ export function isProtectedPath(filePath: string): boolean {
|
|
|
122
122
|
return PROTECTED_PATHS.some((p) => filePath === p || filePath.startsWith(p))
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* 完整覆盖闸(blast radius):自修改 proposal 必须声明非空 blastRadius,否则拒绝。
|
|
127
|
+
*
|
|
128
|
+
* 源于 2026-08-26 教训:修「思考转储」只接实时指示器、漏历史行冲刷路径——
|
|
129
|
+
* 局部正确、全局遗漏。自修改前必须摸清并声明全部受影响路径,fail-closed。
|
|
130
|
+
*
|
|
131
|
+
* 返回错误字符串(拒绝理由),合法时返回 null。
|
|
132
|
+
*/
|
|
133
|
+
export function validateBlastRadius(proposal: { blastRadius?: string[] }): string | null {
|
|
134
|
+
if (!proposal.blastRadius || proposal.blastRadius.length === 0) {
|
|
135
|
+
return (
|
|
136
|
+
'blast radius 未声明:自修改必须枚举触及的全部代码路径。' +
|
|
137
|
+
'教训:两条渲染路径只接一条 = 局部正确全局遗漏。'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
125
143
|
// ── Sandbox ──
|
|
126
144
|
|
|
127
145
|
export class CrsiSandbox {
|
package/src/core/eval-harness.ts
CHANGED
|
@@ -19,7 +19,7 @@ import { ConstitutionLoader, DEFAULT_CONSTITUTION } from './constitution-loader'
|
|
|
19
19
|
import { ErrorSignatureDB } from './error-signature-db'
|
|
20
20
|
import { PreFlightChecker } from './preflight-checker'
|
|
21
21
|
import { RedTeam } from './red-team'
|
|
22
|
-
import { isProtectedPath } from './crsi-sandbox'
|
|
22
|
+
import { isProtectedPath, validateBlastRadius } from './crsi-sandbox'
|
|
23
23
|
import { produceRuleProposal, MANAGED_RULES_FILE } from './crsi-producer'
|
|
24
24
|
import type { CrsiSignal } from './crsi-producer'
|
|
25
25
|
import { loadBehaviorTasks, judgeBehaviorTask } from './behavior-tasks'
|
|
@@ -167,6 +167,16 @@ export function runEval(): EvalReport {
|
|
|
167
167
|
results.push({ id, description: `受保护路径被拒: ${path}`, passed: isProtectedPath(path) })
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// ── 完整覆盖闸(ground truth:未声明 blast radius 的 proposal 被 fail-closed 拒绝) ──
|
|
171
|
+
results.push({
|
|
172
|
+
id: 'blast-radius-gate',
|
|
173
|
+
description: '自修改 proposal 未声明 blast radius 被拒,声明后放行',
|
|
174
|
+
passed:
|
|
175
|
+
validateBlastRadius({ blastRadius: undefined }) !== null &&
|
|
176
|
+
validateBlastRadius({ blastRadius: [] }) !== null &&
|
|
177
|
+
validateBlastRadius({ blastRadius: ['apps/cli/src/foo.ts'] }) === null,
|
|
178
|
+
})
|
|
179
|
+
|
|
170
180
|
// ── 安全(ground truth:16 攻击零漏过) ──
|
|
171
181
|
const redTeam = new RedTeam().run(constitution, preflight, errorDB)
|
|
172
182
|
results.push({
|
package/src/core/instructions.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
2
|
+
import { join, resolve, relative, sep, isAbsolute } from 'node:path'
|
|
3
3
|
import { execSync } from 'node:child_process'
|
|
4
4
|
import { parse as parseYaml } from 'yaml'
|
|
5
5
|
import type { InstructionFile } from '../shared/index.ts'
|
|
@@ -59,32 +59,78 @@ export function parsePromptExclude(value: unknown): string[] {
|
|
|
59
59
|
return []
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** 定位仓库根(git rev-parse --show-toplevel),非 git 目录回退 cwd。 */
|
|
63
|
+
export function gitRoot(cwd: string): string {
|
|
64
|
+
try {
|
|
65
|
+
return execSync('git rev-parse --show-toplevel', {
|
|
66
|
+
cwd,
|
|
67
|
+
timeout: 5000,
|
|
68
|
+
encoding: 'utf-8',
|
|
69
|
+
}).trim()
|
|
70
|
+
} catch {
|
|
71
|
+
return cwd
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 从仓库根到 cwd 的目录链(含两端),就近(cwd)在最后。cwd 不在 root 下时退化为 [cwd]。 */
|
|
76
|
+
export function discoverDirectories(root: string, cwd: string): string[] {
|
|
77
|
+
const absRoot = resolve(root)
|
|
78
|
+
const absCwd = resolve(cwd)
|
|
79
|
+
if (absCwd === absRoot) return [absRoot]
|
|
80
|
+
|
|
81
|
+
const rel = relative(absRoot, absCwd)
|
|
82
|
+
if (rel.startsWith('..') || isAbsolute(rel)) return [absCwd]
|
|
83
|
+
|
|
84
|
+
const dirs = [absRoot]
|
|
85
|
+
let cur = absRoot
|
|
86
|
+
for (const seg of rel.split(sep)) {
|
|
87
|
+
cur = join(cur, seg)
|
|
88
|
+
dirs.push(cur)
|
|
89
|
+
}
|
|
90
|
+
return dirs
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 每目录内指令文件的读取顺序(后加载 = 更高优先级)。
|
|
95
|
+
* AGENTS.md(行业标准基线)→ AGENTS.override.md(Codex 覆盖层)→
|
|
96
|
+
* MIPHAM.md(Mipham 品牌)→ CLAUDE.md(Claude Code 兼容)。
|
|
97
|
+
* MIPHAM→CLAUDE 相对顺序保持现状不变,仅前置 AGENTS 两条,避免行为回归。
|
|
98
|
+
*/
|
|
99
|
+
export const INSTRUCTION_FILENAMES = [
|
|
100
|
+
'AGENTS.md',
|
|
101
|
+
'AGENTS.override.md',
|
|
102
|
+
'MIPHAM.md',
|
|
103
|
+
'CLAUDE.md',
|
|
104
|
+
] as const
|
|
105
|
+
|
|
62
106
|
export class InstructionsLoader {
|
|
63
107
|
private instructions: InstructionFile[] = []
|
|
64
108
|
private crsiLessonSummaries: CrsiLessonSummary[] = []
|
|
65
109
|
|
|
66
110
|
loadAll(cwd: string): void {
|
|
67
111
|
this.instructions = []
|
|
112
|
+
const root = gitRoot(cwd)
|
|
113
|
+
|
|
114
|
+
// Tier 1: 集团/公司策略(锚定仓库根,从任意子目录启动都正确;不读 AGENTS.md)
|
|
115
|
+
this.tryLoad(join(root, '..', '..', 'CLAUDE.md'), 'group') // Rismed_Ronxin_Capital
|
|
116
|
+
this.tryLoad(join(root, '..', 'CLAUDE.md'), 'company') // One_Mipham_Corporation
|
|
117
|
+
this.tryLoad(join(root, '..', 'MIPHAM.md'), 'group')
|
|
118
|
+
|
|
119
|
+
// Tier 2: 递归项目层 — git 根 → cwd,逐目录读,就近(cwd)最后 = 优先级最高
|
|
120
|
+
const dirs = discoverDirectories(root, cwd)
|
|
121
|
+
dirs.forEach((dir, i) => {
|
|
122
|
+
const level: InstructionFile['level'] = i === dirs.length - 1 ? 'project' : 'directory'
|
|
123
|
+
for (const name of INSTRUCTION_FILENAMES) {
|
|
124
|
+
this.tryLoad(join(dir, name), level)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
68
127
|
|
|
69
|
-
// Tier
|
|
70
|
-
// ../../ = Rismed_Ronxin_Capital
|
|
71
|
-
this.tryLoad(join(cwd, '..', '..', 'CLAUDE.md'), 'group')
|
|
72
|
-
// ../ = One_Mipham_Corporation
|
|
73
|
-
this.tryLoad(join(cwd, '..', 'CLAUDE.md'), 'company')
|
|
74
|
-
|
|
75
|
-
// Tier 1b: Group-level MIPHAM.md — One_Mipham_Corporation is the root
|
|
76
|
-
this.tryLoad(join(cwd, '..', 'MIPHAM.md'), 'group')
|
|
77
|
-
|
|
78
|
-
// Tier 2: Project-level (CLAUDE.md + MIPHAM.md at cwd)
|
|
79
|
-
this.tryLoad(join(cwd, 'MIPHAM.md'), 'project')
|
|
80
|
-
this.tryLoad(join(cwd, 'CLAUDE.md'), 'project')
|
|
81
|
-
|
|
82
|
-
// Tier 3: User-level ~/.mipham/USER.md
|
|
128
|
+
// Tier 3: 用户层 ~/.mipham/USER.md
|
|
83
129
|
const home = process.env.HOME || '~'
|
|
84
130
|
this.tryLoad(join(home, '.mipham', 'USER.md'), 'user')
|
|
85
131
|
|
|
86
132
|
// CRSI 教训召回:读 crsi-lessons.md 提取精华,注入系统提示(只写不读 → 写后召回)
|
|
87
|
-
this.crsiLessonSummaries = this.loadCrsiLessons(
|
|
133
|
+
this.crsiLessonSummaries = this.loadCrsiLessons(root)
|
|
88
134
|
}
|
|
89
135
|
|
|
90
136
|
buildSystemPrompt(permissionMode?: string): string {
|
|
@@ -235,17 +281,7 @@ Never omit it or present the work as purely human-authored.`)
|
|
|
235
281
|
}
|
|
236
282
|
|
|
237
283
|
/** 读 crsi-lessons.md(按仓库根定位)提取教训精华。读不到则返回空。 */
|
|
238
|
-
private loadCrsiLessons(
|
|
239
|
-
let root = cwd
|
|
240
|
-
try {
|
|
241
|
-
root = execSync('git rev-parse --show-toplevel', {
|
|
242
|
-
cwd,
|
|
243
|
-
timeout: 5000,
|
|
244
|
-
encoding: 'utf-8',
|
|
245
|
-
}).trim()
|
|
246
|
-
} catch {
|
|
247
|
-
// 非 git 目录 → 回退 cwd
|
|
248
|
-
}
|
|
284
|
+
private loadCrsiLessons(root: string): CrsiLessonSummary[] {
|
|
249
285
|
try {
|
|
250
286
|
const content = readFileSync(join(root, LESSONS_FILE), 'utf-8')
|
|
251
287
|
return extractCrsiLessonSummaries(content)
|
|
@@ -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.61.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/shared/types.ts
CHANGED
|
@@ -137,11 +137,15 @@ export interface MiphamConfig {
|
|
|
137
137
|
permissionRules?: { allow?: string[]; deny?: string[] }
|
|
138
138
|
/**
|
|
139
139
|
* How to render the model's reasoning/thinking before the answer:
|
|
140
|
-
* - `off` → hide entirely
|
|
141
|
-
* - `minimal` → content-free "thinking…" indicator
|
|
140
|
+
* - `off` → hide entirely (default — clean output)
|
|
141
|
+
* - `minimal` → content-free "thinking…" indicator
|
|
142
142
|
* - `full` → last 200 chars of the actual thinking text
|
|
143
143
|
*/
|
|
144
144
|
showThinking?: 'off' | 'minimal' | 'full'
|
|
145
|
+
/** When false, hide the `⏰ Wakeup scheduled` scheduling confirmation notices. Default false. */
|
|
146
|
+
showSchedulingNotices?: boolean
|
|
147
|
+
/** When false, disable the slash-command picker auto-popup on `/`. Default false. */
|
|
148
|
+
showCommandPicker?: boolean
|
|
145
149
|
providers: ProviderConfig[]
|
|
146
150
|
skills?: { paths: string[]; mcpServers: McpServerConfig[] }
|
|
147
151
|
marketplace?: {
|
package/src/ui/app.tsx
CHANGED
|
@@ -446,10 +446,17 @@ export function App({
|
|
|
446
446
|
clearTimeout(thinkingTimerRef.current)
|
|
447
447
|
thinkingTimerRef.current = null
|
|
448
448
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
449
|
+
const thinkingLine = formatThinking(
|
|
450
|
+
config.showThinking ?? 'off',
|
|
451
|
+
thought,
|
|
452
|
+
t('ui.loading.thinking'),
|
|
453
|
+
)
|
|
454
|
+
if (thinkingLine) {
|
|
455
|
+
setMessages((prev) => [
|
|
456
|
+
...prev,
|
|
457
|
+
{ role: 'system' as const, content: thinkingLine },
|
|
458
|
+
])
|
|
459
|
+
}
|
|
453
460
|
}
|
|
454
461
|
turnContent = chunk.content
|
|
455
462
|
isNewTurn = false
|
|
@@ -668,7 +675,7 @@ export function App({
|
|
|
668
675
|
// Turn finished — drain any /loop wakeup queued while we were running.
|
|
669
676
|
drainLoopQueueRef.current?.(turnId)
|
|
670
677
|
},
|
|
671
|
-
[engine, syncBgAgents],
|
|
678
|
+
[engine, syncBgAgents, config, t],
|
|
672
679
|
)
|
|
673
680
|
|
|
674
681
|
// Ref-held drain breaks the runTurn ↔ drain circular useCallback dependency: runTurn
|
|
@@ -811,11 +818,11 @@ export function App({
|
|
|
811
818
|
setApiKeyPrompt(result.needsApiKey)
|
|
812
819
|
setApiKeyInput('')
|
|
813
820
|
}
|
|
814
|
-
setMessages((prev) =>
|
|
815
|
-
...prev,
|
|
816
|
-
{ role: '
|
|
817
|
-
|
|
818
|
-
|
|
821
|
+
setMessages((prev) => {
|
|
822
|
+
const next: ChatMessage[] = [...prev, { role: 'user', content: input }]
|
|
823
|
+
if (result.content) next.push({ role: 'system', content: result.content })
|
|
824
|
+
return next
|
|
825
|
+
})
|
|
819
826
|
if (result.needsApiKey) {
|
|
820
827
|
// Don't process nextProvider/model when waiting for API key
|
|
821
828
|
if (result.exit) process.exit(0)
|
|
@@ -1019,7 +1026,7 @@ export function App({
|
|
|
1019
1026
|
<ChatPanel messages={messages} focusMode={focusMode} />
|
|
1020
1027
|
{(() => {
|
|
1021
1028
|
const indicator = formatThinking(
|
|
1022
|
-
config.showThinking ?? '
|
|
1029
|
+
config.showThinking ?? 'off',
|
|
1023
1030
|
thinkingText,
|
|
1024
1031
|
t('ui.loading.thinking'),
|
|
1025
1032
|
)
|
|
@@ -1063,6 +1070,7 @@ export function App({
|
|
|
1063
1070
|
<InputBar
|
|
1064
1071
|
onSubmit={handleSubmit}
|
|
1065
1072
|
isLoading={isLoading}
|
|
1073
|
+
showCommandPicker={config.showCommandPicker ?? false}
|
|
1066
1074
|
onTogglePicker={() => setPickerOpen((prev) => !prev)}
|
|
1067
1075
|
onToggleFocus={() => setFocusMode((prev) => !prev)}
|
|
1068
1076
|
onToggleExpand={() => {
|
|
@@ -3,6 +3,7 @@ import { Box, Text, useInput } from 'ink'
|
|
|
3
3
|
import TextInput from 'ink-text-input'
|
|
4
4
|
import { useI18n } from '../i18n-context'
|
|
5
5
|
import { getCommandList } from './commands.js'
|
|
6
|
+
import { commandToken, hasInlineArgs } from './command-token.js'
|
|
6
7
|
|
|
7
8
|
interface CommandPickerProps {
|
|
8
9
|
/** Text already typed (e.g. "/", "/age") — used as initial filter */
|
|
@@ -60,9 +61,10 @@ export function CommandPicker({
|
|
|
60
61
|
const [filter, setFilter] = useState(initialFilter)
|
|
61
62
|
const [cursorIdx, setCursorIdx] = useState(0)
|
|
62
63
|
|
|
63
|
-
// Filter commands based on user input
|
|
64
|
+
// Filter commands based on user input (match on the command-name token, not the
|
|
65
|
+
// full line — so "/loop 60s echo hello" still matches "/loop").
|
|
64
66
|
const filtered = useMemo(() => {
|
|
65
|
-
const q =
|
|
67
|
+
const q = commandToken(filter)
|
|
66
68
|
if (!q) return allCommands
|
|
67
69
|
return allCommands.filter(
|
|
68
70
|
(cmd) => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q),
|
|
@@ -86,6 +88,19 @@ export function CommandPicker({
|
|
|
86
88
|
const safeCursor = (i: number) =>
|
|
87
89
|
filtered.length === 0 ? 0 : ((i % filtered.length) + filtered.length) % filtered.length
|
|
88
90
|
|
|
91
|
+
// Submit the selection: if the filter has inline args (e.g. "/loop 60s echo hello"),
|
|
92
|
+
// submit the full line so the args reach the command handler; otherwise submit the
|
|
93
|
+
// highlighted command name.
|
|
94
|
+
const submitSelection = () => {
|
|
95
|
+
const trimmed = filter.trim()
|
|
96
|
+
if (hasInlineArgs(trimmed)) {
|
|
97
|
+
onSelect(trimmed)
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
const selected = filtered[cursorIdx]
|
|
101
|
+
if (selected) onSelect(selected.name)
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
useInput((_input, key) => {
|
|
90
105
|
if (key.escape) {
|
|
91
106
|
onClose()
|
|
@@ -93,10 +108,7 @@ export function CommandPicker({
|
|
|
93
108
|
}
|
|
94
109
|
|
|
95
110
|
if (key.return) {
|
|
96
|
-
|
|
97
|
-
if (selected) {
|
|
98
|
-
onSelect(selected.name)
|
|
99
|
-
}
|
|
111
|
+
submitSelection()
|
|
100
112
|
return
|
|
101
113
|
}
|
|
102
114
|
|
|
@@ -132,7 +144,7 @@ export function CommandPicker({
|
|
|
132
144
|
{visible.map((cmd, i) => {
|
|
133
145
|
const globalIdx = i + scrollStart
|
|
134
146
|
const isCursor = globalIdx === cursorIdx
|
|
135
|
-
const query =
|
|
147
|
+
const query = commandToken(filter)
|
|
136
148
|
const segments = highlightMatches(cmd.name, query)
|
|
137
149
|
const padLen = Math.max(0, 20 - cmd.name.length)
|
|
138
150
|
return (
|
|
@@ -171,12 +183,7 @@ export function CommandPicker({
|
|
|
171
183
|
<TextInput
|
|
172
184
|
value={filter.startsWith('/') ? filter.slice(1) : filter}
|
|
173
185
|
onChange={(val) => setFilter(`/${val}`)}
|
|
174
|
-
onSubmit={() =>
|
|
175
|
-
const selected = filtered[cursorIdx]
|
|
176
|
-
if (selected) {
|
|
177
|
-
onSelect(selected.name)
|
|
178
|
-
}
|
|
179
|
-
}}
|
|
186
|
+
onSubmit={() => submitSelection()}
|
|
180
187
|
placeholder={t('ui.command_picker.placeholder')}
|
|
181
188
|
/>
|
|
182
189
|
</Box>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Extract the command-name token from a slash filter: "/loop 60s echo hello" → "loop". */
|
|
2
|
+
export function commandToken(filter: string): string {
|
|
3
|
+
const withoutSlash = filter.startsWith('/') ? filter.slice(1) : filter
|
|
4
|
+
return withoutSlash.trim().split(/\s+/)[0]?.toLowerCase() ?? ''
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** True when the filter carries inline args beyond the command name: "/loop auto" → true, "/loop" → false. */
|
|
8
|
+
export function hasInlineArgs(filter: string): boolean {
|
|
9
|
+
return filter.trim().includes(' ')
|
|
10
|
+
}
|
package/src/ui/commands.ts
CHANGED
|
@@ -546,7 +546,9 @@ const configCmd: CommandHandler = (ctx) => {
|
|
|
546
546
|
`defaultProvider: ${c.defaultProvider}`,
|
|
547
547
|
`defaultModel: ${c.defaultModel}`,
|
|
548
548
|
`permission: ${c.permission}`,
|
|
549
|
-
`showThinking: ${c.showThinking ?? '
|
|
549
|
+
`showThinking: ${c.showThinking ?? 'off'}`,
|
|
550
|
+
`showSchedulingNotices: ${c.showSchedulingNotices ?? false}`,
|
|
551
|
+
`showCommandPicker: ${c.showCommandPicker ?? false}`,
|
|
550
552
|
`providers: ${c.providers.length} configured`,
|
|
551
553
|
'',
|
|
552
554
|
`crossSessionInbound: ${xs.crossSessionInbound}`,
|
|
@@ -2821,9 +2823,14 @@ const loopCmd: CommandHandler = async (ctx, args) => {
|
|
|
2821
2823
|
{ delaySeconds: seconds, reason: `Loop: ${prompt.slice(0, 40)}`, prompt },
|
|
2822
2824
|
{ cwd: process.cwd(), sessionId: 'loop-session', provider: '', model: '' },
|
|
2823
2825
|
)
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2826
|
+
if (!result.success) {
|
|
2827
|
+
return { content: `Loop failed: ${result.error || 'unknown error'}` }
|
|
2828
|
+
}
|
|
2829
|
+
// When scheduling notices are hidden, stay silent — the loop is already scheduled.
|
|
2830
|
+
if (ctx.config.showSchedulingNotices === false) {
|
|
2831
|
+
return { content: '' }
|
|
2832
|
+
}
|
|
2833
|
+
return { content: result.content }
|
|
2827
2834
|
} catch (e) {
|
|
2828
2835
|
return { content: `Loop failed: ${e instanceof Error ? e.message : String(e)}` }
|
|
2829
2836
|
}
|
package/src/ui/input.tsx
CHANGED
|
@@ -21,6 +21,8 @@ interface InputBarProps {
|
|
|
21
21
|
onCyclePermission?: () => void
|
|
22
22
|
/** Escape → cancel loading / clear draft */
|
|
23
23
|
onCancel?: () => void
|
|
24
|
+
/** When false, don't auto-open the slash-command picker when typing `/`. */
|
|
25
|
+
showCommandPicker?: boolean
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
// ── Loading verb keys (i18n) ──
|
|
@@ -75,6 +77,11 @@ export function isBulkInput(prev: string, next: string): boolean {
|
|
|
75
77
|
return Math.abs(next.length - prev.length) > 1
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/** True when typing a leading `/` should auto-open the slash-command picker. */
|
|
81
|
+
export function shouldAutoOpenPicker(value: string, prevValue: string, enabled: boolean): boolean {
|
|
82
|
+
return enabled && value.startsWith('/') && !prevValue.startsWith('/')
|
|
83
|
+
}
|
|
84
|
+
|
|
78
85
|
export function InputBar({
|
|
79
86
|
onSubmit,
|
|
80
87
|
isLoading,
|
|
@@ -84,6 +91,7 @@ export function InputBar({
|
|
|
84
91
|
onToggleAgentView,
|
|
85
92
|
onCyclePermission,
|
|
86
93
|
onCancel,
|
|
94
|
+
showCommandPicker = true,
|
|
87
95
|
}: InputBarProps) {
|
|
88
96
|
const { t } = useI18n()
|
|
89
97
|
const [value, setValue] = useState('')
|
|
@@ -251,9 +259,9 @@ export function InputBar({
|
|
|
251
259
|
const [pickerActive, setPickerActive] = useState(false)
|
|
252
260
|
const prevValueRef = useRef(value)
|
|
253
261
|
|
|
254
|
-
// Auto-activate picker when user types "/"
|
|
262
|
+
// Auto-activate picker when user types "/" (unless disabled via showCommandPicker)
|
|
255
263
|
useEffect(() => {
|
|
256
|
-
if (value
|
|
264
|
+
if (shouldAutoOpenPicker(value, prevValueRef.current, showCommandPicker)) {
|
|
257
265
|
setPickerActive(true)
|
|
258
266
|
}
|
|
259
267
|
// Dismiss picker when user clears the / prefix
|
|
@@ -261,7 +269,7 @@ export function InputBar({
|
|
|
261
269
|
setPickerActive(false)
|
|
262
270
|
}
|
|
263
271
|
prevValueRef.current = value
|
|
264
|
-
}, [value])
|
|
272
|
+
}, [value, showCommandPicker])
|
|
265
273
|
|
|
266
274
|
// Keep valueRef in sync with state (so useInput handlers read latest value)
|
|
267
275
|
useEffect(() => {
|