@miphamai/cli 0.46.0 → 0.48.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.46.0",
3
+ "version": "0.48.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -41,6 +41,7 @@
41
41
  "test": "vitest run"
42
42
  },
43
43
  "dependencies": {
44
+ "@larksuiteoapi/node-sdk": "^1.73.0",
44
45
  "commander": "^13.1.0",
45
46
  "ink": "^5.2.1",
46
47
  "ink-text-input": "^6.0.0",
@@ -102,10 +102,12 @@ export function createAgentContext(
102
102
  contextWindow?: number,
103
103
  crsiConfig?: Partial<CrsiConfig>,
104
104
  ): AgentContextResult {
105
- // Create isolated context
105
+ // Create isolated context, sized to the resolved model's window when known.
106
+ // Passing contextWindow (not just maxTokens) enables adaptive thresholds.
106
107
  const context = new ContextManager({
107
108
  maxTokens: contextWindow || 100_000,
108
109
  compactionThreshold: 0.85,
110
+ contextWindow,
109
111
  })
110
112
 
111
113
  // Build system prompt with optional agent memory
@@ -254,7 +254,35 @@ export class SubAgent {
254
254
  const systemPrompt =
255
255
  agentDef?.systemPrompt || options.systemPrompt || TYPE_SYSTEM_PROMPTS[agentType]
256
256
 
257
- // Create isolated context with tool scoping
257
+ // Resolve the sub-agent's model first, so its context window is known
258
+ // before the isolated context is created (model-aware sizing).
259
+ const modelToUse = options.modelOverride || agentDef?.model || model
260
+ // 'inherit' means use parent model
261
+ const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
262
+
263
+ // Validate resolved model exists in registry; fall back to parent model with warning
264
+ let finalModel = resolvedModel
265
+ if (resolvedModel !== model) {
266
+ const modelExists = this.registry.findModel(resolvedModel) !== undefined
267
+ if (!modelExists) {
268
+ const warnMsg = `Warning: model "${resolvedModel}" not found in provider registry. Falling back to "${model}".`
269
+ console.warn(warnMsg)
270
+
271
+ // Post warning to message bus for UI display
272
+ const bus = getMessageBus()
273
+ bus.post(
274
+ 'system',
275
+ 'main',
276
+ `Sub-agent model fallback: ${resolvedModel} → ${model}`,
277
+ warnMsg,
278
+ 'warning',
279
+ )
280
+
281
+ finalModel = model
282
+ }
283
+ }
284
+
285
+ // Create isolated context with tool scoping, sized to the resolved model.
258
286
  const resolvedDef: AgentDefinition = agentDef || {
259
287
  name: agentType,
260
288
  description: '',
@@ -264,10 +292,11 @@ export class SubAgent {
264
292
  background: false,
265
293
  source: 'builtin',
266
294
  }
295
+ const contextWindow = this.registry.findModel(finalModel)?.contextWindow
267
296
  const { context, allowedTools } = createAgentContext(
268
297
  resolvedDef,
269
298
  this.toolRegistry,
270
- options.maxContextMessages,
299
+ contextWindow,
271
300
  )
272
301
 
273
302
  context.setSystemPrompt(systemPrompt)
@@ -291,32 +320,6 @@ export class SubAgent {
291
320
  }))
292
321
  : undefined
293
322
 
294
- const modelToUse = options.modelOverride || agentDef?.model || model
295
- // 'inherit' means use parent model
296
- const resolvedModel = modelToUse === 'inherit' ? model : modelToUse
297
-
298
- // Validate resolved model exists in registry; fall back to parent model with warning
299
- let finalModel = resolvedModel
300
- if (resolvedModel !== model) {
301
- const modelExists = this.registry.findModel(resolvedModel) !== undefined
302
- if (!modelExists) {
303
- const warnMsg = `Warning: model "${resolvedModel}" not found in provider registry. Falling back to "${model}".`
304
- console.warn(warnMsg)
305
-
306
- // Post warning to message bus for UI display
307
- const bus = getMessageBus()
308
- bus.post(
309
- 'system',
310
- 'main',
311
- `Sub-agent model fallback: ${resolvedModel} → ${model}`,
312
- warnMsg,
313
- 'warning',
314
- )
315
-
316
- finalModel = model
317
- }
318
- }
319
-
320
323
  const chunks: string[] = []
321
324
  const MAX_TOOL_TURNS = options.maxTurns || 5
322
325
 
@@ -39,7 +39,6 @@ export interface SubAgentOptions {
39
39
  type?: SubAgentType
40
40
  agentDef?: AgentDefinition
41
41
  systemPrompt?: string
42
- maxContextMessages?: number
43
42
  allowedTools?: string[]
44
43
  modelOverride?: string
45
44
  /** Maximum tool-calling turns (default: 5) to prevent infinite loops. */
@@ -1431,7 +1431,7 @@ export class QueryEngine {
1431
1431
  const DISABLE_1M = process.env.MIPHAM_DISABLE_1M_CONTEXT === '1'
1432
1432
  const maxTokens =
1433
1433
  DISABLE_1M && model.contextWindow > 200_000 ? 200_000 : model.contextWindow
1434
- this.context.updateMaxTokens(maxTokens)
1434
+ this.context.updateMaxTokens(maxTokens, model.contextWindow)
1435
1435
  }
1436
1436
  }
1437
1437
  }
@@ -0,0 +1,79 @@
1
+ import { createFeishuApi } from './api.js'
2
+ import { createFeishuEventDispatcher } from './events.js'
3
+ import type { FeishuConfig, FeishuTextMessage } from './types.js'
4
+ import type { SessionManager } from '../session-manager'
5
+ import type { SessionWorker } from '../session-worker'
6
+ import type { RateLimiter } from '../rate-limiter'
7
+
8
+ export interface FeishuAdapterDeps {
9
+ sm: SessionManager
10
+ getOrCreateWorker: (sessionId: string) => SessionWorker | null
11
+ rateLimiter: RateLimiter
12
+ cwd: string
13
+ provider: string
14
+ model: string
15
+ }
16
+
17
+ export interface FeishuAdapter {
18
+ handleEvent(request: Request): Promise<Response>
19
+ isAllowed(openId: string): boolean
20
+ }
21
+
22
+ export function createFeishuAdapter(config: FeishuConfig, deps: FeishuAdapterDeps): FeishuAdapter {
23
+ const api = createFeishuApi(config)
24
+ const allowed = new Set(config.allowedOpenIds)
25
+
26
+ const onMessage = async (msg: FeishuTextMessage) => {
27
+ try {
28
+ if (!allowed.has(msg.openId)) return
29
+ if (!deps.rateLimiter.check(`feishu:${msg.openId}`).allowed) return
30
+
31
+ const session = deps.sm.getOrCreateByFeishuOpenId(
32
+ msg.openId,
33
+ deps.cwd,
34
+ deps.provider,
35
+ deps.model,
36
+ )
37
+ const worker = deps.getOrCreateWorker(session.id)
38
+ if (!worker) {
39
+ await api.sendText(msg.openId, '(会话初始化失败,请稍后重试)')
40
+ return
41
+ }
42
+ await worker.processPrompt(msg.text)
43
+ const result = worker.getLastAssistantContent()
44
+ await api.sendText(msg.openId, result ? result.slice(0, 4000) : '(无回复)')
45
+ } catch (err) {
46
+ console.error('[feishu] message handling failed:', err)
47
+ try {
48
+ await api.sendText(msg.openId, '(处理失败,请稍后重试)')
49
+ } catch {
50
+ /* 忽略回送失败,确保不 rethrow → Feishu 不重试 */
51
+ }
52
+ }
53
+ }
54
+
55
+ const dispatcher = createFeishuEventDispatcher(config, onMessage)
56
+
57
+ return {
58
+ isAllowed: (openId) => allowed.has(openId),
59
+ async handleEvent(request: Request): Promise<Response> {
60
+ let body: unknown = {}
61
+ try {
62
+ body = await request.json()
63
+ } catch {
64
+ /* 非 JSON body 容忍 */
65
+ }
66
+ // URL 验证:回显 challenge(未加密)
67
+ if (body && typeof body === 'object' && 'challenge' in (body as object)) {
68
+ return Response.json({ challenge: (body as { challenge: string }).challenge })
69
+ }
70
+ const headers: Record<string, string> = {}
71
+ request.headers.forEach((v, k) => (headers[k] = v))
72
+ const result = await dispatcher.invoke(body, headers)
73
+ if (!result.ok) {
74
+ return Response.json({ code: 1, msg: result.reason }, { status: 400 })
75
+ }
76
+ return Response.json({ code: 0 })
77
+ },
78
+ }
79
+ }
@@ -0,0 +1,23 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk'
2
+ import type { FeishuConfig } from './types.js'
3
+
4
+ export interface FeishuApi {
5
+ sendText(openId: string, text: string): Promise<void>
6
+ }
7
+
8
+ export function createFeishuApi(config: FeishuConfig): FeishuApi {
9
+ const client = new lark.Client({
10
+ appId: config.appId,
11
+ appSecret: config.appSecret,
12
+ appType: lark.AppType.SelfBuild,
13
+ domain: lark.Domain.Feishu,
14
+ })
15
+ return {
16
+ async sendText(openId: string, text: string): Promise<void> {
17
+ await client.im.message.create({
18
+ params: { receive_id_type: 'open_id' },
19
+ data: { receive_id: openId, msg_type: 'text', content: JSON.stringify({ text }) },
20
+ })
21
+ },
22
+ }
23
+ }
@@ -0,0 +1,22 @@
1
+ import type { FeishuConfig } from './types.js'
2
+
3
+ export function parseFeishuEnv(): FeishuConfig | null {
4
+ const appId = process.env.FEISHU_APP_ID
5
+ const appSecret = process.env.FEISHU_APP_SECRET
6
+ const encryptKey = process.env.FEISHU_ENCRYPT_KEY || ''
7
+ const verificationToken = process.env.FEISHU_VERIFICATION_TOKEN || ''
8
+ // Fail-closed:encryptKey 必填。lark SDK 对 im.message.receive_v1 事件流的签名验证
9
+ // 只依赖 encryptKey(checkIsEventValidated 在空 key 时静默返回 true);
10
+ // verificationToken 仅用于 URL challenge 握手,不保护事件流,故保持可选。
11
+ if (!appId || !appSecret || !encryptKey) return null
12
+ return {
13
+ appId,
14
+ appSecret,
15
+ encryptKey,
16
+ verificationToken,
17
+ allowedOpenIds: (process.env.FEISHU_ALLOWED_OPEN_IDS || '')
18
+ .split(',')
19
+ .map((s) => s.trim())
20
+ .filter(Boolean),
21
+ }
22
+ }
@@ -0,0 +1,53 @@
1
+ import * as lark from '@larksuiteoapi/node-sdk'
2
+ import type { FeishuConfig, FeishuTextMessage } from './types.js'
3
+
4
+ export type OnFeishuMessage = (msg: FeishuTextMessage) => Promise<void>
5
+
6
+ export type FeishuInvokeResult =
7
+ { ok: true } | { ok: false; reason: 'invalid_signature' | 'no_handler' | 'parse_failed' }
8
+
9
+ export interface FeishuEventDispatcher {
10
+ invoke(body: unknown, headers: Record<string, string>): Promise<FeishuInvokeResult>
11
+ }
12
+
13
+ export function createFeishuEventDispatcher(
14
+ config: FeishuConfig,
15
+ onMessage: OnFeishuMessage,
16
+ ): FeishuEventDispatcher {
17
+ const dispatcher = new lark.EventDispatcher({
18
+ encryptKey: config.encryptKey,
19
+ verificationToken: config.verificationToken,
20
+ }).register({
21
+ 'im.message.receive_v1': async (data: any): Promise<true> => {
22
+ const message = data?.message
23
+ if (message?.message_type !== 'text') return true
24
+ let text = ''
25
+ try {
26
+ text = JSON.parse(message.content).text ?? ''
27
+ } catch {
28
+ return true
29
+ }
30
+ const openId = data?.sender?.sender_id?.open_id
31
+ if (!openId || !text) return true
32
+ await onMessage({ chatId: message.chat_id, messageId: message.message_id, openId, text })
33
+ return true
34
+ },
35
+ })
36
+
37
+ return {
38
+ async invoke(body, headers): Promise<FeishuInvokeResult> {
39
+ const assigned = Object.assign(Object.create({ headers }), body)
40
+ let raw: unknown
41
+ try {
42
+ raw = await dispatcher.invoke(assigned)
43
+ } catch {
44
+ return { ok: false, reason: 'parse_failed' }
45
+ }
46
+ if (raw === undefined) return { ok: false, reason: 'invalid_signature' }
47
+ if (typeof raw === 'string' && raw.startsWith('no ')) {
48
+ return { ok: false, reason: 'no_handler' }
49
+ }
50
+ return { ok: true }
51
+ },
52
+ }
53
+ }
@@ -0,0 +1,14 @@
1
+ export interface FeishuConfig {
2
+ appId: string
3
+ appSecret: string
4
+ encryptKey: string
5
+ verificationToken: string
6
+ allowedOpenIds: string[]
7
+ }
8
+
9
+ export interface FeishuTextMessage {
10
+ chatId: string
11
+ messageId: string
12
+ openId: string
13
+ text: string
14
+ }
@@ -23,6 +23,9 @@ import { RateLimiter } from './rate-limiter'
23
23
  import { PACKAGE_VERSION } from '../shared/package-info'
24
24
  import type { DaemonStatus } from './types'
25
25
  import { logger } from './logger'
26
+ import { parseFeishuEnv } from './feishu/env.js'
27
+ import type { FeishuConfig } from './feishu/types.js'
28
+ import { loadConfig } from '../config/loader'
26
29
 
27
30
  const HOME = homedir()
28
31
  const MIPHAM_HOME = join(HOME, '.mipham')
@@ -163,6 +166,21 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
163
166
  const rateLimiter = new RateLimiter(100, 60_000)
164
167
  activeRateLimiter = rateLimiter
165
168
 
169
+ // Feishu remote-control adapter(env 未配置时跳过;配置则解析 session 默认 provider/model)
170
+ const feishuConfig = parseFeishuEnv()
171
+ let feishu: { config: FeishuConfig; cwd: string; provider: string; model: string } | undefined
172
+ if (feishuConfig) {
173
+ const cfg = loadConfig()
174
+ // 取第一个非 upcoming(即非「待上线」)的 provider 及其首个 model
175
+ const provider = cfg.providers.find((p) => p.status !== 'upcoming') ?? cfg.providers[0]
176
+ feishu = {
177
+ config: feishuConfig,
178
+ cwd: process.env.FEISHU_CWD || process.cwd(),
179
+ provider: provider?.id ?? 'anthropic',
180
+ model: provider?.models?.[0]?.id ?? 'claude-sonnet-5',
181
+ }
182
+ }
183
+
166
184
  // Start HTTP server (Bun.serve starts listening immediately)
167
185
  const server = createServer({
168
186
  db,
@@ -177,6 +195,7 @@ export async function startDaemon(): Promise<{ port: number; token: string }> {
177
195
  goalManager,
178
196
  scheduleManager,
179
197
  rateLimiter,
198
+ feishu,
180
199
  })
181
200
  activeServer = server
182
201
 
@@ -23,6 +23,8 @@ import { createToolRegistry } from '../tools'
23
23
  import { PermissionSystem } from '../core/permission'
24
24
  import type { ProviderRegistry } from '../providers/registry'
25
25
  import type { ToolDefinition, PermissionMode } from '../shared/types'
26
+ import { createFeishuAdapter } from './feishu/adapter.js'
27
+ import type { FeishuConfig } from './feishu/types.js'
26
28
 
27
29
  interface ServerConfig {
28
30
  db: DaemonDatabase
@@ -37,6 +39,7 @@ interface ServerConfig {
37
39
  goalManager: GoalManager
38
40
  scheduleManager: ScheduleManager
39
41
  rateLimiter: RateLimiter
42
+ feishu?: { config: FeishuConfig; cwd: string; provider: string; model: string }
40
43
  }
41
44
 
42
45
  interface WsData {
@@ -64,6 +67,22 @@ function resolveDaemonPermission(): PermissionMode {
64
67
  : 'default'
65
68
  }
66
69
 
70
+ const DAEMON_DEFAULT_CONTEXT_WINDOW = 200_000
71
+
72
+ /**
73
+ * Resolve the effective context window (in tokens) for the daemon session's
74
+ * model. Mirrors the CLI's model-aware context sizing so a 1M-context model
75
+ * isn't silently hard-capped at 200K; falls back when the model isn't in the
76
+ * registry.
77
+ */
78
+ export function resolveContextWindow(
79
+ registry: ProviderRegistry,
80
+ modelId: string,
81
+ fallback = DAEMON_DEFAULT_CONTEXT_WINDOW,
82
+ ): number {
83
+ return registry.findModel(modelId)?.contextWindow ?? fallback
84
+ }
85
+
67
86
  export function createServer(config: ServerConfig): Server<WsData> {
68
87
  const {
69
88
  db,
@@ -78,6 +97,7 @@ export function createServer(config: ServerConfig): Server<WsData> {
78
97
  goalManager,
79
98
  scheduleManager,
80
99
  rateLimiter,
100
+ feishu,
81
101
  } = config
82
102
 
83
103
  const wsClients = new Map<string, Set<ServerWebSocket<WsData>>>()
@@ -116,7 +136,6 @@ export function createServer(config: ServerConfig): Server<WsData> {
116
136
  const engineCache = new Map<string, QueryEngine>()
117
137
  let sharedRegistry: ProviderRegistry | null = null
118
138
  let sharedTools: Map<string, ToolDefinition> | null = null
119
- const DEFAULT_MAX_TOKENS = 200_000
120
139
 
121
140
  function getOrCreateEngine(
122
141
  sessionId: string,
@@ -144,11 +163,13 @@ export function createServer(config: ServerConfig): Server<WsData> {
144
163
  sharedTools = createToolRegistry()
145
164
  }
146
165
 
147
- // Create per-session context, restoring previous messages from DB
166
+ // Create per-session context, restoring previous messages from DB.
167
+ // Size it to the active model's context window (1M-capable models get 1M).
168
+ const contextWindow = resolveContextWindow(sharedRegistry!, model)
148
169
  const context = new ContextManager({
149
- maxTokens: DEFAULT_MAX_TOKENS,
170
+ maxTokens: contextWindow,
150
171
  compactionThreshold: 0.9,
151
- contextWindow: DEFAULT_MAX_TOKENS,
172
+ contextWindow,
152
173
  })
153
174
 
154
175
  const dbMessages = db.getMessages(sessionId, 10000)
@@ -198,6 +219,18 @@ export function createServer(config: ServerConfig): Server<WsData> {
198
219
  }
199
220
  }
200
221
 
222
+ // ── Feishu remote-control adapter(独立签名验证,复用同一 worker 池)──
223
+ const feishuAdapter = feishu
224
+ ? createFeishuAdapter(feishu.config, {
225
+ sm,
226
+ getOrCreateWorker,
227
+ rateLimiter,
228
+ cwd: feishu.cwd,
229
+ provider: feishu.provider,
230
+ model: feishu.model,
231
+ })
232
+ : undefined
233
+
201
234
  const server = Bun.serve<WsData>({
202
235
  port,
203
236
  hostname,
@@ -211,6 +244,11 @@ export function createServer(config: ServerConfig): Server<WsData> {
211
244
  const method = req.method
212
245
  logger.info('request', { method, path })
213
246
 
247
+ // ── Feishu event callback(独立签名验证,不经过 daemon Bearer 鉴权)──
248
+ if (feishuAdapter && method === 'POST' && path === '/feishu/event') {
249
+ return await feishuAdapter.handleEvent(req)
250
+ }
251
+
214
252
  // ── Rate limiting (skip health endpoint) ──────────
215
253
  if (path !== '/api/v1/health') {
216
254
  const ip = server.requestIP(req)?.address || 'unknown'
@@ -23,6 +23,18 @@ export class SessionManager {
23
23
  return this.db.listSessions(status)
24
24
  }
25
25
 
26
+ getOrCreateByFeishuOpenId(
27
+ openId: string,
28
+ cwd: string,
29
+ provider: string,
30
+ model: string,
31
+ ): DaemonSession {
32
+ const name = `feishu-${openId}`
33
+ const existing = this.db.listSessions().find((s) => s.name === name && s.status !== 'closed')
34
+ if (existing) return existing
35
+ return this.db.createSession({ name, cwd, provider, model })
36
+ }
37
+
26
38
  closeSession(id: string): void {
27
39
  this.db.closeSession(id)
28
40
  for (const cb of this.closeCallbacks) {
@@ -253,6 +253,11 @@ export class SessionWorker {
253
253
  return this.session.id
254
254
  }
255
255
 
256
+ /** Get the last assistant text content from the underlying engine. */
257
+ getLastAssistantContent(): string | undefined {
258
+ return this.engine.getLastAssistantContent()
259
+ }
260
+
256
261
  // ── Persistence ────────────────────────────────────────────────────────
257
262
 
258
263
  /**
@@ -718,6 +718,79 @@
718
718
  "shift_tab_cycle": "shift+tab",
719
719
  "left_for_agents": "ctrl+g agents to manage",
720
720
  "mipham_modes": "Mipham"
721
+ },
722
+ "wizard": {
723
+ "header_subtitle": " — Configuration Wizard",
724
+ "welcome_title": "Welcome to Mipham Code!",
725
+ "welcome_intro": "Before you start, you need to configure your AI model connection.",
726
+ "welcome_time": "This wizard takes about 1 minute — please have your API key ready.",
727
+ "welcome_start": "Press Enter to start →",
728
+ "welcome_skip": "Esc to skip",
729
+ "mode_title": "Choose connection method:",
730
+ "mode_cloud": "🌐 Cloud AI models (recommended)",
731
+ "mode_local": "🖥️ Local models (Ollama)",
732
+ "mode_local_hint": " Requires Ollama installed with models downloaded",
733
+ "nav_hint": "↑↓ select · Enter confirm · Esc back",
734
+ "provider_title": "Choose AI model provider:",
735
+ "provider_count": " ({count} models)",
736
+ "model_title": "Choose model ({provider}):",
737
+ "apikey_title": "Paste your API key:",
738
+ "apikey_provider": "Provider: {provider}",
739
+ "apikey_model": "Model: {model}",
740
+ "apikey_placeholder": "Paste API key then press Enter...",
741
+ "apikey_continue": "Enter to continue · Esc to go back to model",
742
+ "ollama_title": "Ollama local model configuration:",
743
+ "ollama_status": "Status: ",
744
+ "ollama_installed": "✅ installed",
745
+ "ollama_not_installed": "❌ not installed",
746
+ "ollama_running": " · running",
747
+ "ollama_not_running": " · not running (run ollama serve)",
748
+ "ollama_detecting": "Detecting Ollama...",
749
+ "ollama_downloaded": "Downloaded models (↑↓ select · Enter confirm):",
750
+ "ollama_source_hot": "Popular",
751
+ "ollama_no_models": "No downloaded models found. Run ollama pull <model> to download one.",
752
+ "ollama_preset_hint": "Below are preset models — switch anytime via /models.",
753
+ "confirm_title": "Confirm configuration:",
754
+ "confirm_connection": "Connection: ",
755
+ "confirm_cloud": "🌐 Cloud",
756
+ "confirm_local": "🖥️ Local",
757
+ "confirm_provider": "Provider: {provider}",
758
+ "confirm_model": "Model: {model}",
759
+ "confirm_apikey": "API key: {masked}",
760
+ "confirm_ollama_model": "Ollama model: {model}",
761
+ "confirm_save_path": "Config will be saved to ~/.mipham/config.yml",
762
+ "confirm_save": "Press Enter to save and launch →",
763
+ "confirm_back": "Esc to go back",
764
+ "done_title": "✅ Configuration complete!",
765
+ "done_launching": "Mipham Code is starting...",
766
+ "error_apikey_required": "Please enter your API key",
767
+ "error_write_failed": "Failed to write config: {err}"
768
+ },
769
+ "goal_progress": {
770
+ "title": "🎯 Goal: {goal}",
771
+ "no_subtasks": " (no subtasks yet)",
772
+ "summary": "═══ {done}/{total} done",
773
+ "in_progress": " · {n} in progress",
774
+ "failed": " · {n} failed",
775
+ "tokens": " · {n} tokens",
776
+ "border_end": " ═══"
777
+ },
778
+ "workflow": {
779
+ "title": "═══ Workflow: {runId} ═══",
780
+ "phase": "Phase: {name}",
781
+ "done_count": " [{done}/{total} done]",
782
+ "footer": "═══ Elapsed: {time} | Agents: {done}/{total}",
783
+ "running": " ({n} running)",
784
+ "failed": " ({n} failed)",
785
+ "cache_hits": " | Cache hits: {n}",
786
+ "done_marker": " | DONE ═══",
787
+ "running_marker": " | Status: running ═══"
788
+ },
789
+ "error_boundary": {
790
+ "title": "⚠️ UI Render Error",
791
+ "unknown": "Unknown rendering error",
792
+ "recovery": "The terminal interface encountered a rendering issue. Auto-recovery in 5 seconds... Press Enter to attempt immediate recovery.",
793
+ "preserved": "Your session data and conversation history are preserved."
721
794
  }
722
795
  },
723
796
  "tools": {
@@ -718,6 +718,79 @@
718
718
  "shift_tab_cycle": "Shift+Tab",
719
719
  "left_for_agents": "ctrl+g 管理代理",
720
720
  "mipham_modes": "Mipham独有"
721
+ },
722
+ "wizard": {
723
+ "header_subtitle": " — 配置向导",
724
+ "welcome_title": "欢迎使用 Mipham Code!",
725
+ "welcome_intro": "在开始之前,需要先配置 AI 模型连接。",
726
+ "welcome_time": "此向导只需 1 分钟,需要您准备好 API Key。",
727
+ "welcome_start": "按 Enter 开始 →",
728
+ "welcome_skip": "Esc 跳过配置",
729
+ "mode_title": "选择模型连接方式:",
730
+ "mode_cloud": "🌐 云端 AI 模型(推荐)",
731
+ "mode_local": "🖥️ 本地模型(Ollama)",
732
+ "mode_local_hint": " 需要提前安装 Ollama 并下载模型",
733
+ "nav_hint": "↑↓ 选择 · Enter 确认 · Esc 返回",
734
+ "provider_title": "选择 AI 模型提供商:",
735
+ "provider_count": " ({count} 个模型)",
736
+ "model_title": "选择模型({provider}):",
737
+ "apikey_title": "粘贴 API Key:",
738
+ "apikey_provider": "提供商:{provider}",
739
+ "apikey_model": "模型:{model}",
740
+ "apikey_placeholder": "粘贴 API Key 后按 Enter...",
741
+ "apikey_continue": "Enter 继续 · Esc 返回选模型",
742
+ "ollama_title": "Ollama 本地模型配置:",
743
+ "ollama_status": "状态:",
744
+ "ollama_installed": "✅ 已安装",
745
+ "ollama_not_installed": "❌ 未安装",
746
+ "ollama_running": " · 运行中",
747
+ "ollama_not_running": " · 未运行(请执行 ollama serve)",
748
+ "ollama_detecting": "正在检测 Ollama...",
749
+ "ollama_downloaded": "已下载模型(↑↓ 选择 · Enter 确认):",
750
+ "ollama_source_hot": "热门",
751
+ "ollama_no_models": "未检测到已下载模型。请先运行 ollama pull <model> 下载模型。",
752
+ "ollama_preset_hint": "以下为预置模型列表,选择后可在对话中通过 /models 切换。",
753
+ "confirm_title": "配置确认:",
754
+ "confirm_connection": "连接方式:",
755
+ "confirm_cloud": "🌐 云端",
756
+ "confirm_local": "🖥️ 本地",
757
+ "confirm_provider": "提供商:{provider}",
758
+ "confirm_model": "模型:{model}",
759
+ "confirm_apikey": "API Key:{masked}",
760
+ "confirm_ollama_model": "Ollama 模型:{model}",
761
+ "confirm_save_path": "配置文件将保存到 ~/.mipham/config.yml",
762
+ "confirm_save": "按 Enter 保存并启动 →",
763
+ "confirm_back": "Esc 返回修改",
764
+ "done_title": "✅ 配置完成!",
765
+ "done_launching": "Mipham Code 正在启动...",
766
+ "error_apikey_required": "请填写 API Key",
767
+ "error_write_failed": "写入配置失败: {err}"
768
+ },
769
+ "goal_progress": {
770
+ "title": "🎯 目标:{goal}",
771
+ "no_subtasks": " (暂无子任务)",
772
+ "summary": "═══ {done}/{total} 完成",
773
+ "in_progress": " · {n} 进行中",
774
+ "failed": " · {n} 失败",
775
+ "tokens": " · {n} tokens",
776
+ "border_end": " ═══"
777
+ },
778
+ "workflow": {
779
+ "title": "═══ 工作流:{runId} ═══",
780
+ "phase": "阶段:{name}",
781
+ "done_count": " [{done}/{total} 完成]",
782
+ "footer": "═══ 耗时:{time} | Agent:{done}/{total}",
783
+ "running": " ({n} 运行中)",
784
+ "failed": " ({n} 失败)",
785
+ "cache_hits": " | 缓存命中:{n}",
786
+ "done_marker": " | 完成 ═══",
787
+ "running_marker": " | 状态:运行中 ═══"
788
+ },
789
+ "error_boundary": {
790
+ "title": "⚠️ UI 渲染错误",
791
+ "unknown": "未知渲染错误",
792
+ "recovery": "终端界面遇到渲染问题,5 秒后自动恢复... 按 Enter 尝试立即恢复。",
793
+ "preserved": "你的会话数据和对话历史已保留。"
721
794
  }
722
795
  },
723
796
  "tools": {
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.46.0' as const
12
+ export const PACKAGE_VERSION = '0.48.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -10,6 +10,7 @@
10
10
 
11
11
  import React, { useState, useEffect } from 'react'
12
12
  import { Box, Text, useInput } from 'ink'
13
+ import { useI18n } from '../i18n-context'
13
14
  import TextInput from 'ink-text-input'
14
15
  import { DEFAULT_PROVIDERS, OLLAMA_PRESET_MODELS } from '../shared/constants'
15
16
  import type { ProviderConfig, ModelInfo } from '../shared/types'
@@ -167,6 +168,7 @@ function getOllamaModelList(installedModels: string[]): OllamaModelItem[] {
167
168
  // ── Component ──
168
169
 
169
170
  export function ConfigWizard({ onComplete, onSkip }: Props) {
171
+ const { t } = useI18n()
170
172
  const [step, setStep] = useState<Step>('welcome')
171
173
 
172
174
  // Selections
@@ -202,14 +204,14 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
202
204
 
203
205
  const finishCloud = () => {
204
206
  if (!providerId || !modelId || !apiKey.trim()) {
205
- setError('请填写 API Key')
207
+ setError(t('ui.wizard.error_apikey_required'))
206
208
  return
207
209
  }
208
210
  try {
209
211
  writeConfigFile(providerId, modelId, apiKey.trim())
210
212
  onComplete({ providerId, modelId, apiKey: apiKey.trim() })
211
213
  } catch (err) {
212
- setError(`写入配置失败: ${String(err)}`)
214
+ setError(t('ui.wizard.error_write_failed', { err: String(err) }))
213
215
  }
214
216
  }
215
217
 
@@ -220,7 +222,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
220
222
  writeConfigFile('ollama', model, 'ollama-local')
221
223
  onComplete({ providerId: 'ollama', modelId: model, apiKey: 'ollama-local' })
222
224
  } catch (err) {
223
- setError(`写入配置失败: ${String(err)}`)
225
+ setError(t('ui.wizard.error_write_failed', { err: String(err) }))
224
226
  }
225
227
  }
226
228
 
@@ -366,7 +368,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
366
368
  <Text color="#FFD700" bold>
367
369
  Mipham Code
368
370
  </Text>
369
- <Text dimColor> — 配置向导</Text>
371
+ <Text dimColor>{t('ui.wizard.header_subtitle')}</Text>
370
372
  </Box>
371
373
  <Box marginBottom={1}>
372
374
  <Text dimColor>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━</Text>
@@ -377,16 +379,16 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
377
379
  <Box flexDirection="column">
378
380
  <Box marginBottom={1}>
379
381
  <Text bold color="cyan">
380
- 欢迎使用 Mipham Code!
382
+ {t('ui.wizard.welcome_title')}
381
383
  </Text>
382
384
  </Box>
383
- <Text>在开始之前,需要先配置 AI 模型连接。</Text>
384
- <Text>此向导只需 1 分钟,需要您准备好 API Key。</Text>
385
+ <Text>{t('ui.wizard.welcome_intro')}</Text>
386
+ <Text>{t('ui.wizard.welcome_time')}</Text>
385
387
  <Box marginTop={1}>
386
- <Text color="yellow">按 Enter 开始 →</Text>
388
+ <Text color="yellow">{t('ui.wizard.welcome_start')}</Text>
387
389
  </Box>
388
390
  <Box marginTop={1}>
389
- <Text dimColor>Esc 跳过配置</Text>
391
+ <Text dimColor>{t('ui.wizard.welcome_skip')}</Text>
390
392
  </Box>
391
393
  </Box>
392
394
  )}
@@ -395,25 +397,25 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
395
397
  {step === 'mode' && (
396
398
  <Box flexDirection="column">
397
399
  <Box marginBottom={1}>
398
- <Text bold>选择模型连接方式:</Text>
400
+ <Text bold>{t('ui.wizard.mode_title')}</Text>
399
401
  </Box>
400
402
 
401
403
  <Box marginBottom={1}>
402
404
  <Text color={cursor === 0 ? SELECTED_COLOR : undefined}>
403
- {cursor === 0 ? '▶' : ' '} 🌐 云端 AI 模型(推荐)
405
+ {cursor === 0 ? '▶' : ' '} {t('ui.wizard.mode_cloud')}
404
406
  </Text>
405
407
  </Box>
406
408
  <Text dimColor> Claude · DeepSeek · Gemini · 豆包 · Qwen · 混元 · OpenAI</Text>
407
409
 
408
410
  <Box marginTop={2} marginBottom={1}>
409
411
  <Text color={cursor === 1 ? SELECTED_COLOR : undefined}>
410
- {cursor === 1 ? '▶' : ' '} 🖥️ 本地模型(Ollama)
412
+ {cursor === 1 ? '▶' : ' '} {t('ui.wizard.mode_local')}
411
413
  </Text>
412
414
  </Box>
413
- <Text dimColor> 需要提前安装 Ollama 并下载模型</Text>
415
+ <Text dimColor>{t('ui.wizard.mode_local_hint')}</Text>
414
416
 
415
417
  <Box marginTop={2}>
416
- <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
418
+ <Text dimColor>{t('ui.wizard.nav_hint')}</Text>
417
419
  </Box>
418
420
  </Box>
419
421
  )}
@@ -422,18 +424,22 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
422
424
  {step === 'provider' && (
423
425
  <Box flexDirection="column">
424
426
  <Box marginBottom={1}>
425
- <Text bold>选择 AI 模型提供商:</Text>
427
+ <Text bold>{t('ui.wizard.provider_title')}</Text>
426
428
  </Box>
427
429
  {providerList.map((p, i) => (
428
430
  <Box key={p.id} marginBottom={1}>
429
431
  <Text color={i === cursor ? SELECTED_COLOR : undefined}>
430
432
  {i === cursor ? '▶' : ' '} {p.name}
431
433
  </Text>
432
- <Text dimColor> ({p.models.filter((m) => m.status === 'active').length} 个模型)</Text>
434
+ <Text dimColor>
435
+ {t('ui.wizard.provider_count', {
436
+ count: String(p.models.filter((m) => m.status === 'active').length),
437
+ })}
438
+ </Text>
433
439
  </Box>
434
440
  ))}
435
441
  <Box marginTop={1}>
436
- <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
442
+ <Text dimColor>{t('ui.wizard.nav_hint')}</Text>
437
443
  </Box>
438
444
  </Box>
439
445
  )}
@@ -443,7 +449,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
443
449
  <Box flexDirection="column">
444
450
  <Box marginBottom={1}>
445
451
  <Text bold>
446
- 选择模型({DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || providerId}):
452
+ {t('ui.wizard.model_title', {
453
+ provider:
454
+ DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || providerId || '',
455
+ })}
447
456
  </Text>
448
457
  </Box>
449
458
  {modelList.map((m, i) => (
@@ -455,7 +464,7 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
455
464
  </Box>
456
465
  ))}
457
466
  <Box marginTop={1}>
458
- <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
467
+ <Text dimColor>{t('ui.wizard.nav_hint')}</Text>
459
468
  </Box>
460
469
  </Box>
461
470
  )}
@@ -464,11 +473,17 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
464
473
  {step === 'apikey' && (
465
474
  <Box flexDirection="column">
466
475
  <Box marginBottom={1}>
467
- <Text bold>粘贴 API Key:</Text>
476
+ <Text bold>{t('ui.wizard.apikey_title')}</Text>
468
477
  </Box>
469
- <Text dimColor>提供商:{DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name}</Text>
470
478
  <Text dimColor>
471
- 模型:{getActiveModels(providerId!).find((m) => m.id === modelId)?.name}
479
+ {t('ui.wizard.apikey_provider', {
480
+ provider: DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || '',
481
+ })}
482
+ </Text>
483
+ <Text dimColor>
484
+ {t('ui.wizard.apikey_model', {
485
+ model: getActiveModels(providerId!).find((m) => m.id === modelId)?.name || '',
486
+ })}
472
487
  </Text>
473
488
  {error && (
474
489
  <Box marginBottom={1}>
@@ -481,11 +496,11 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
481
496
  value={apiKey}
482
497
  onChange={setApiKey}
483
498
  onSubmit={() => goStep('confirm')}
484
- placeholder="粘贴 API Key 后按 Enter..."
499
+ placeholder={t('ui.wizard.apikey_placeholder')}
485
500
  />
486
501
  </Box>
487
502
  <Box marginTop={1}>
488
- <Text dimColor>Enter 继续 · Esc 返回选模型</Text>
503
+ <Text dimColor>{t('ui.wizard.apikey_continue')}</Text>
489
504
  </Box>
490
505
  </Box>
491
506
  )}
@@ -494,26 +509,29 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
494
509
  {step === 'ollama' && (
495
510
  <Box flexDirection="column">
496
511
  <Box marginBottom={1}>
497
- <Text bold>Ollama 本地模型配置:</Text>
512
+ <Text bold>{t('ui.wizard.ollama_title')}</Text>
498
513
  </Box>
499
514
  {ollamaStatus ? (
500
515
  <Box flexDirection="column" marginBottom={1}>
501
516
  <Text>
502
- 状态:{ollamaStatus.installed ? '✅ 已安装' : '❌ 未安装'}
517
+ {t('ui.wizard.ollama_status')}
518
+ {ollamaStatus.installed
519
+ ? t('ui.wizard.ollama_installed')
520
+ : t('ui.wizard.ollama_not_installed')}
503
521
  {ollamaStatus.running
504
- ? ' · 运行中'
522
+ ? t('ui.wizard.ollama_running')
505
523
  : ollamaStatus.installed
506
- ? ' · 未运行(请执行 ollama serve)'
524
+ ? t('ui.wizard.ollama_not_running')
507
525
  : ''}
508
526
  </Text>
509
527
  </Box>
510
528
  ) : (
511
- <Text dimColor>正在检测 Ollama...</Text>
529
+ <Text dimColor>{t('ui.wizard.ollama_detecting')}</Text>
512
530
  )}
513
531
  {ollamaModelList.length > 0 && (
514
532
  <Box flexDirection="column" marginBottom={1}>
515
533
  <Box marginBottom={1}>
516
- <Text dimColor>已下载模型(↑↓ 选择 · Enter 确认):</Text>
534
+ <Text dimColor>{t('ui.wizard.ollama_downloaded')}</Text>
517
535
  </Box>
518
536
  {ollamaModelList.map((m, i) => (
519
537
  <Box key={m.id}>
@@ -521,7 +539,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
521
539
  {i === ollamaCursor ? '▶' : ' '} {m.id}
522
540
  </Text>
523
541
  {m.source !== 'local' && (
524
- <Text dimColor> [{m.source === 'MiphamAI' ? 'MiphamAI' : '热门'}]</Text>
542
+ <Text dimColor>
543
+ {' '}
544
+ [{m.source === 'MiphamAI' ? 'MiphamAI' : t('ui.wizard.ollama_source_hot')}]
545
+ </Text>
525
546
  )}
526
547
  </Box>
527
548
  ))}
@@ -529,14 +550,12 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
529
550
  )}
530
551
  {ollamaStatus && ollamaModelList.length === 0 && (
531
552
  <Box marginBottom={1}>
532
- <Text dimColor>
533
- 未检测到已下载模型。请先运行 ollama pull &lt;model&gt; 下载模型。
534
- </Text>
535
- <Text dimColor>以下为预置模型列表,选择后可在对话中通过 /models 切换。</Text>
553
+ <Text dimColor>{t('ui.wizard.ollama_no_models')}</Text>
554
+ <Text dimColor>{t('ui.wizard.ollama_preset_hint')}</Text>
536
555
  </Box>
537
556
  )}
538
557
  <Box marginTop={1}>
539
- <Text dimColor>↑↓ 选择 · Enter 确认 · Esc 返回</Text>
558
+ <Text dimColor>{t('ui.wizard.nav_hint')}</Text>
540
559
  </Box>
541
560
  </Box>
542
561
  )}
@@ -546,26 +565,37 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
546
565
  <Box flexDirection="column">
547
566
  <Box marginBottom={1}>
548
567
  <Text bold color="cyan">
549
- 配置确认:
568
+ {t('ui.wizard.confirm_title')}
550
569
  </Text>
551
570
  </Box>
552
- <Text>连接方式:{mode === 'cloud' ? '🌐 云端' : '🖥️ 本地'}</Text>
571
+ <Text>
572
+ {t('ui.wizard.confirm_connection')}
573
+ {mode === 'cloud' ? t('ui.wizard.confirm_cloud') : t('ui.wizard.confirm_local')}
574
+ </Text>
553
575
  {mode === 'cloud' && (
554
576
  <>
555
- <Text>提供商:{DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name}</Text>
556
- <Text>模型:{modelId}</Text>
557
577
  <Text>
558
- API Key:{apiKey.slice(0, 8)}...{apiKey.slice(-4)}
578
+ {t('ui.wizard.confirm_provider', {
579
+ provider: DEFAULT_PROVIDERS.find((p) => p.id === providerId)?.name || '',
580
+ })}
581
+ </Text>
582
+ <Text>{t('ui.wizard.confirm_model', { model: modelId || '' })}</Text>
583
+ <Text>
584
+ {t('ui.wizard.confirm_apikey', {
585
+ masked: `${apiKey.slice(0, 8)}...${apiKey.slice(-4)}`,
586
+ })}
559
587
  </Text>
560
588
  </>
561
589
  )}
562
590
  {mode === 'local' && (
563
591
  <Text>
564
- Ollama 模型:{ollamaModelList[ollamaCursor]?.id || ollamaModel.trim() || 'llama3.2'}
592
+ {t('ui.wizard.confirm_ollama_model', {
593
+ model: ollamaModelList[ollamaCursor]?.id || ollamaModel.trim() || 'llama3.2',
594
+ })}
565
595
  </Text>
566
596
  )}
567
597
  <Box marginTop={1}>
568
- <Text dimColor>配置文件将保存到 ~/.mipham/config.yml</Text>
598
+ <Text dimColor>{t('ui.wizard.confirm_save_path')}</Text>
569
599
  </Box>
570
600
  {error && (
571
601
  <Box marginBottom={1}>
@@ -573,10 +603,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
573
603
  </Box>
574
604
  )}
575
605
  <Box marginTop={1}>
576
- <Text color="yellow">按 Enter 保存并启动 →</Text>
606
+ <Text color="yellow">{t('ui.wizard.confirm_save')}</Text>
577
607
  </Box>
578
608
  <Box marginTop={1}>
579
- <Text dimColor>Esc 返回修改</Text>
609
+ <Text dimColor>{t('ui.wizard.confirm_back')}</Text>
580
610
  </Box>
581
611
  </Box>
582
612
  )}
@@ -586,10 +616,10 @@ export function ConfigWizard({ onComplete, onSkip }: Props) {
586
616
  <Box flexDirection="column">
587
617
  <Box marginBottom={1}>
588
618
  <Text color="green" bold>
589
- ✅ 配置完成!
619
+ {t('ui.wizard.done_title')}
590
620
  </Text>
591
621
  </Box>
592
- <Text>Mipham Code 正在启动...</Text>
622
+ <Text>{t('ui.wizard.done_launching')}</Text>
593
623
  </Box>
594
624
  )}
595
625
  </Box>
@@ -11,6 +11,7 @@
11
11
 
12
12
  import React, { Component } from 'react'
13
13
  import { Text, Box } from 'ink'
14
+ import { useI18n } from '../i18n-context'
14
15
 
15
16
  interface Props {
16
17
  children: React.ReactNode
@@ -63,23 +64,25 @@ export class ErrorBoundary extends Component<Props, State> {
63
64
 
64
65
  override render(): React.ReactNode {
65
66
  if (this.state.hasError) {
66
- return (
67
- <Box flexDirection="column" padding={1}>
68
- <Text color="red" bold>
69
- ⚠️ UI Render Error
70
- </Text>
71
- <Text dimColor>{this.state.error?.message || 'Unknown rendering error'}</Text>
72
- <Text> </Text>
73
- <Text dimColor>
74
- The terminal interface encountered a rendering issue. Auto-recovery in 5 seconds...
75
- Press Enter to attempt immediate recovery.
76
- </Text>
77
- <Text> </Text>
78
- <Text color="yellow">Your session data and conversation history are preserved.</Text>
79
- </Box>
80
- )
67
+ return <ErrorFallback error={this.state.error} />
81
68
  }
82
69
 
83
70
  return this.props.children
84
71
  }
85
72
  }
73
+
74
+ function ErrorFallback({ error }: { error: Error | null }): React.ReactNode {
75
+ const { t } = useI18n()
76
+ return (
77
+ <Box flexDirection="column" padding={1}>
78
+ <Text color="red" bold>
79
+ {t('ui.error_boundary.title')}
80
+ </Text>
81
+ <Text dimColor>{error?.message || t('ui.error_boundary.unknown')}</Text>
82
+ <Text> </Text>
83
+ <Text dimColor>{t('ui.error_boundary.recovery')}</Text>
84
+ <Text> </Text>
85
+ <Text color="yellow">{t('ui.error_boundary.preserved')}</Text>
86
+ </Box>
87
+ )
88
+ }
@@ -1,5 +1,6 @@
1
1
  import { useState, useEffect, useRef } from 'react'
2
2
  import { Box, Text } from 'ink'
3
+ import { useI18n } from '../i18n-context'
3
4
  import { getTasks } from '../tools/exec/task.js'
4
5
  import type { Task } from '../tools/exec/task.js'
5
6
 
@@ -61,6 +62,7 @@ function taskIcon(status: Task['status']): { icon: string; color?: string; dim?:
61
62
 
62
63
  /** Live goal progress panel — mirrors ZCode's goal view: title + subtasks + done/total · elapsed · tokens. */
63
64
  export function GoalProgress({ goal, getTokens, listTasks }: GoalProgressProps) {
65
+ const { t } = useI18n()
64
66
  const [tasks, setTasks] = useState<Task[]>([])
65
67
  const [tokens, setTokens] = useState(0)
66
68
  const [elapsedMs, setElapsedMs] = useState(0)
@@ -103,11 +105,11 @@ export function GoalProgress({ goal, getTokens, listTasks }: GoalProgressProps)
103
105
  marginBottom={1}
104
106
  >
105
107
  <Text bold color="green">
106
- 🎯 Goal: {goal}
108
+ {t('ui.goal_progress.title', { goal })}
107
109
  </Text>
108
110
 
109
111
  {visibleTasks.length === 0 ? (
110
- <Text dimColor> (no subtasks yet)</Text>
112
+ <Text dimColor>{t('ui.goal_progress.no_subtasks')}</Text>
111
113
  ) : (
112
114
  visibleTasks.map((t) => {
113
115
  const meta = taskIcon(t.status)
@@ -123,13 +125,17 @@ export function GoalProgress({ goal, getTokens, listTasks }: GoalProgressProps)
123
125
 
124
126
  <Box marginTop={1}>
125
127
  <Text dimColor>
126
- ═══ {summary.done}/{summary.total} done
127
- {summary.inProgress > 0 && ` · ${summary.inProgress} in progress`}
128
- {summary.failed > 0 && ` · ${summary.failed} failed`}
128
+ {t('ui.goal_progress.summary', {
129
+ done: String(summary.done),
130
+ total: String(summary.total),
131
+ })}
132
+ {summary.inProgress > 0 &&
133
+ t('ui.goal_progress.in_progress', { n: String(summary.inProgress) })}
134
+ {summary.failed > 0 && t('ui.goal_progress.failed', { n: String(summary.failed) })}
129
135
  {' · '}
130
136
  {formatElapsed(elapsedMs)}
131
- {tokens > 0 && ` · ${formatTokens(tokens)} tokens`}
132
- {' ═══'}
137
+ {tokens > 0 && t('ui.goal_progress.tokens', { n: formatTokens(tokens) })}
138
+ {t('ui.goal_progress.border_end')}
133
139
  </Text>
134
140
  </Box>
135
141
  </Box>
@@ -1,5 +1,6 @@
1
1
  import React, { useState, useEffect, useMemo } from 'react'
2
2
  import { Box, Text } from 'ink'
3
+ import { useI18n } from '../i18n-context'
3
4
  import { getEventBus } from '../workflow/event-bus.js'
4
5
  import type { WorkflowEvent } from '../workflow/event-bus.js'
5
6
 
@@ -16,6 +17,7 @@ interface WorkflowProgressProps {
16
17
  }
17
18
 
18
19
  export function WorkflowProgress({ runId }: WorkflowProgressProps) {
20
+ const { t } = useI18n()
19
21
  const [activeRunId, setActiveRunId] = useState<string | null>(null)
20
22
  const [phases, setPhases] = useState<Array<{ name: string; done: boolean }>>([])
21
23
  const [agents, setAgents] = useState<Map<string, AgentState>>(new Map())
@@ -129,7 +131,7 @@ export function WorkflowProgress({ runId }: WorkflowProgressProps) {
129
131
  <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
130
132
  <Box>
131
133
  <Text bold color="cyan">
132
- ═══ Workflow: {activeRunId || runId} ═══
134
+ {t('ui.workflow.title', { runId: activeRunId ?? runId ?? '' })}
133
135
  </Text>
134
136
  </Box>
135
137
 
@@ -139,9 +141,12 @@ export function WorkflowProgress({ runId }: WorkflowProgressProps) {
139
141
  return (
140
142
  <Box key={i} flexDirection="column" marginTop={1}>
141
143
  <Text>
142
- {phase.done ? '●' : '◌'} Phase: {phase.name}
144
+ {phase.done ? '●' : '◌'} {t('ui.workflow.phase', { name: phase.name })}
143
145
  {phaseAgents.length > 0 &&
144
- ` [${phaseAgents.filter((a) => a.status === 'done').length}/${phaseAgents.length} done]`}
146
+ t('ui.workflow.done_count', {
147
+ done: String(phaseAgents.filter((a) => a.status === 'done').length),
148
+ total: String(phaseAgents.length),
149
+ })}
145
150
  </Text>
146
151
  {phaseAgents.map((agent, j) => (
147
152
  <Box key={j} marginLeft={2}>
@@ -163,12 +168,16 @@ export function WorkflowProgress({ runId }: WorkflowProgressProps) {
163
168
  {/* Summary footer */}
164
169
  <Box marginTop={1}>
165
170
  <Text dimColor>
166
- ═══ Elapsed: {elapsedStr} | Agents: {doneCount}/{agentList.length}
167
- {runningCount > 0 && ` (${runningCount} running)`}
168
- {failedCount > 0 && ` (${failedCount} failed)`}
169
- {done && ` | Cache hits: ${cacheHits}`}
170
- {done && ' | DONE ═══'}
171
- {!done && ' | Status: running ═══'}
171
+ {t('ui.workflow.footer', {
172
+ time: elapsedStr,
173
+ done: String(doneCount),
174
+ total: String(agentList.length),
175
+ })}
176
+ {runningCount > 0 && t('ui.workflow.running', { n: String(runningCount) })}
177
+ {failedCount > 0 && t('ui.workflow.failed', { n: String(failedCount) })}
178
+ {done && t('ui.workflow.cache_hits', { n: String(cacheHits) })}
179
+ {done && t('ui.workflow.done_marker')}
180
+ {!done && t('ui.workflow.running_marker')}
172
181
  </Text>
173
182
  </Box>
174
183
  </Box>