@miphamai/cli 0.19.5 → 0.20.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.19.5",
3
+ "version": "0.20.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -55,6 +55,7 @@
55
55
  "@types/bun": "^1.3.14",
56
56
  "@types/node": "^22.19.19",
57
57
  "@types/react": "^18.3.29",
58
+ "ink-testing-library": "^4.0.0",
58
59
  "vitest": "^4.1.7"
59
60
  }
60
61
  }
@@ -18,6 +18,16 @@ import { getBackgroundAgentRegistry } from '../agent/background-registry'
18
18
  import { RulesLoader } from './rules-loader'
19
19
  import { UsageTracker } from './usage-tracker'
20
20
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
21
+ import { createT } from '@mipham/shared/i18n/t'
22
+ import enUS from '@mipham/shared/i18n/locales/en-US.json'
23
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
24
+ import type { TranslationMap } from '@mipham/shared/i18n/types'
25
+
26
+ const bundles: Record<string, TranslationMap> = {
27
+ 'en-US': enUS as TranslationMap,
28
+ 'zh-CN': zhCN as TranslationMap,
29
+ }
30
+ const t = createT(bundles['en-US'] || (enUS as TranslationMap), enUS as TranslationMap)
21
31
 
22
32
  export class QueryEngine {
23
33
  private hookEngine?: HookEngine
@@ -232,7 +242,7 @@ export class QueryEngine {
232
242
  // Return a minimal summary on failure
233
243
  }
234
244
 
235
- return summary.slice(0, 8000) || 'Prior conversation context omitted.'
245
+ return summary.slice(0, 8000) || t('system.context.omitted')
236
246
  })
237
247
  }
238
248
 
