@miphamai/cli 0.85.3 → 0.85.5

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.
@@ -4,6 +4,7 @@ import TextInput from 'ink-text-input'
4
4
  import { useI18n } from '../i18n-context'
5
5
  import { getCommandList } from './commands.js'
6
6
  import { commandToken, hasInlineArgs } from './command-token.js'
7
+ import { useKeyState } from './use-key-state'
7
8
 
8
9
  interface CommandPickerProps {
9
10
  /** Text already typed (e.g. "/", "/age") — used as initial filter */
@@ -59,7 +60,9 @@ export function CommandPicker({
59
60
  const { t } = useI18n()
60
61
  const allCommands = useMemo(() => getCommandList(), [])
61
62
  const [filter, setFilter] = useState(initialFilter)
62
- const [cursorIdx, setCursorIdx] = useState(0)
63
+ // 光标走 `useKeyState`:一组按键可能在同一拍里到达(↓ 之后紧跟 Enter),确认时
64
+ // 必须读得到本次按键刚写下的那个索引,而不是上一张闭包里的。
65
+ const cursorIdx = useKeyState(0)
63
66
 
64
67
  // Filter commands based on user input (match on the command-name token, not the
65
68
  // full line — so "/loop 60s echo hello" still matches "/loop").
@@ -73,16 +76,16 @@ export function CommandPicker({
73
76
 
74
77
  // Reset cursor when filter changes
75
78
  useEffect(() => {
76
- setCursorIdx(0)
77
- }, [filter])
79
+ cursorIdx.set(0)
80
+ }, [filter, cursorIdx])
78
81
 
79
82
  // Scroll window: keep cursor in the visible range
80
83
  const scrollStart = Math.max(
81
84
  0,
82
- Math.min(cursorIdx - Math.floor(maxVisible / 2), filtered.length - maxVisible),
85
+ Math.min(cursorIdx.value - Math.floor(maxVisible / 2), filtered.length - maxVisible),
83
86
  )
84
87
  const visible = filtered.slice(scrollStart, scrollStart + maxVisible)
85
- const _adjustedCursor = cursorIdx - scrollStart
88
+ const _adjustedCursor = cursorIdx.value - scrollStart
86
89
 
87
90
  // Wrap cursor safely
88
91
  const safeCursor = (i: number) =>
@@ -97,7 +100,7 @@ export function CommandPicker({
97
100
  onSelect(trimmed)
98
101
  return
99
102
  }
100
- const selected = filtered[cursorIdx]
103
+ const selected = filtered[cursorIdx.read()]
101
104
  if (selected) onSelect(selected.name)
102
105
  }
103
106
 
@@ -113,12 +116,12 @@ export function CommandPicker({
113
116
  }
114
117
 
115
118
  if (key.upArrow) {
116
- setCursorIdx((prev) => safeCursor(prev - 1))
119
+ cursorIdx.set((prev) => safeCursor(prev - 1))
117
120
  return
118
121
  }
119
122
 
120
123
  if (key.downArrow) {
121
- setCursorIdx((prev) => safeCursor(prev + 1))
124
+ cursorIdx.set((prev) => safeCursor(prev + 1))
122
125
  return
123
126
  }
124
127
  })
@@ -143,7 +146,7 @@ export function CommandPicker({
143
146
  )}
144
147
  {visible.map((cmd, i) => {
145
148
  const globalIdx = i + scrollStart
146
- const isCursor = globalIdx === cursorIdx
149
+ const isCursor = globalIdx === cursorIdx.value
147
150
  const query = commandToken(filter)
148
151
  const segments = highlightMatches(cmd.name, query)
149
152
  const padLen = Math.max(0, 20 - cmd.name.length)
@@ -180,10 +183,15 @@ export function CommandPicker({
180
183
  {/* Filter input */}
181
184
  <Box>
182
185
  <Text color="cyan">/ </Text>
186
+ {/*
187
+ Enter 由本组件自己的 `useInput` 处理(那里才知道光标停在哪一行、要不要走
188
+ 内联参数那条路)。**不要再给 TextInput 挂 `onSubmit`** —— 它会成为第二个
189
+ 监听者,一次 Enter 变成两次提交,而 `onSelect` 的调用点接的是「提交这条
190
+ 命令」:多出来的一遍就是命令跑两次。
191
+ */}
183
192
  <TextInput
184
193
  value={filter.startsWith('/') ? filter.slice(1) : filter}
185
194
  onChange={(val) => setFilter(`/${val}`)}
186
- onSubmit={() => submitSelection()}
187
195
  placeholder={t('ui.command_picker.placeholder')}
188
196
  />
189
197
  </Box>
@@ -4593,7 +4593,7 @@ const workflowsCmd: CommandHandler = async () => {
4593
4593
  } else {
4594
4594
  lines.push('')
4595
4595
  lines.push(`${found} workflow(s) found.`)
4596
- lines.push('Use /workflows <name> to run a specific workflow.')
4596
+ lines.push('Use /workflow run <name> to run one.')
4597
4597
  }
4598
4598
 
4599
4599
  return { content: lines.join('\n') }
@@ -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
- const [cursor, setCursor] = useState(0)
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
- setCursor(0)
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) setCursor((c) => (c === 0 ? 1 : 0))
269
- if (key.downArrow) setCursor((c) => (c === 0 ? 1 : 0))
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) setCursor((c) => (c > 0 ? c - 1 : providerList.length - 1))
288
- if (key.downArrow) setCursor((c) => (c < providerList.length - 1 ? c + 1 : 0))
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) setCursor((c) => (c > 0 ? c - 1 : modelList.length - 1))
303
- if (key.downArrow) setCursor((c) => (c < modelList.length - 1 ? c + 1 : 0))
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/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
- const [providerIdx, setProviderIdx] = useState(() => {
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 [modelIdx, setModelIdx] = useState(0)
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
- const wrapped = ((idx % providers.length) + providers.length) % providers.length
45
- setProviderIdx(wrapped)
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
- const selectModel = useCallback(
53
- (idx: number) => {
59
+ /** 相对移动 —— 按 `read()` 叠加,同一拍里的两次 ↓ 就是两步。 */
60
+ const stepModel = useCallback(
61
+ (delta: number) => {
54
62
  if (!selectedProvider) return
55
- const wrapped = ((idx % models.length) + models.length) % models.length
56
- setModelIdx(wrapped)
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
- if (!selectedProvider) return
63
- const model = models[modelIdx]
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 = selectedProvider.apiKey
77
+ const apiKey = provider.apiKey
67
78
  if (!apiKey || apiKey.trim() === '' || /^\$\{[A-Z_]+\}$/.test(apiKey.trim())) {
68
- if (selectedProvider.id !== 'ollama' && onNeedsApiKey) {
69
- onNeedsApiKey(selectedProvider.id, model.id, selectedProvider.name)
79
+ if (provider.id !== 'ollama' && onNeedsApiKey) {
80
+ onNeedsApiKey(provider.id, model.id, provider.name)
70
81
  return
71
82
  }
72
83
  }
73
- onSelect(selectedProvider.id, model.id)
84
+ onSelect(provider.id, model.id)
74
85
  }
75
- }, [selectedProvider, models, modelIdx, onSelect, onNeedsApiKey])
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
- setProviderIdx((providerIdx - 1 + providers.length) % providers.length)
119
+ providerIdx.set((prev) => wrap(prev - 1, providers.length))
109
120
  } else {
110
- selectModel(modelIdx - 1)
121
+ stepModel(-1)
111
122
  }
112
123
  return
113
124
  }
114
125
 
115
126
  if (key.downArrow) {
116
127
  if (activePanel === 'provider') {
117
- setProviderIdx((providerIdx + 1) % providers.length)
128
+ providerIdx.set((prev) => wrap(prev + 1, providers.length))
118
129
  } else {
119
- selectModel(modelIdx + 1)
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
- }