@miphamai/cli 0.19.5 → 0.20.1

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.
@@ -0,0 +1,19 @@
1
+ import type { TranslationMap } from './types'
2
+
3
+ function getNested(obj: TranslationMap, key: string): unknown {
4
+ return key.split('.').reduce((o, k) => (o as any)?.[k], obj as any)
5
+ }
6
+
7
+ export function createT(
8
+ current: TranslationMap,
9
+ fallback: TranslationMap,
10
+ ): (key: string, params?: Record<string, string>) => string {
11
+ return function t(key: string, params?: Record<string, string>): string {
12
+ const val = getNested(current, key) ?? getNested(fallback, key) ?? key
13
+ if (typeof val !== 'string') return key
14
+ if (params) {
15
+ return val.replace(/\{(\w+)\}/g, (_, k: string) => params[k] ?? '')
16
+ }
17
+ return val
18
+ }
19
+ }
@@ -0,0 +1,10 @@
1
+ export type Locale = 'en-US' | 'zh-CN'
2
+
3
+ export const SUPPORTED_LOCALES: Locale[] = ['en-US', 'zh-CN']
4
+
5
+ export const FALLBACK_LOCALE: Locale = 'en-US'
6
+
7
+ /** A TranslationMap is a nested object where leaves are template strings. */
8
+ export interface TranslationMap {
9
+ [key: string]: string | TranslationMap
10
+ }
package/src/index.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import React from 'react'
1
2
  import { join } from 'node:path'
2
3
  import { existsSync } from 'node:fs'
3
4
  import { render } from 'ink'
@@ -23,6 +24,12 @@ import { getMetrics } from './core/metrics'
23
24
  import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
24
25
  import { AgentViewManager } from './agent-view/agent-view-manager'
25
26
  import { AgentViewDashboard } from './agent-view/dashboard'
27
+ import { createT } from './i18n-core/t'
28
+ import { detectLocale } from './i18n-core/detect'
29
+ import { I18nProvider } from './i18n-context'
30
+ import enUS from './i18n-core/locales/en-US.json' with { type: 'json' }
31
+ import zhCN from './i18n-core/locales/zh-CN.json' with { type: 'json' }
32
+ import type { TranslationMap } from './i18n-core/types'
26
33
 