@@ -257,7 +267,7 @@ export class QueryEngine {
257
267
  if (!submitResult.allowed) {
258
268
  yield {
259
269
  type: 'error',
260
- error: submitResult.reason || 'User input blocked by hook.',
270
+ error: submitResult.reason || t('errors.user_input_blocked'),
261
271
  }
262
272
  return
263
273
  }
@@ -291,7 +301,7 @@ export class QueryEngine {
291
301
  if (!verdict.allowed) {
292
302
  yield {
293
303
  type: 'error',
294
- error: verdict.reason || 'Request blocked by DLP policy.',
304
+ error: verdict.reason || t('errors.dlp_blocked'),
295
305
  }
296
306
  return
297
307
  }
@@ -316,7 +326,10 @@ export class QueryEngine {
316
326
  yield chunk
317
327
 
318
328
  if (chunk.type === 'error') {
319
- this.context.addMessage({ role: 'assistant', content: `Error: ${chunk.error}` })
329
+ this.context.addMessage({
330
+ role: 'assistant',
331
+ content: t('errors.model_error', { error: chunk.error ?? 'Unknown error' }),
332
+ })
320
333
  return
321
334
  }
322
335
 
@@ -495,17 +508,26 @@ export class QueryEngine {
495
508
  if (this.goalVerifyScript) {
496
509
  const passed = await this.runScriptVerification()
497
510
  if (passed) {
498
- yield { type: 'text', content: `✅ Goal verification passed: ${this.goalVerifyScript}` }
511
+ yield {
512
+ type: 'text',
513
+ content: t('system.goal.verified', { script: this.goalVerifyScript }),
514
+ }
499
515
  break
500
516
  }
501
517
  // Script failed — continue looping
502
- yield { type: 'text', content: `🔄 Verification script failed — continuing (loop ${loop})` }
518
+ yield {
519
+ type: 'text',
520
+ content: t('system.goal.failed_continuing', { loop: String(loop) }),
521
+ }
503
522
  continue
504
523
  }
505
524
 
506
525
  // If using skill verification, delegate to the skill
507
526
  if (this.goalVerifySkill) {
508
- yield { type: 'text', content: `🔍 Running verification skill: ${this.goalVerifySkill}` }
527
+ yield {
528
+ type: 'text',
529
+ content: t('system.goal.running_skill', { skill: this.goalVerifySkill }),
530
+ }
509
531
  yield* this.process(checkMsg, signal)
510
532
  if (this.lastAssistantContent?.includes('VERIFIED')) break
511
533
  continue
@@ -519,7 +541,7 @@ export class QueryEngine {
519
541
  if (loop >= this.maxGoalLoops) {
520
542
  yield {
521
543
  type: 'text',
522
- content: `⚠ Max goal loops (${this.maxGoalLoops}) reached — goal may not be achieved.`,
544
+ content: t('system.goal.max_loops', { max: String(this.maxGoalLoops) }),
523
545
  }
524
546
  }
525
547
  }
@@ -571,7 +593,7 @@ export class QueryEngine {
571
593
  if (!verdict.allowed) {
572
594
  yield {
573
595
  type: 'error',
574
- error: verdict.reason || 'Request blocked by DLP policy.',
596
+ error: verdict.reason || t('errors.dlp_blocked'),
575
597
  }
576
598
  return
577
599
  }
@@ -655,10 +677,10 @@ export class QueryEngine {
655
677
  if (turn === MAX_TURNS - 1 && toolUses.length > 0) {
656
678
  this.context.addMessage({
657
679
  role: 'user',
658
- content:
659
- `You've reached the maximum of ${MAX_TURNS} tool-calling rounds. ` +
660
- `${toolUses.length} tool call(s) were not executed. ` +
661
- 'Please summarize what you found so far and any next steps the user should take.',
680
+ content: t('errors.max_tool_turns_warning', {
681
+ max: String(MAX_TURNS),
682
+ pending: String(toolUses.length),
683
+ }),
662
684
  })
663
685
  // Give model one final chance to respond with a summary
664
686
  try {
@@ -677,7 +699,7 @@ export class QueryEngine {
677
699
  } catch {
678
700
  yield {
679
701
  type: 'error',
680
- error: `Max tool-calling turns (${MAX_TURNS}) reached. Some tool calls were not executed.`,
702
+ error: t('errors.max_tool_turns', { max: String(MAX_TURNS) }),
681
703
  }
682
704
  }
683
705
  return
@@ -730,7 +752,7 @@ export class QueryEngine {
730
752
  return {
731
753
  success: false,
732
754
  content: '',
733
- error: `Tool "${name}" requires user approval (permission: ask). The tool was not executed.`,
755
+ error: t('errors.tool_not_allowed', { name }),
734
756
  }
735
757
  }
736
758
 
@@ -742,7 +764,7 @@ export class QueryEngine {
742
764
  return {
743
765
  success: false,
744
766
  content: '',
745
- error: preResult.reason || `Tool "${name}" blocked by hook`,
767
+ error: preResult.reason || t('errors.tool_blocked', { name }),
746
768
  }
747
769
  }
748
770
  if (preResult.modifiedInput) {
@@ -803,7 +825,7 @@ export class QueryEngine {
803
825
  /** Register a tool dynamically (used by MCP auto-registration). */
804
826
  registerTool(tool: ToolDefinition): void {
805
827
  if (this.tools.has(tool.name)) {
806
- process.stderr.write(`[mcp] Tool collision: "${tool.name}" already registered. Skipping.\n`)
828
+ process.stderr.write(t('errors.tool_collision', { name: tool.name }) + '\n')
807
829
  return
808
830
  }
809
831
  this.tools.set(tool.name, tool)
@@ -869,7 +891,9 @@ export class QueryEngine {
869
891
  // Feed the block reason back to the AI and continue
870
892
  this.context.addMessage({
871
893
  role: 'user',
872
- content: `[The Stop hook blocked completion]: ${stopResult.reason || 'Continue working.'}`,
894
+ content: t('system.context.stop_blocked', {
895
+ reason: stopResult.reason || 'Continue working.',
896
+ }),
873
897
  })
874
898
  yield* this.continueWithTools(signal)
875
899
  }
@@ -0,0 +1,37 @@
1
+ import React, { createContext, useContext } from 'react'
2
+ import type { Locale } from '@mipham/shared/i18n/types'
3
+
4
+ export interface I18nContextValue {
5
+ locale: Locale
6
+ t: (key: string, params?: Record<string, string>) => string
7
+ }
8
+
9
+ const I18nContext = createContext<I18nContextValue | null>(null)
10
+
11
+ export function I18nProvider({
12
+ locale,
13
+ t,
14
+ children,
15
+ }: {
16
+ locale: Locale
17
+ t: (key: string, params?: Record<string, string>) => string
18
+ children: React.ReactNode
19
+ }) {
20
+ return React.createElement(
21
+ I18nContext.Provider,
22
+ { value: { locale, t } },
23
+ children,
24
+ )
25
+ }
26
+
27
+ export function useI18n(): I18nContextValue {
28
+ const ctx = useContext(I18nContext)
29
+ if (!ctx) {
30
+ // If no Provider is mounted (e.g., tests, bare scripts), return en-US no-op.
31
+ return {
32
+ locale: 'en-US',
33
+ t: (key: string) => key,
34
+ }
35
+ }
36
+ return ctx
37
+ }
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 '@mipham/shared/i18n/t'
28
+ import { detectLocale } from '@mipham/shared/i18n/detect'
29
+ import { I18nProvider } from './i18n-context'
30
+ import enUS from '@mipham/shared/i18n/locales/en-US.json' with { type: 'json' }
31
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json' with { type: 'json' }
32
+ import type { TranslationMap } from '@mipham/shared/i18n/types'
26
33
 
27
34
  interface RunOptions {
28
35
  model?: string
@@ -60,6 +67,11 @@ 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> = { 'en-US': enUS as TranslationMap, 'zh-CN': zhCN as TranslationMap }
73
+ const t = createT(localeBundles[locale] || enUS, enUS)
74
+
63
75
  // Bootstrap providers
64
76
  const defaultProvider = options.provider || config.defaultProvider
65
77
  const defaultModel = options.model || config.defaultModel
@@ -297,17 +309,21 @@ export async function runApp(options: RunOptions): Promise<void> {
297
309
  })
298
310
 
299
311
  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
- />,
312
+ React.createElement(I18nProvider, {
313
+ locale,
314
+ t,
315
+ children: React.createElement(App, {
316
+ engine,
317
+ config,
318
+ initialProvider: defaultProvider,
319
+ initialModel: defaultModel,
320
+ lang: options.lang,
321
+ skillsLoader,
322
+ pluginManager,
323
+ version: options.version,
324
+ sessionId: sessionName,
325
+ }),
326
+ }),
311
327
  )
312
328
  await waitUntilExit()
313
329
  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 '@mipham/shared/i18n/t'
14
+ import enUS from '@mipham/shared/i18n/locales/en-US.json'
15
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
16
+ import type { TranslationMap } from '@mipham/shared/i18n/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 '@mipham/shared/i18n/t'
8
+ import enUS from '@mipham/shared/i18n/locales/en-US.json'
9
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
10
+ import type { TranslationMap } from '@mipham/shared/i18n/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 '@mipham/shared/i18n/t'
13
+ import enUS from '@mipham/shared/i18n/locales/en-US.json'
14
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
15
+ import type { TranslationMap } from '@mipham/shared/i18n/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,7 @@ 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', { tool: mcpTool.name, server: serverName, error: String(err) }) + '\n',
121
131
  )
122
132
  }
123
133
  }
@@ -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 '@mipham/shared/i18n/t'
4
+ import enUS from '@mipham/shared/i18n/locales/en-US.json'
5
+ import zhCN from '@mipham/shared/i18n/locales/zh-CN.json'
6
+ import type { TranslationMap } from '@mipham/shared/i18n/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,7 @@ 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 { success: false, content: '', error: t('errors.invalid_params', { errors: errors.join('; ') }) }
104
114
  }
105
115
  return tool.execute(cleanParams, ctx)
106
116
  },
@@ -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)
@@ -244,8 +249,9 @@ export function App({
244
249
  setUltracodeMode: (on: boolean) => setUltracodeMode(on),
245
250
  skillsLoader,
246
251
  pluginManager,
252
+ t,
247
253
  }),
248
- [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
254
+ [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId, t],
249
255
  )
250
256
 
251
257
  const handleSubmit = useCallback(
@@ -612,10 +618,10 @@ export function App({
612
618
  )}
613
619
  <Box flexDirection="row">
614
620
  <Text color={PERMISSION_COLORS[permissionMode]}>
615
- ⏵⏵ {PERMISSION_LABELS[permissionMode]} (shift+tab to cycle)
621
+ ⏵⏵ {PERMISSION_LABELS[permissionMode]} ({t('ui.status.shift_tab_cycle')})
616
622
  </Text>
617
- <Text dimColor> · esc to interrupt</Text>
618
- <Text dimColor> · ← for agents</Text>
623
+ <Text dimColor> · {t('ui.status.esc_to_interrupt')}</Text>
624
+ <Text dimColor> · {t('ui.status.left_for_agents')}</Text>
619
625
  </Box>
620
626
  </Box>
621
627
  </Box>
package/src/ui/chat.tsx CHANGED
@@ -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
  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,6 +95,7 @@ const MessageRow = React.memo(
93
95
  )
94
96
 
95
97
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
98
+ const { t } = useI18n()
96
99
  // Group consecutive tool calls into compact summaries to reduce visual noise.
97
100
  // In focus mode, use the more aggressive compactForFocus.
98
101
  const displayMessages = focusMode ? compactForFocus(messages) : compactToolGroups(messages)
@@ -102,8 +105,7 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
102
105
  {focusMode && messages.length > 0 && (
103
106
  <Box marginBottom={1}>
104
107
  <Text dimColor>
105
- 🔍 Focus — {countHidden(messages, displayMessages)} hidden · /focus to toggle · Ctrl+O
106
- to expand
108
+ 🔍 {t('ui.chat.focus_hint')} — {countHidden(messages, displayMessages, t)} · {t('ui.chat.toggle_focus_hint')} · {t('ui.chat.expand_hint')}
107
109
  </Text>
108
110
  </Box>
109
111
  )}
@@ -111,22 +113,24 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
111
113
  <Box flexDirection="column">
112
114
  <Box marginBottom={1}>
113
115
  <Text color="cyan" bold>
114
- Mipham Code
116
+ {t('ui.banner.title')}
115
117
  </Text>
116
- <Text dimColor> — AI-Powered Programming Assistant</Text>
118
+ <Text dimColor> — {t('ui.banner.subtitle')}</Text>
117
119
  </Box>
118
- <Text dimColor>Multi-model · Multi-provider · Skills & Tools · Open-core</Text>
120
+ <Text dimColor>{t('ui.banner.tagline')}</Text>
119
121
  <Box marginTop={1}>
120
122
  <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
123
+ {t('ui.banner.start_message')} <Text color="yellow">/help</Text>
124
124
  </Text>
125
125
  </Box>
126
126
  <Box marginTop={1}>
127
127
  <Text dimColor>
128
- Tip: Use <Text color="yellow">/clear</Text> to start fresh when switching topics and
129
- free up context
128
+ {t('ui.banner.controls_hint')}
129
+ </Text>
130
+ </Box>
131
+ <Box marginTop={1}>
132
+ <Text dimColor>
133
+ {t('ui.banner.tip_clear')}
130
134
  </Text>
131
135
  </Box>
132
136
  </Box>
@@ -239,9 +243,9 @@ function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
239
243
  }
240
244
 
241
245
  /** Count how many messages are hidden in focus mode. */
242
- function countHidden(all: ChatMessage[], compacted: ChatMessage[]): string {
246
+ function countHidden(all: ChatMessage[], compacted: ChatMessage[], t: (key: string, params?: Record<string, string>) => string): string {
243
247
  const toolCount = all.filter((m) => m.toolMeta).length
244
248
  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)`
249
+ if (hidden <= 0) return t('ui.chat.showing_all', { count: String(all.length) })
250
+ return t('ui.chat.tool_outputs_folded', { hidden: String(hidden), toolCount: String(toolCount) })
247
251
  }