@miphamai/cli 0.85.4 → 0.85.6
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 +48 -7
- package/package.json +2 -2
- package/src/agent/agent-context.ts +8 -1
- package/src/agent/effectiveness-tracker.ts +16 -2
- package/src/agent/sub-agent.ts +25 -17
- package/src/agent-view/agents-standalone.tsx +42 -0
- package/src/core/autocomplete.ts +30 -2
- package/src/core/context.ts +61 -6
- package/src/core/dream-engine.ts +17 -2
- package/src/core/engine.ts +95 -34
- package/src/core/error-signature-db.ts +7 -2
- package/src/core/hooks-executor.ts +88 -11
- package/src/core/hooks.ts +26 -2
- package/src/core/instructions.ts +105 -17
- package/src/core/memory/memory-manager.ts +14 -6
- package/src/core/permission-classifier.ts +17 -5
- package/src/core/permission-rules.ts +1 -1
- package/src/core/permission.ts +50 -1
- package/src/core/rule-engine.ts +27 -2
- package/src/core/self-critique.ts +15 -3
- package/src/core/session-log.ts +60 -0
- package/src/daemon/index.ts +2 -5
- package/src/daemon/launch.ts +69 -2
- package/src/daemon/server.ts +19 -14
- package/src/i18n-core/locales/en-US.json +86 -143
- package/src/i18n-core/locales/zh-CN.json +86 -143
- package/src/index.tsx +17 -7
- package/src/mcp/client.ts +61 -0
- package/src/mcp/instructions.ts +49 -0
- package/src/mcp/types.ts +7 -0
- package/src/plugin/claude-plugin.ts +12 -2
- package/src/plugin/plugin-loader.ts +32 -14
- package/src/plugin/plugin-manager.ts +16 -2
- package/src/plugin/plugin-validator.ts +183 -1
- package/src/providers/anthropic.ts +159 -124
- package/src/providers/fetch-utils.ts +65 -34
- package/src/providers/openai-compat.ts +121 -106
- package/src/security/dangerous-rm.ts +192 -0
- package/src/shared/arg-validation.ts +11 -1
- package/src/shared/constants.ts +18 -0
- package/src/shared/deleted-cwd.ts +46 -1
- package/src/shared/package-info.ts +36 -1
- package/src/shared/types.ts +8 -0
- package/src/shared/update.ts +290 -146
- package/src/ui/command-picker.tsx +18 -10
- package/src/ui/commands.ts +20 -6
- package/src/ui/config-wizard.tsx +22 -19
- package/src/ui/graft-status.tsx +35 -6
- package/src/ui/input.tsx +7 -2
- package/src/ui/picker.tsx +38 -27
- package/src/ui/use-key-state.ts +55 -0
- package/src/daemon/message-bus.ts +0 -84
package/src/ui/commands.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { SkillsLoader } from '../skills/loader'
|
|
|
11
11
|
import { loadSkillUsage } from '../skills/usage'
|
|
12
12
|
import type { PluginManager } from '../plugin/plugin-manager'
|
|
13
13
|
import type { Message } from '../shared/types.js'
|
|
14
|
-
import type { UpdateStatus } from '../shared/update'
|
|
14
|
+
import type { InstallState, UpdateStatus } from '../shared/update'
|
|
15
15
|
import { McpClient } from '../mcp/client'
|
|
16
16
|
import { unregisterMcpServerTools } from '../mcp/registry'
|
|
17
17
|
import { buildCapabilityReport } from '../core/capability-inventory'
|
|
@@ -4438,6 +4438,21 @@ const memoryCmd: CommandHandler = async (ctx, args) => {
|
|
|
4438
4438
|
// Upgrade
|
|
4439
4439
|
// ═══════════════════════════════════════════════════════════════
|
|
4440
4440
|
|
|
4441
|
+
/**
|
|
4442
|
+
* 失败后「我手里还有没有 CLI」这句话的四种说法。
|
|
4443
|
+
*
|
|
4444
|
+
* `Record<InstallState, …>` 而非内联三元:漏一个状态**编译期就红**。写成
|
|
4445
|
+
* `result.rolledBack ? A : B` 时漏掉的那一格会静默落进 else,而 else 说的正是**最重的
|
|
4446
|
+
* 那句**(「原安装未能恢复」)—— 拿「最坏情况」当兜底,就等于把每条没想清楚的路径都
|
|
4447
|
+
* 报成灾难。
|
|
4448
|
+
*/
|
|
4449
|
+
const UPGRADE_FAILURE_NOTE: Record<InstallState, string> = {
|
|
4450
|
+
untouched: 'commands.upgrade.install_untouched',
|
|
4451
|
+
restored: 'commands.upgrade.rolled_back',
|
|
4452
|
+
broken: 'commands.upgrade.no_rollback',
|
|
4453
|
+
unknown: 'commands.upgrade.install_unknown',
|
|
4454
|
+
}
|
|
4455
|
+
|
|
4441
4456
|
const upgradeCmd: CommandHandler = async (ctx) => {
|
|
4442
4457
|
const t = resolveT(ctx)
|
|
4443
4458
|
const { checkForUpdates, backupConfig, performUpdate, restoreConfig, getConfigPath } =
|
|
@@ -4500,10 +4515,9 @@ const upgradeCmd: CommandHandler = async (ctx) => {
|
|
|
4500
4515
|
lines.push(t('commands.upgrade.old_version_warning'))
|
|
4501
4516
|
} else {
|
|
4502
4517
|
lines.push('')
|
|
4503
|
-
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
)
|
|
4518
|
+
// 失败信息必须回答两件事:为什么,以及**我手里还有没有 CLI**。四态各有各的说法 ——
|
|
4519
|
+
// 尤其「旧树没被碰过」与「旧树没能恢复」是两件不同的事,合并成一句后者必定说谎。
|
|
4520
|
+
const rollbackNote = t(UPGRADE_FAILURE_NOTE[result.installState])
|
|
4507
4521
|
lines.push(
|
|
4508
4522
|
t('commands.upgrade.update_failed', {
|
|
4509
4523
|
reason: result.reason ?? '',
|
|
@@ -4593,7 +4607,7 @@ const workflowsCmd: CommandHandler = async () => {
|
|
|
4593
4607
|
} else {
|
|
4594
4608
|
lines.push('')
|
|
4595
4609
|
lines.push(`${found} workflow(s) found.`)
|
|
4596
|
-
lines.push('Use /
|
|
4610
|
+
lines.push('Use /workflow run <name> to run one.')
|
|
4597
4611
|
}
|
|
4598
4612
|
|
|
4599
4613
|
return { content: lines.join('\n') }
|
package/src/ui/config-wizard.tsx
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import React, { useState, useEffect } from 'react'
|
|
12
12
|
import { Box, Text, useInput } from 'ink'
|
|
13
13
|
import { useI18n } from '../i18n-context'
|
|
14
|
+
import { useKeyState } from './use-key-state'
|
|
14
15
|
import TextInput from 'ink-text-input'
|
|
15
16
|
import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
|
|
16
17
|
import type { ModelInfo } from '../shared/types'
|
|
@@ -159,7 +160,9 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
159
160
|
const [error, setError] = useState<string | null>(null)
|
|
160
161
|
|
|
161
162
|
// List navigation cursor
|
|
162
|
-
|
|
163
|
+
// 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),确认时
|
|
164
|
+
// 必须读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
|
|
165
|
+
const cursor = useKeyState(0)
|
|
163
166
|
|
|
164
167
|
// Ollama
|
|
165
168
|
const [ollamaModel, _setOllamaModel] = useState('')
|
|
@@ -174,7 +177,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
174
177
|
|
|
175
178
|
// Reset cursor when step changes
|
|
176
179
|
const goStep = (s: Step) => {
|
|
177
|
-
|
|
180
|
+
cursor.set(0)
|
|
178
181
|
setOllamaCursor(0)
|
|
179
182
|
setError(null)
|
|
180
183
|
setStep(s)
|
|
@@ -265,10 +268,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
265
268
|
|
|
266
269
|
// ── Mode selection ──
|
|
267
270
|
if (step === 'mode') {
|
|
268
|
-
if (key.upArrow)
|
|
269
|
-
if (key.downArrow)
|
|
271
|
+
if (key.upArrow) cursor.set((c) => (c === 0 ? 1 : 0))
|
|
272
|
+
if (key.downArrow) cursor.set((c) => (c === 0 ? 1 : 0))
|
|
270
273
|
if (key.return) {
|
|
271
|
-
if (cursor === 0) {
|
|
274
|
+
if (cursor.read() === 0) {
|
|
272
275
|
// Cloud
|
|
273
276
|
setMode('cloud')
|
|
274
277
|
goStep('provider')
|
|
@@ -284,10 +287,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
284
287
|
|
|
285
288
|
// ── Cloud provider selection ──
|
|
286
289
|
if (step === 'provider') {
|
|
287
|
-
if (key.upArrow)
|
|
288
|
-
if (key.downArrow)
|
|
290
|
+
if (key.upArrow) cursor.set((c) => (c > 0 ? c - 1 : providerList.length - 1))
|
|
291
|
+
if (key.downArrow) cursor.set((c) => (c < providerList.length - 1 ? c + 1 : 0))
|
|
289
292
|
if (key.return) {
|
|
290
|
-
const p = providerList[cursor]
|
|
293
|
+
const p = providerList[cursor.read()]
|
|
291
294
|
if (p) {
|
|
292
295
|
setProviderId(p.id)
|
|
293
296
|
goStep('model')
|
|
@@ -299,10 +302,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
299
302
|
// ── Cloud model selection ──
|
|
300
303
|
if (step === 'model') {
|
|
301
304
|
if (modelList.length === 0) return
|
|
302
|
-
if (key.upArrow)
|
|
303
|
-
if (key.downArrow)
|
|
305
|
+
if (key.upArrow) cursor.set((c) => (c > 0 ? c - 1 : modelList.length - 1))
|
|
306
|
+
if (key.downArrow) cursor.set((c) => (c < modelList.length - 1 ? c + 1 : 0))
|
|
304
307
|
if (key.return) {
|
|
305
|
-
const m = modelList[cursor]
|
|
308
|
+
const m = modelList[cursor.read()]
|
|
306
309
|
if (m) {
|
|
307
310
|
setModelId(m.id)
|
|
308
311
|
goStep('apikey')
|
|
@@ -381,8 +384,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
381
384
|
</Box>
|
|
382
385
|
|
|
383
386
|
<Box marginBottom={1}>
|
|
384
|
-
<Text color={cursor === 0 ? SELECTED_COLOR : undefined}>
|
|
385
|
-
{cursor === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')}
|
|
387
|
+
<Text color={cursor.value === 0 ? SELECTED_COLOR : undefined}>
|
|
388
|
+
{cursor.value === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')}
|
|
386
389
|
</Text>
|
|
387
390
|
</Box>
|
|
388
391
|
<Text dimColor>
|
|
@@ -390,8 +393,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
390
393
|
</Text>
|
|
391
394
|
|
|
392
395
|
<Box marginTop={2} marginBottom={1}>
|
|
393
|
-
<Text color={cursor === 1 ? SELECTED_COLOR : undefined}>
|
|
394
|
-
{cursor === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')}
|
|
396
|
+
<Text color={cursor.value === 1 ? SELECTED_COLOR : undefined}>
|
|
397
|
+
{cursor.value === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')}
|
|
395
398
|
</Text>
|
|
396
399
|
</Box>
|
|
397
400
|
<Text dimColor>{t('ui.wizard.mode_local_hint')}</Text>
|
|
@@ -410,8 +413,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
410
413
|
</Box>
|
|
411
414
|
{providerList.map((p, i) => (
|
|
412
415
|
<Box key={p.id} marginBottom={1}>
|
|
413
|
-
<Text color={i === cursor ? SELECTED_COLOR : undefined}>
|
|
414
|
-
{i === cursor ? '▶' : ' '} {p.name}
|
|
416
|
+
<Text color={i === cursor.value ? SELECTED_COLOR : undefined}>
|
|
417
|
+
{i === cursor.value ? '▶' : ' '} {p.name}
|
|
415
418
|
</Text>
|
|
416
419
|
<Text dimColor>
|
|
417
420
|
{t('ui.wizard.provider_count', {
|
|
@@ -439,8 +442,8 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
|
|
|
439
442
|
</Box>
|
|
440
443
|
{modelList.map((m, i) => (
|
|
441
444
|
<Box key={m.id} marginBottom={1}>
|
|
442
|
-
<Text color={i === cursor ? SELECTED_COLOR : undefined}>
|
|
443
|
-
{i === cursor ? '▶' : ' '} {m.name}
|
|
445
|
+
<Text color={i === cursor.value ? SELECTED_COLOR : undefined}>
|
|
446
|
+
{i === cursor.value ? '▶' : ' '} {m.name}
|
|
444
447
|
</Text>
|
|
445
448
|
<Text dimColor> ({m.id})</Text>
|
|
446
449
|
</Box>
|
package/src/ui/graft-status.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState } from 'react'
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
2
|
import { Box, Text } from 'ink'
|
|
3
3
|
import { basename } from 'node:path'
|
|
4
4
|
import { findGraftStats, type GraftStats } from '../shared/graft-stats'
|
|
@@ -13,17 +13,46 @@ function freshnessSegment(stats: GraftStats): { label: string; color: string } {
|
|
|
13
13
|
return { label: '✓ synced', color: 'blue' }
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* 只有**真变了**才换 state —— 否则每秒一次 setState 会把整棵树重渲染一遍。
|
|
18
|
+
* 两侧都出自 `findGraftStats` 的同一个 return 字面量,键序恒等,故字符串比较是稳的;
|
|
19
|
+
* 用整体比较而不是逐字段比,是为了将来给 `GraftStats` 加字段时**不必回来补这一行**。
|
|
20
|
+
*/
|
|
21
|
+
function sameStats(a: GraftStats | null, b: GraftStats | null): boolean {
|
|
22
|
+
if (a === b) return true
|
|
23
|
+
if (!a || !b) return false
|
|
24
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** 重读间隔。与 `goal-progress` / `workflow-progress` 同法(那两处也是 1s 一拍)。 */
|
|
28
|
+
const STATS_REFRESH_MS = 1000
|
|
29
|
+
|
|
16
30
|
/**
|
|
17
31
|
* Bottom-of-screen graft status line — mirrors graft's own "◤ graft · …" bar:
|
|
18
32
|
* ◤ graft · N nodes / E edges · ✓ synced · ~T tok saved
|
|
19
33
|
* ▸ ctx X% · last: file
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
34
|
+
*
|
|
35
|
+
* `graft/.cache/stats.json`(向上走到仓库根)是**别人在后台写的**:graft 自己重建索引时
|
|
36
|
+
* 先落 `syncing: true`,建完再落回来。挂载时读一次就永远说那一拍的话 ⇒ 启动正好撞上索引
|
|
37
|
+
* 重建的话,页脚会一直挂着 `syncing…`,而磁盘上早已 `"syncing": false` ——「有人的读数与
|
|
38
|
+
* 没人读的那个对象不一致」。故这里**按拍重读**。
|
|
39
|
+
*
|
|
40
|
+
* 别把它降成「每次渲染读一遍」:那要靠别的组件把这一棵带着重渲染(敲键盘 / agent tick)
|
|
41
|
+
* 才生效,而索引建完的那一刻**恰恰可能一个键都没有** —— 页脚会一直撒谎到用户下次敲键。
|
|
42
|
+
*
|
|
43
|
+
* 其余同前:tok-saved 每次渲染实时读;只在有 graft 图或有已知 ctx% 时渲染 —— ctx% 行
|
|
44
|
+
* 在本目录没建过 graft 时也照常显示。
|
|
24
45
|
*/
|
|
25
46
|
export function GraftStatusLine({ cwd, ctxPct }: { cwd: string; ctxPct?: number }) {
|
|
26
|
-
const [stats] = useState(() => findGraftStats(cwd))
|
|
47
|
+
const [stats, setStats] = useState(() => findGraftStats(cwd))
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const timer = setInterval(() => {
|
|
51
|
+
const next = findGraftStats(cwd)
|
|
52
|
+
setStats((prev) => (sameStats(prev, next) ? prev : next))
|
|
53
|
+
}, STATS_REFRESH_MS)
|
|
54
|
+
return () => clearInterval(timer)
|
|
55
|
+
}, [cwd])
|
|
27
56
|
const showCtx = typeof ctxPct === 'number'
|
|
28
57
|
if (!stats && !showCtx) return null
|
|
29
58
|
const fresh = stats ? freshnessSegment(stats) : null
|
package/src/ui/input.tsx
CHANGED
|
@@ -324,7 +324,12 @@ export function InputBar({
|
|
|
324
324
|
const suggestionReqIdRef = useRef(0)
|
|
325
325
|
|
|
326
326
|
// 统一清理:清 suggestion + 使在途/待发请求失效 + 取消防抖定时器。
|
|
327
|
-
// Escape / handleSubmit /
|
|
327
|
+
// Escape / handleSubmit / 翻历史 / **Tab 接受** 四处共用,避免各自手写漏掉某一环。
|
|
328
|
+
// Tab 那一处原先只手写 `setSuggestion(null)`(ROADMAP D8 缺口③):今天**不可达**
|
|
329
|
+
// ——有建议显示就说明上次请求已结束、定时器早烧掉了——但「接受后继续续写」一加上,
|
|
330
|
+
// 那个漏掉的环立刻变成洞。走同一入口,洞就不存在了。
|
|
331
|
+
// (`onChange` 里另有一处内联的同样三步,**不共用**:那里要的是把 reqId 推成**新**的,
|
|
332
|
+
// 与这里的「作废」是两件事。)
|
|
328
333
|
const clearSuggestion = () => {
|
|
329
334
|
setSuggestion(null)
|
|
330
335
|
suggestionReqIdRef.current++
|
|
@@ -411,7 +416,7 @@ export function InputBar({
|
|
|
411
416
|
const next = valueRef.current + suggestion
|
|
412
417
|
setValue(next)
|
|
413
418
|
valueRef.current = next
|
|
414
|
-
|
|
419
|
+
clearSuggestion()
|
|
415
420
|
return
|
|
416
421
|
}
|
|
417
422
|
// Ctrl+P → toggle model picker
|
package/src/ui/picker.tsx
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import React, { useState, useCallback } from 'react'
|
|
2
2
|
import { Box, Text, useInput } from 'ink'
|
|
3
3
|
import { useI18n } from '../i18n-context'
|
|
4
|
+
import { useKeyState } from './use-key-state'
|
|
4
5
|
import type { MiphamConfig, ProviderConfig } from '../shared/index.ts'
|
|
5
6
|
|
|
7
|
+
/** 环绕取模 —— 列表两端互为首尾。 */
|
|
8
|
+
const wrap = (i: number, len: number) => ((i % len) + len) % len
|
|
9
|
+
|
|
10
|
+
/** 只列启用中的模型(与 `providerList` 的 `upcoming` 过滤同一条口径)。 */
|
|
11
|
+
const activeModels = (p: ProviderConfig) => p.models.filter((m) => m.status === 'active')
|
|
12
|
+
|
|
6
13
|
interface PickerProps {
|
|
7
14
|
config: MiphamConfig
|
|
8
15
|
currentProvider: string
|
|
@@ -25,54 +32,58 @@ export function ModelPicker({
|
|
|
25
32
|
const { t } = useI18n()
|
|
26
33
|
// Get active providers only
|
|
27
34
|
const providers = config.providers.filter((p) => p.status !== 'upcoming')
|
|
28
|
-
const activeModels = (p: ProviderConfig) => p.models.filter((m) => m.status === 'active')
|
|
29
35
|
|
|
30
36
|
// State
|
|
31
37
|
const [activePanel, setActivePanel] = useState<Panel>('provider')
|
|
32
|
-
|
|
38
|
+
// 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),判据必须
|
|
39
|
+
// 读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
|
|
40
|
+
const providerIdx = useKeyState(() => {
|
|
33
41
|
const idx = providers.findIndex((p) => p.id === currentProvider)
|
|
34
42
|
return idx >= 0 ? idx : 0
|
|
35
43
|
})
|
|
36
|
-
const
|
|
44
|
+
const modelIdx = useKeyState(0)
|
|
37
45
|
|
|
38
|
-
const selectedProvider = providers[providerIdx]
|
|
46
|
+
const selectedProvider = providers[providerIdx.value]
|
|
39
47
|
const models = selectedProvider ? activeModels(selectedProvider) : []
|
|
40
48
|
|
|
41
49
|
// Reset model index when provider changes
|
|
42
50
|
const goToProvider = useCallback(
|
|
43
51
|
(idx: number) => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
setModelIdx(0)
|
|
52
|
+
providerIdx.set(wrap(idx, providers.length))
|
|
53
|
+
modelIdx.set(0)
|
|
47
54
|
setActivePanel('model') // auto-switch to model panel
|
|
48
55
|
},
|
|
49
|
-
[providers.length],
|
|
56
|
+
[providers.length, providerIdx, modelIdx],
|
|
50
57
|
)
|
|
51
58
|
|
|
52
|
-
|
|
53
|
-
|
|
59
|
+
/** 相对移动 —— 按 `read()` 叠加,同一拍里的两次 ↓ 就是两步。 */
|
|
60
|
+
const stepModel = useCallback(
|
|
61
|
+
(delta: number) => {
|
|
54
62
|
if (!selectedProvider) return
|
|
55
|
-
const
|
|
56
|
-
|
|
63
|
+
const len = models.length
|
|
64
|
+
if (len === 0) return
|
|
65
|
+
modelIdx.set((prev) => wrap(prev + delta, len))
|
|
57
66
|
},
|
|
58
|
-
[selectedProvider, models.length],
|
|
67
|
+
[selectedProvider, models.length, modelIdx],
|
|
59
68
|
)
|
|
60
69
|
|
|
61
70
|
const confirmSelection = useCallback(() => {
|
|
62
|
-
|
|
63
|
-
const
|
|
71
|
+
// 按 `read()` 取当前光标,而不是渲染时那份闭包 —— 否则「↓ Enter」确认的是上一行。
|
|
72
|
+
const provider = providers[providerIdx.read()]
|
|
73
|
+
if (!provider) return
|
|
74
|
+
const model = activeModels(provider)[modelIdx.read()]
|
|
64
75
|
if (model) {
|
|
65
76
|
// Check API key before confirming switch
|
|
66
|
-
const apiKey =
|
|
77
|
+
const apiKey = provider.apiKey
|
|
67
78
|
if (!apiKey || apiKey.trim() === '' || /^\$\{[A-Z_]+\}$/.test(apiKey.trim())) {
|
|
68
|
-
if (
|
|
69
|
-
onNeedsApiKey(
|
|
79
|
+
if (provider.id !== 'ollama' && onNeedsApiKey) {
|
|
80
|
+
onNeedsApiKey(provider.id, model.id, provider.name)
|
|
70
81
|
return
|
|
71
82
|
}
|
|
72
83
|
}
|
|
73
|
-
onSelect(
|
|
84
|
+
onSelect(provider.id, model.id)
|
|
74
85
|
}
|
|
75
|
-
}, [
|
|
86
|
+
}, [providers, providerIdx, modelIdx, onSelect, onNeedsApiKey])
|
|
76
87
|
|
|
77
88
|
useInput((input, key) => {
|
|
78
89
|
// Global keys
|
|
@@ -83,7 +94,7 @@ export function ModelPicker({
|
|
|
83
94
|
|
|
84
95
|
if (key.return) {
|
|
85
96
|
if (activePanel === 'provider') {
|
|
86
|
-
goToProvider(providerIdx) // switches to model panel
|
|
97
|
+
goToProvider(providerIdx.read()) // switches to model panel
|
|
87
98
|
} else {
|
|
88
99
|
confirmSelection()
|
|
89
100
|
}
|
|
@@ -105,18 +116,18 @@ export function ModelPicker({
|
|
|
105
116
|
// Up/Down navigation
|
|
106
117
|
if (key.upArrow) {
|
|
107
118
|
if (activePanel === 'provider') {
|
|
108
|
-
|
|
119
|
+
providerIdx.set((prev) => wrap(prev - 1, providers.length))
|
|
109
120
|
} else {
|
|
110
|
-
|
|
121
|
+
stepModel(-1)
|
|
111
122
|
}
|
|
112
123
|
return
|
|
113
124
|
}
|
|
114
125
|
|
|
115
126
|
if (key.downArrow) {
|
|
116
127
|
if (activePanel === 'provider') {
|
|
117
|
-
|
|
128
|
+
providerIdx.set((prev) => wrap(prev + 1, providers.length))
|
|
118
129
|
} else {
|
|
119
|
-
|
|
130
|
+
stepModel(1)
|
|
120
131
|
}
|
|
121
132
|
return
|
|
122
133
|
}
|
|
@@ -150,7 +161,7 @@ export function ModelPicker({
|
|
|
150
161
|
</Text>
|
|
151
162
|
{providers.map((p, i) => {
|
|
152
163
|
const isCurrent = p.id === currentProvider
|
|
153
|
-
const isSelected = i === providerIdx
|
|
164
|
+
const isSelected = i === providerIdx.value
|
|
154
165
|
const isUpcoming = p.status === 'upcoming'
|
|
155
166
|
return (
|
|
156
167
|
<Box key={p.id}>
|
|
@@ -185,7 +196,7 @@ export function ModelPicker({
|
|
|
185
196
|
{models.length === 0 && <Text dimColor> {t('ui.picker.no_active_models')}</Text>}
|
|
186
197
|
{models.map((m, i) => {
|
|
187
198
|
const isCurrentModel = selectedProvider?.id === currentProvider && m.id === currentModel
|
|
188
|
-
const isSelected = i === modelIdx
|
|
199
|
+
const isSelected = i === modelIdx.value
|
|
189
200
|
return (
|
|
190
201
|
<Box key={m.id} flexDirection="column">
|
|
191
202
|
<Text
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useCallback, useRef, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 一个状态 + 一个「在同一次按键回调里也读得到最新值」的镜像。
|
|
5
|
+
*
|
|
6
|
+
* 为什么需要它:一组按键会在同一个 chunk 里到达(远端/远程控制,或键盘把
|
|
7
|
+
* 「↓ 然后 Enter」一起写进来),Ink 的输入解析器会把这种转义序列打头的 chunk
|
|
8
|
+
* 切开**逐个派发** —— 而 React 还没提交第一个回调里的 setState,第二个回调拿到的
|
|
9
|
+
* 就还是上一张闭包。于是「↓ Enter」按在**按下 ↓ 之前**那一行上:按键有反应,
|
|
10
|
+
* 世界却是上一拍的。
|
|
11
|
+
*
|
|
12
|
+
* 与 `useCtrlCConfirm` 的两条路同源(那里的 `isArmed()` / `armed`):
|
|
13
|
+
* - `value` 只给渲染用;
|
|
14
|
+
* - `read()` 给判据用 —— 同步读数,同一次按键回调里 `set()` 之后立刻为新值;
|
|
15
|
+
* - `set()` 接受函数式写法,并按 `read()` 的当前值求值,于是同一个 burst 里的多次
|
|
16
|
+
* 修改能**叠加**(两次 ↓ 是两步,而不是把两次都算在同一个起点上)。
|
|
17
|
+
*
|
|
18
|
+
* 注意 `set()` 之外不要再写这个 state —— 绕过它写会让镜像与真值分叉。
|
|
19
|
+
*/
|
|
20
|
+
export interface KeyState<T> {
|
|
21
|
+
/** 渲染用。 */
|
|
22
|
+
value: T
|
|
23
|
+
/**
|
|
24
|
+
* 写入。函数式写法按 `read()` 求值后落盘:同一个 burst 里调用多次会依次叠加。
|
|
25
|
+
*
|
|
26
|
+
* `T` 是函数时函数式写法不可用(按值处理)—— 本仓库的调用点都是索引/枚举。
|
|
27
|
+
*/
|
|
28
|
+
set: (next: T | ((prev: T) => T)) => void
|
|
29
|
+
/** 判据用:同步读数,`set()` 之后立刻为新值。 */
|
|
30
|
+
read: () => T
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function useKeyState<T>(initial: T | (() => T)): KeyState<T> {
|
|
34
|
+
const [value, setValue] = useState(initial)
|
|
35
|
+
const ref = useRef(value)
|
|
36
|
+
|
|
37
|
+
const set = useCallback((next: T | ((prev: T) => T)) => {
|
|
38
|
+
const resolved = typeof next === 'function' ? (next as (prev: T) => T)(ref.current) : next
|
|
39
|
+
ref.current = resolved
|
|
40
|
+
setValue(resolved)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const read = useCallback(() => ref.current, [])
|
|
44
|
+
|
|
45
|
+
// 返回的对象**身份稳定**(每次渲染只刷新 `.value`):调用点会把它整个放进依赖
|
|
46
|
+
// 数组,每次渲染换一个身份会让依赖它的 effect 每次都重跑。
|
|
47
|
+
const apiRef = useRef<KeyState<T> | null>(null)
|
|
48
|
+
let api = apiRef.current
|
|
49
|
+
if (!api) {
|
|
50
|
+
api = { value, set, read }
|
|
51
|
+
apiRef.current = api
|
|
52
|
+
}
|
|
53
|
+
api.value = value
|
|
54
|
+
return api
|
|
55
|
+
}
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
// apps/cli/src/daemon/message-bus.ts — Phase 3: Inter-Agent Message Bus
|
|
2
|
-
//
|
|
3
|
-
// MessageBus provides asynchronous message passing between agents:
|
|
4
|
-
// - send() — enqueue a message from one agent to another
|
|
5
|
-
// - poll() — retrieve and clear pending messages (discards TTL-expired)
|
|
6
|
-
// - broadcastToSession() — send a message to all agents in a session
|
|
7
|
-
//
|
|
8
|
-
// Messages older than 5 minutes are automatically discarded on poll.
|
|
9
|
-
// Session-to-agent mapping is maintained externally via registerAgent/unregisterAgent.
|
|
10
|
-
|
|
11
|
-
interface BusMessage {
|
|
12
|
-
sender: string
|
|
13
|
-
content: string
|
|
14
|
-
timestamp: number
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export class MessageBus {
|
|
18
|
-
private queues = new Map<string, BusMessage[]>()
|
|
19
|
-
private sessionAgents = new Map<string, Set<string>>()
|
|
20
|
-
private readonly ttlMs = 5 * 60 * 1000 // 5 minutes
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Register an agent to a session so that broadcastToSession can reach it.
|
|
24
|
-
*/
|
|
25
|
-
registerAgent(sessionId: string, agentId: string): void {
|
|
26
|
-
if (!this.sessionAgents.has(sessionId)) {
|
|
27
|
-
this.sessionAgents.set(sessionId, new Set())
|
|
28
|
-
}
|
|
29
|
-
this.sessionAgents.get(sessionId)!.add(agentId)
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Remove an agent from all session registrations and clear its message queue.
|
|
34
|
-
*/
|
|
35
|
-
unregisterAgent(agentId: string): void {
|
|
36
|
-
for (const [, agents] of this.sessionAgents) {
|
|
37
|
-
agents.delete(agentId)
|
|
38
|
-
}
|
|
39
|
-
this.queues.delete(agentId)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Enqueue a message from senderAgentId to receiverAgentId.
|
|
44
|
-
*/
|
|
45
|
-
send(senderAgentId: string, receiverAgentId: string, content: string): void {
|
|
46
|
-
if (!this.queues.has(receiverAgentId)) {
|
|
47
|
-
this.queues.set(receiverAgentId, [])
|
|
48
|
-
}
|
|
49
|
-
this.queues.get(receiverAgentId)!.push({
|
|
50
|
-
sender: senderAgentId,
|
|
51
|
-
content,
|
|
52
|
-
timestamp: Date.now(),
|
|
53
|
-
})
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Retrieve and clear all pending messages for an agent.
|
|
58
|
-
* Messages older than the TTL (5 min) are discarded.
|
|
59
|
-
*/
|
|
60
|
-
poll(agentId: string): BusMessage[] {
|
|
61
|
-
const queue = this.queues.get(agentId)
|
|
62
|
-
if (!queue || queue.length === 0) return []
|
|
63
|
-
|
|
64
|
-
const now = Date.now()
|
|
65
|
-
const valid = queue.filter((msg) => now - msg.timestamp < this.ttlMs)
|
|
66
|
-
|
|
67
|
-
this.queues.delete(agentId)
|
|
68
|
-
return valid
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Send a message to every agent in a session except the one specified.
|
|
73
|
-
* The sender is recorded as 'system'.
|
|
74
|
-
*/
|
|
75
|
-
broadcastToSession(sessionId: string, content: string, excludeAgentId?: string): void {
|
|
76
|
-
const agents = this.sessionAgents.get(sessionId)
|
|
77
|
-
if (!agents) return
|
|
78
|
-
for (const agentId of agents) {
|
|
79
|
-
if (agentId !== excludeAgentId) {
|
|
80
|
-
this.send('system', agentId, content)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
}
|