27
34
  interface RunOptions {
28
35
  model?: string
@@ -60,6 +67,14 @@ export async function runApp(options: RunOptions): Promise<void> {
60
67
  // Load configuration
61
68
  const config = loadConfig()
62
69
 
70
+ // Detect locale and create translation function
71
+ const locale = detectLocale({ lang: options.lang })
72
+ const localeBundles: Record<string, TranslationMap> = {
73
+ 'en-US': enUS as TranslationMap,
74
+ 'zh-CN': zhCN as TranslationMap,
75
+ }
76
+ const t = createT(localeBundles[locale] || enUS, enUS)
77
+
63
78
  // Bootstrap providers
64
79
  const defaultProvider = options.provider || config.defaultProvider
65
80
  const defaultModel = options.model || config.defaultModel
@@ -297,17 +312,21 @@ export async function runApp(options: RunOptions): Promise<void> {
297
312
  })
298
313
 
299
314
  const { waitUntilExit } = render(
300
- <App
301
- engine={engine}
302
- config={config}
303
- initialProvider={defaultProvider}
304
- initialModel={defaultModel}
305
- lang={options.lang}
306
- skillsLoader={skillsLoader}
307
- pluginManager={pluginManager}
308
- version={options.version}
309
- sessionId={sessionName}
310
- />,
315
+ React.createElement(I18nProvider, {
316
+ locale,
317
+ t,
318
+ children: React.createElement(App, {
319
+ engine,
320
+ config,
321
+ initialProvider: defaultProvider,
322
+ initialModel: defaultModel,
323
+ lang: options.lang,
324
+ skillsLoader,
325
+ pluginManager,
326
+ version: options.version,
327
+ sessionId: sessionName,
328
+ }),
329
+ }),
311
330
  )
312
331
  await waitUntilExit()
313
332
  saveAndExit()
package/src/mcp/client.ts CHANGED
@@ -10,6 +10,16 @@ import { StdioTransport } from './transport'
10
10
  import { McpProtocol } from './protocol'
11
11
  import { OAuthClient } from './oauth'
12
12
  import { TokenStore } from './token-store'
13
+ import { createT } from '../i18n-core/t'
14
+ import enUS from '../i18n-core/locales/en-US.json'
15
+ import zhCN from '../i18n-core/locales/zh-CN.json'
16
+ import type { TranslationMap } from '../i18n-core/types'
17
+
18
+ const bundles: Record<string, TranslationMap> = {
19
+ 'en-US': enUS as TranslationMap,
20
+ 'zh-CN': zhCN as TranslationMap,
21
+ }
22
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
13
23
 
14
24
  interface ActiveConnection {
15
25
  config: McpServerConfig
@@ -98,7 +108,7 @@ export class McpClient {
98
108
  /** Reconnect with exponential backoff (1s→2s→4s→…max 60s, 10 attempts). */
99
109
  async reconnect(name: string): Promise<void> {
100
110
  const connection = this.connections.get(name)
101
- if (!connection) throw new Error(`No connection for "${name}"`)
111
+ if (!connection) throw new Error(t('errors.mcp_no_connection', { name }))
102
112
 
103
113
  const config = connection.config
104
114
  let delay = 1000
@@ -262,7 +272,7 @@ export class McpClient {
262
272
  const conn = this.connections.get(serverName)
263
273
  if (!conn || conn.status !== 'connected') {
264
274
  return {
265
- content: [{ type: 'text', text: `Error: MCP server "${serverName}" not connected` }],
275
+ content: [{ type: 'text', text: t('errors.mcp_not_connected', { server: serverName }) }],
266
276
  isError: true,
267
277
  }
268
278
  }
@@ -271,7 +281,7 @@ export class McpClient {
271
281
  return await conn.protocol.callTool(toolName, params)
272
282
  } catch (err) {
273
283
  return {
274
- content: [{ type: 'text', text: `MCP tool error: ${String(err)}` }],
284
+ content: [{ type: 'text', text: t('errors.mcp_tool_error', { error: String(err) }) }],
275
285
  isError: true,
276
286
  }
277
287
  }
package/src/mcp/oauth.ts CHANGED
@@ -4,6 +4,16 @@ import { createServer, Server } from 'node:http'
4
4
  import type { IncomingMessage, ServerResponse } from 'node:http'
5
5
  import type { McpServerConfig } from '../shared/types'
6
6
  import { TokenStore } from './token-store'
7
+ import { createT } from '../i18n-core/t'
8
+ import enUS from '../i18n-core/locales/en-US.json'
9
+ import zhCN from '../i18n-core/locales/zh-CN.json'
10
+ import type { TranslationMap } from '../i18n-core/types'
11
+
12
+ const bundles: Record<string, TranslationMap> = {
13
+ 'en-US': enUS as TranslationMap,
14
+ 'zh-CN': zhCN as TranslationMap,
15
+ }
16
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
7
17
 
8
18
  interface TokenResponse {
9
19
  accessToken: string
@@ -41,18 +51,18 @@ export class OAuthClient {
41
51
  if (receivedState !== state) {
42
52
  res.writeHead(400)
43
53
  res.end('State mismatch')
44
- reject(new Error('OAuth state mismatch — possible CSRF'))
54
+ reject(new Error(t('errors.oauth_state_mismatch')))
45
55
  return
46
56
  }
47
57
  if (!receivedCode) {
48
58
  res.writeHead(400)
49
59
  res.end('No code received')
50
- reject(new Error('No authorization code received'))
60
+ reject(new Error(t('errors.oauth_no_code')))
51
61
  return
52
62
  }
53
63
  res.writeHead(200, { 'Content-Type': 'text/html' })
54
64
  res.end(
55
- '<html><body><h1>Authenticated</h1><p>You may close this window.</p></body></html>',
65
+ `<html><body><h1>${t('system.oauth.authenticated')}</h1><p>${t('system.oauth.close_window')}</p></body></html>`,
56
66
  )
57
67
  server.close()
58
68
  resolve(receivedCode)
@@ -81,7 +91,7 @@ export class OAuthClient {
81
91
  })
82
92
  setTimeout(() => {
83
93
  server.close()
84
- reject(new Error('OAuth flow timed out (5 minutes)'))
94
+ reject(new Error(t('errors.oauth_timeout')))
85
95
  }, 300_000)
86
96
  })
87
97
 
@@ -9,6 +9,16 @@
9
9
  import type { ToolDefinition as McpToolDefinition, ToolCallResult } from './types'
10
10
  import type { ToolDefinition, ToolResult, ToolContext } from '../shared/types'
11
11
  import { McpClient } from './client'
12
+ import { createT } from '../i18n-core/t'
13
+ import enUS from '../i18n-core/locales/en-US.json'
14
+ import zhCN from '../i18n-core/locales/zh-CN.json'
15
+ import type { TranslationMap } from '../i18n-core/types'
16
+
17
+ const bundles: Record<string, TranslationMap> = {
18
+ 'en-US': enUS as TranslationMap,
19
+ 'zh-CN': zhCN as TranslationMap,
20
+ }
21
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
12
22
 
13
23
  const MCP_TOOL_PREFIX = 'mcp__'
14
24
  const MAX_NAME_LENGTH = 128
@@ -108,7 +118,7 @@ export function registerMcpServerTools(
108
118
 
109
119
  if (toolsMap.has(tool.name)) {
110
120
  process.stderr.write(
111
- `[mcp] Tool collision: "${tool.name}" (from server "${serverName}") conflicts with existing tool. Skipping.\n`,
121
+ t('errors.mcp_register_collision', { name: tool.name, server: serverName }) + '\n',
112
122
  )
113
123
  continue
114
124
  }
@@ -117,7 +127,11 @@ export function registerMcpServerTools(
117
127
  registered++
118
128
  } catch (err) {
119
129
  process.stderr.write(
120
- `[mcp] Failed to register tool "${mcpTool.name}" from server "${serverName}": ${String(err)}\n`,
130
+ t('errors.mcp_register_failed', {
131
+ tool: mcpTool.name,
132
+ server: serverName,
133
+ error: String(err),
134
+ }) + '\n',
121
135
  )
122
136
  }
123
137
  }
@@ -1,5 +1,9 @@
1
1
  import type { ToolDefinition, ToolResult } from '../shared/index.ts'
2
2
  import { sanitizeParams } from '../shared/sanitize'
3
+ import { createT } from '../i18n-core/t'
4
+ import enUS from '../i18n-core/locales/en-US.json'
5
+ import zhCN from '../i18n-core/locales/zh-CN.json'
6
+ import type { TranslationMap } from '../i18n-core/types'
3
7
  import { readTool } from './file/read'
4
8
  import { writeTool } from './file/write'
5
9
  import { editTool } from './file/edit'
@@ -29,6 +33,12 @@ import { computerUseTool } from './computer/computer-use'
29
33
  import { scheduleWakeupTool } from './scheduling/schedule-wakeup.js'
30
34
  import { cronCreateTool, cronDeleteTool, cronListTool } from './scheduling/cron.js'
31
35
 
36
+ const bundles: Record<string, TranslationMap> = {
37
+ 'en-US': enUS as TranslationMap,
38
+ 'zh-CN': zhCN as TranslationMap,
39
+ }
40
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
41
+
32
42
  /**
33
43
  * Validate tool parameters against the tool's JSON Schema definition.
34
44
  * Returns an array of error messages (empty = valid).
@@ -44,7 +54,7 @@ function validateParams(
44
54
  if (required) {
45
55
  for (const field of required) {
46
56
  if (params[field] === undefined || params[field] === null) {
47
- errors.push(`Missing required parameter: "${field}"`)
57
+ errors.push(t('errors.missing_param', { param: field }))
48
58
  }
49
59
  }
50
60
  }
@@ -59,25 +69,25 @@ function validateParams(
59
69
 
60
70
  switch (def.type) {
61
71
  case 'string':
62
- if (typeof value !== 'string') errors.push(`"${key}" must be a string`)
72
+ if (typeof value !== 'string') errors.push(t('errors.type_string', { key }))
63
73
  else if (def.enum && !def.enum.includes(value)) {
64
- errors.push(`"${key}" must be one of: ${def.enum.join(', ')}`)
74
+ errors.push(t('errors.type_enum', { key, values: def.enum.join(', ') }))
65
75
  }
66
76
  break
67
77
  case 'integer':
68
78
  case 'number':
69
- if (typeof value !== 'number') errors.push(`"${key}" must be a number`)
79
+ if (typeof value !== 'number') errors.push(t('errors.type_number', { key }))
70
80
  break
71
81
  case 'boolean':
72
- if (typeof value !== 'boolean') errors.push(`"${key}" must be a boolean`)
82
+ if (typeof value !== 'boolean') errors.push(t('errors.type_boolean', { key }))
73
83
  break
74
84
  case 'object':
75
85
  if (typeof value !== 'object' || Array.isArray(value)) {
76
- errors.push(`"${key}" must be an object`)
86
+ errors.push(t('errors.type_object', { key }))
77
87
  }
78
88
  break
79
89
  case 'array':
80
- if (!Array.isArray(value)) errors.push(`"${key}" must be an array`)
90
+ if (!Array.isArray(value)) errors.push(t('errors.type_array', { key }))
81
91
  break
82
92
  }
83
93
  }
@@ -100,7 +110,11 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
100
110
  const cleanParams = sanitizeParams(params)
101
111
  const errors = validateParams(schema, cleanParams)
102
112
  if (errors.length > 0) {
103
- return { success: false, content: '', error: `Invalid parameters: ${errors.join('; ')}` }
113
+ return {
114
+ success: false,
115
+ content: '',
116
+ error: t('errors.invalid_params', { errors: errors.join('; ') }),
117
+ }
104
118
  }
105
119
  return tool.execute(cleanParams, ctx)
106
120
  },
@@ -1,5 +1,6 @@
1
1
  import React from 'react'
2
2
  import { Box, Text } from 'ink'
3
+ import { useI18n } from '../i18n-context'
3
4
 
4
5
  export interface AgentEntry {
5
6
  id: string
@@ -33,6 +34,7 @@ function formatTokens(n: number): string {
33
34
  }
34
35
 
35
36
  export function AgentFooter({ agents, gitBranch, tick: _tick }: AgentFooterProps) {
37
+ const { t } = useI18n()
36
38
  const hasAgents = agents.length > 0
37
39
  const hasBranch = gitBranch.length > 0
38
40
 
@@ -73,7 +75,7 @@ export function AgentFooter({ agents, gitBranch, tick: _tick }: AgentFooterProps
73
75
  {/* Spacer pushes stats to the right — Ink handles this naturally */}
74
76
  <Text dimColor>
75
77
  {' '}
76
- {isRunning ? formatElapsed(elapsed) : 'finished'}
78
+ {isRunning ? formatElapsed(elapsed) : t('ui.agent.finished')}
77
79
  </Text>
78
80
  {isRunning && agent.tokensUsed > 0 && (
79
81
  <Text dimColor>
package/src/ui/app.tsx CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  handleSwitch,
20
20
  type CommandContext,
21
21
  } from './commands'
22
+ import { useI18n } from '../i18n-context'
22
23
  import type { PermissionMode } from '../shared/index.ts'
23
24
 
24
25
  interface AppProps {
@@ -72,14 +73,6 @@ const PERMISSION_MODES: PermissionMode[] = [
72
73
  // Labels aligned with Claude Code terminology: describe behavior, not capability.
73
74
  // Claude Code modes: manual mode → accept edits on → bypass
74
75
  // Mipham extends with 3 extra modes (plan, auto, dontAsk) for finer control.
75
- const PERMISSION_LABELS: Record<PermissionMode, string> = {
76
- default: 'manual mode',
77
- acceptEdits: 'accept edits on',
78
- plan: 'plan mode · read-only',
79
- auto: 'auto mode',
80
- dontAsk: "don't ask",
81
- bypassPermissions: 'bypass',
82
- }
83
76
  const PERMISSION_COLORS: Record<PermissionMode, string> = {
84
77
  default: 'white',
85
78
  acceptEdits: 'blue',
@@ -128,6 +121,18 @@ export function App({
128
121
  version,
129
122
  sessionId,
130
123
  }: AppProps) {
124
+ const { t } = useI18n()
125
+ const PERMISSION_LABELS = useMemo<Record<PermissionMode, string>>(
126
+ () => ({
127
+ default: t('ui.permission.manual'),
128
+ acceptEdits: t('ui.permission.accept_edits'),
129
+ plan: t('ui.permission.plan_mode'),
130
+ auto: t('ui.permission.auto'),
131
+ dontAsk: t('ui.permission.dont_ask'),
132
+ bypassPermissions: t('ui.permission.bypass'),
133
+ }),
134
+ [t],
135
+ )
131
136
  const [messages, setMessages] = useState<ChatMessage[]>([])
132
137
  const [isLoading, setIsLoading] = useState(false)
133
138
  const [providerId, setProviderId] = useState(initialProvider || config.defaultProvider)
@@ -141,6 +146,15 @@ export function App({
141
146
  const [goalText, setGoalText] = useState('')
142
147
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
143
148
  const abortRef = useRef<AbortController | null>(null)
149
+ // Stream buffer: accumulate text chunks and throttle state updates to ~16fps.
150
+ // Without this, every SSE chunk triggers setMessages → copies full array →
151
+ // re-renders ChatPanel → re-runs compactToolGroups O(n). At 20-50 chunks/sec
152
+ // with 200+ messages, this saturates the event loop and freezes the UI.
153
+ const streamBufferRef = useRef<{
154
+ turnContent: string
155
+ isFirst: boolean
156
+ timer: ReturnType<typeof setTimeout> | null
157
+ }>({ turnContent: '', isFirst: true, timer: null })
144
158
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
145
159
  // Multi-agent tracking: keyed by agent/task ID, shows all running + recently completed agents
146
160
  const [runningAgents, setRunningAgents] = useState<Record<string, AgentEntry>>({})
@@ -244,8 +258,9 @@ export function App({
244
258
  setUltracodeMode: (on: boolean) => setUltracodeMode(on),
245
259
  skillsLoader,
246
260
  pluginManager,
261
+ t,
247
262
  }),
248
- [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
263
+ [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId, t],
249
264
  )
250
265
 
251
266
  const handleSubmit = useCallback(
@@ -358,6 +373,25 @@ export function App({
358
373
  let turnContent = ''
359
374
  let isNewTurn = true
360
375
 
376
+ // Flush any pending stream buffer to state
377
+ const flushStreamBuffer = () => {
378
+ if (streamBufferRef.current.timer) {
379
+ clearTimeout(streamBufferRef.current.timer)
380
+ streamBufferRef.current.timer = null
381
+ }
382
+ const latest = streamBufferRef.current.turnContent
383
+ if (latest && !streamBufferRef.current.isFirst) {
384
+ setMessages((prev) => {
385
+ const updated = [...prev]
386
+ const last = updated[updated.length - 1]
387
+ if (last?.role === 'assistant') {
388
+ last.content = latest
389
+ }
390
+ return updated
391
+ })
392
+ }
393
+ }
394
+
361
395
  try {
362
396
  for await (const chunk of engine.process(input, controller.signal)) {
363
397
  // Reasoning content (DeepSeek V4 thinking mode) — silently consumed,
@@ -367,21 +401,34 @@ export function App({
367
401
  }
368
402
 
369
403
  if (chunk.type === 'text' && chunk.content) {
370
- // New turn: reset per-turn accumulator and push a fresh assistant message
404
+ // New turn: push fresh assistant message, reset stream buffer
371
405
  if (isNewTurn) {
372
406
  turnContent = chunk.content
373
407
  isNewTurn = false
374
- setMessages((prev) => [...prev, { role: 'assistant', content: turnContent }])
408
+ streamBufferRef.current = { turnContent: chunk.content, isFirst: false, timer: null }
409
+ setMessages((prev) => [
410
+ ...prev,
411
+ { role: 'assistant' as const, content: chunk.content || '' },
412
+ ])
375
413
  } else {
376
414
  turnContent += chunk.content
377
- setMessages((prev) => {
378
- const updated = [...prev]
379
- const last = updated[updated.length - 1]
380
- if (last?.role === 'assistant') {
381
- last.content = turnContent
382
- }
383
- return updated
384
- })
415
+ streamBufferRef.current.turnContent = turnContent
416
+ // Throttle: flush to state at most every 60ms (~16 fps for text).
417
+ // The ref holds the latest text; state update copies the ref value.
418
+ if (!streamBufferRef.current.timer) {
419
+ streamBufferRef.current.timer = setTimeout(() => {
420
+ streamBufferRef.current.timer = null
421
+ const latest = streamBufferRef.current.turnContent
422
+ setMessages((prev) => {
423
+ const updated = [...prev]
424
+ const last = updated[updated.length - 1]
425
+ if (last?.role === 'assistant') {
426
+ last.content = latest
427
+ }
428
+ return updated
429
+ })
430
+ }, 60)
431
+ }
385
432
  }
386
433
  assistantContent += chunk.content
387
434
  }
@@ -411,6 +458,8 @@ export function App({
411
458
  toolMeta: { name: toolName, input: detail, collapsed: true },
412
459
  },
413
460
  ])
461
+ // Flush stream buffer before showing tool card
462
+ flushStreamBuffer()
414
463
  // Mark that next text chunk starts a new turn
415
464
  isNewTurn = true
416
465
  }
@@ -481,6 +530,8 @@ export function App({
481
530
  } catch (err) {
482
531
  setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
483
532
  } finally {
533
+ // Flush any remaining stream buffer before finishing
534
+ flushStreamBuffer()
484
535
  setIsLoading(false)
485
536
  abortRef.current = null
486
537
  // Auto-save checkpoint after each AI response
@@ -612,10 +663,10 @@ export function App({
612
663
  )}
613
664
  <Box flexDirection="row">
614
665
  <Text color={PERMISSION_COLORS[permissionMode]}>
615
- ⏵⏵ {PERMISSION_LABELS[permissionMode]} (shift+tab to cycle)
666
+ ⏵⏵ {PERMISSION_LABELS[permissionMode]} ({t('ui.status.shift_tab_cycle')})
616
667
  </Text>
617
- <Text dimColor> · esc to interrupt</Text>
618
- <Text dimColor> · ← for agents</Text>
668
+ <Text dimColor> · {t('ui.status.esc_to_interrupt')}</Text>
669
+ <Text dimColor> · {t('ui.status.left_for_agents')}</Text>
619
670
  </Box>
620
671
  </Box>
621
672
  </Box>
package/src/ui/chat.tsx CHANGED
@@ -1,5 +1,6 @@
1
- import React from 'react'
1
+ import React, { useMemo } from 'react'
2
2
  import { Box, Text } from 'ink'
3
+ import { useI18n } from '../i18n-context'
3
4
  import type { ChatMessage } from './app'
4
5
 
5
6
  interface ChatPanelProps {
@@ -49,6 +50,7 @@ function toolColor(name: string): string {
49
50
  */
50
51
  const MessageRow = React.memo(
51
52
  function MessageRow({ msg }: { msg: ChatMessage; isLast: boolean }) {
53
+ const { t } = useI18n()
52
54
  return (
53
55
  <Box
54
56
  flexDirection="column"
@@ -76,8 +78,8 @@ const MessageRow = React.memo(
76
78
  {msg.role === 'user'
77
79
  ? `▸ ${displayCwd()}`
78
80
  : msg.role === 'assistant'
79
- ? 'Mipham Code'
80
- : '⚠ System'}
81
+ ? t('ui.assistant.role_label')
82
+ : `⚠ ${t('ui.system.role_label')}`}
81
83
  :
82
84
  </Text>
83
85
  <Text>{msg.content}</Text>
@@ -93,17 +95,23 @@ const MessageRow = React.memo(
93
95
  )
94
96
 
95
97
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
96
- // Group consecutive tool calls into compact summaries to reduce visual noise.
97
- // In focus mode, use the more aggressive compactForFocus.
98
- const displayMessages = focusMode ? compactForFocus(messages) : compactToolGroups(messages)
98
+ const { t } = useI18n()
99
+ // Memoize display message computation to avoid O(n) compact on every render.
100
+ // During streaming, messages changes on every chunk; without memoization,
101
+ // compactToolGroups/compactForFocus re-iterates the full message list on each
102
+ // chunk, saturating the event loop at 20-50 chunks/sec.
103
+ const displayMessages = useMemo(
104
+ () => (focusMode ? compactForFocus(messages) : compactToolGroups(messages)),
105
+ [messages, focusMode],
106
+ )
99
107
 
100
108
  return (
101
109
  <Box flexDirection="column" marginY={1} flexGrow={1}>
102
110
  {focusMode && messages.length > 0 && (
103
111
  <Box marginBottom={1}>
104
112
  <Text dimColor>
105
- 🔍 Focus — {countHidden(messages, displayMessages)} hidden · /focus to toggle · Ctrl+O
106
- to expand
113
+ 🔍 {t('ui.chat.focus_hint')} — {countHidden(messages, displayMessages, t)} ·{' '}
114
+ {t('ui.chat.toggle_focus_hint')} · {t('ui.chat.expand_hint')}
107
115
  </Text>
108
116
  </Box>
109
117
  )}
@@ -111,23 +119,21 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
111
119
  <Box flexDirection="column">
112
120
  <Box marginBottom={1}>
113
121
  <Text color="cyan" bold>
114
- Mipham Code
122
+ {t('ui.banner.title')}
115
123
  </Text>
116
- <Text dimColor> — AI-Powered Programming Assistant</Text>
124
+ <Text dimColor> — {t('ui.banner.subtitle')}</Text>
117
125
  </Box>
118
- <Text dimColor>Multi-model · Multi-provider · Skills & Tools · Open-core</Text>
126
+ <Text dimColor>{t('ui.banner.tagline')}</Text>
119
127
  <Box marginTop={1}>
120
128
  <Text dimColor>
121
- Type a message to start. <Text color="yellow">/help</Text> for commands ·{' '}
122
- <Text color="yellow">Ctrl+P</Text> pick model · <Text color="yellow">Esc</Text> to
123
- exit
129
+ {t('ui.banner.start_message')} <Text color="yellow">/help</Text>
124
130
  </Text>
125
131
  </Box>
126
132
  <Box marginTop={1}>
127
- <Text dimColor>
128
- Tip: Use <Text color="yellow">/clear</Text> to start fresh when switching topics and
129
- free up context
130
- </Text>
133
+ <Text dimColor>{t('ui.banner.controls_hint')}</Text>
134
+ </Box>
135
+ <Box marginTop={1}>
136
+ <Text dimColor>{t('ui.banner.tip_clear')}</Text>
131
137
  </Box>
132
138
  </Box>
133
139
  )}
@@ -239,9 +245,13 @@ function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
239
245
  }
240
246
 
241
247
  /** Count how many messages are hidden in focus mode. */
242
- function countHidden(all: ChatMessage[], compacted: ChatMessage[]): string {
248
+ function countHidden(
249
+ all: ChatMessage[],
250
+ compacted: ChatMessage[],
251
+ t: (key: string, params?: Record<string, string>) => string,
252
+ ): string {
243
253
  const toolCount = all.filter((m) => m.toolMeta).length
244
254
  const hidden = all.length - compacted.length
245
- if (hidden <= 0) return `showing all ${all.length} messages`
246
- return `${hidden} tool outputs folded (${toolCount} total tool calls)`
255
+ if (hidden <= 0) return t('ui.chat.showing_all', { count: String(all.length) })
256
+ return t('ui.chat.tool_outputs_folded', { hidden: String(hidden), toolCount: String(toolCount) })
247
257
  }
@@ -1,6 +1,7 @@
1
1
  import React, { useState, useMemo, useEffect } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
+ import { useI18n } from '../i18n-context'
4
5
  import { getCommandList } from './commands.js'
5
6
 
6
7
  interface CommandPickerProps {
@@ -29,6 +30,7 @@ export function CommandPicker({
29
30
  onClose,
30
31
  maxVisible = PAGE_SIZE,
31
32
  }: CommandPickerProps) {
33
+ const { t } = useI18n()
32
34
  const allCommands = useMemo(() => getCommandList(), [])
33
35
  const [filter, setFilter] = useState(initialFilter)
34
36
  const [cursorIdx, setCursorIdx] = useState(0)
@@ -92,14 +94,16 @@ export function CommandPicker({
92
94
  {/* Title */}
93
95
  <Box marginBottom={1}>
94
96
  <Text bold color="cyan">
95
- Commands
97
+ {t('ui.command_picker.title')}
96
98
  </Text>
97
- <Text dimColor> ↑↓ navigate · Enter select · Esc close</Text>
99
+ <Text dimColor> {t('ui.command_picker.nav_help')}</Text>
98
100
  </Box>
99
101
 
100
102
  {/* Command list */}
101
103
  <Box flexDirection="column" marginBottom={1}>
102
- {visible.length === 0 && <Text dimColor> No commands match "{filter}"</Text>}
104
+ {visible.length === 0 && (
105
+ <Text dimColor> {t('ui.command_picker.no_matches', { filter })}</Text>
106
+ )}
103
107
  {visible.map((cmd, i) => {
104
108
  const globalIdx = i + scrollStart
105
109
  const isCursor = globalIdx === cursorIdx
@@ -119,8 +123,11 @@ export function CommandPicker({
119
123
  {filtered.length > maxVisible && (
120
124
  <Box marginBottom={1}>
121
125
  <Text dimColor>
122
- {scrollStart + 1}–{Math.min(scrollStart + maxVisible, filtered.length)} of{' '}
123
- {filtered.length} commands
126
+ {t('ui.command_picker.scroll_indicator', {
127
+ start: String(scrollStart + 1),
128
+ end: String(Math.min(scrollStart + maxVisible, filtered.length)),
129
+ total: String(filtered.length),
130
+ })}
124
131
  </Text>
125
132
  </Box>
126
133
  )}
@@ -137,7 +144,7 @@ export function CommandPicker({
137
144
  onSelect(selected.name)
138
145
  }
139
146
  }}
140
- placeholder="Type to filter commands..."
147
+ placeholder={t('ui.command_picker.placeholder')}
141
148
  />
142
149
  </Box>
143
150
  </Box>