@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.
- package/package.json +2 -1
- package/src/core/engine.ts +42 -18
- package/src/i18n-context.tsx +33 -0
- package/src/i18n-core/detect.ts +54 -0
- package/src/i18n-core/locales/en-US.json +781 -0
- package/src/i18n-core/locales/zh-CN.json +781 -0
- package/src/i18n-core/t.ts +19 -0
- package/src/i18n-core/types.ts +10 -0
- package/src/index.tsx +30 -11
- package/src/mcp/client.ts +13 -3
- package/src/mcp/oauth.ts +14 -4
- package/src/mcp/registry.ts +16 -2
- package/src/tools/index.ts +22 -8
- package/src/ui/agent-footer.tsx +3 -1
- package/src/ui/app.tsx +73 -22
- package/src/ui/chat.tsx +31 -21
- package/src/ui/command-picker.tsx +13 -6
- package/src/ui/commands.ts +363 -428
- package/src/ui/input.tsx +53 -101
- package/src/ui/picker.tsx +12 -8
|
@@ -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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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(
|
|
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:
|
|
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:
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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(
|
|
94
|
+
reject(new Error(t('errors.oauth_timeout')))
|
|
85
95
|
}, 300_000)
|
|
86
96
|
})
|
|
87
97
|
|
package/src/mcp/registry.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/tools/index.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
86
|
+
errors.push(t('errors.type_object', { key }))
|
|
77
87
|
}
|
|
78
88
|
break
|
|
79
89
|
case 'array':
|
|
80
|
-
if (!Array.isArray(value)) errors.push(
|
|
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 {
|
|
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
|
},
|
package/src/ui/agent-footer.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
|
|
|
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:
|
|
404
|
+
// New turn: push fresh assistant message, reset stream buffer
|
|
371
405
|
if (isNewTurn) {
|
|
372
406
|
turnContent = chunk.content
|
|
373
407
|
isNewTurn = false
|
|
374
|
-
|
|
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
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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]} (
|
|
666
|
+
⏵⏵ {PERMISSION_LABELS[permissionMode]} ({t('ui.status.shift_tab_cycle')})
|
|
616
667
|
</Text>
|
|
617
|
-
<Text dimColor> ·
|
|
618
|
-
<Text dimColor> ·
|
|
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
|
-
? '
|
|
80
|
-
:
|
|
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
|
-
|
|
97
|
-
//
|
|
98
|
-
|
|
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
|
-
🔍
|
|
106
|
-
|
|
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
|
-
|
|
122
|
+
{t('ui.banner.title')}
|
|
115
123
|
</Text>
|
|
116
|
-
<Text dimColor> —
|
|
124
|
+
<Text dimColor> — {t('ui.banner.subtitle')}</Text>
|
|
117
125
|
</Box>
|
|
118
|
-
<Text dimColor>
|
|
126
|
+
<Text dimColor>{t('ui.banner.tagline')}</Text>
|
|
119
127
|
<Box marginTop={1}>
|
|
120
128
|
<Text dimColor>
|
|
121
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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(
|
|
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
|
|
246
|
-
return
|
|
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
|
-
|
|
97
|
+
{t('ui.command_picker.title')}
|
|
96
98
|
</Text>
|
|
97
|
-
<Text dimColor>
|
|
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 &&
|
|
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
|
-
{
|
|
123
|
-
|
|
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=
|
|
147
|
+
placeholder={t('ui.command_picker.placeholder')}
|
|
141
148
|
/>
|
|
142
149
|
</Box>
|
|
143
150
|
</Box>
|