@miphamai/cli 0.2.3 → 0.3.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.
@@ -1,7 +1,8 @@
1
- import type { ToolDefinition, ToolPermission, PermissionLevel } from '../shared/index.ts'
1
+ import type { ToolDefinition, ToolPermission, PermissionLevel, PermissionRule } from '../shared/index.ts'
2
2
 
3
3
  export class PermissionSystem {
4
4
  private rules = new Map<string, PermissionLevel>()
5
+ private patternRules: Array<{ pattern: RegExp; level: PermissionLevel }> = []
5
6
 
6
7
  constructor(private defaultLevel: ToolPermission = 'auto') {}
7
8
 
@@ -13,18 +14,43 @@ export class PermissionSystem {
13
14
  return this.defaultLevel
14
15
  }
15
16
 
16
- setRule(toolName: string, level: PermissionLevel): void {
17
- this.rules.set(toolName, level)
17
+ setRule(toolNameOrRule: string | PermissionRule, level?: PermissionLevel): void {
18
+ if (typeof toolNameOrRule === 'string') {
19
+ if (level) this.rules.set(toolNameOrRule, level)
20
+ } else {
21
+ const rule = toolNameOrRule
22
+ if (rule.pattern) {
23
+ this.patternRules.push({ pattern: new RegExp(rule.pattern), level: rule.level })
24
+ } else {
25
+ this.rules.set(rule.toolName, rule.level)
26
+ }
27
+ }
18
28
  }
19
29
 
20
30
  removeRule(toolName: string): void {
21
31
  this.rules.delete(toolName)
22
32
  }
23
33
 
24
- check(tool: ToolDefinition, _input: Record<string, unknown>): PermissionLevel {
34
+ /**
35
+ * Resolve permission level for a tool call.
36
+ *
37
+ * Fallback chain: exact rule → pattern rule → tool permission → system default
38
+ */
39
+ check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
40
+ // 1. Exact-name rule wins
25
41
  const ruleLevel = this.rules.get(tool.name)
26
42
  if (ruleLevel) return ruleLevel
27
- return tool.permission
43
+
44
+ // 2. Pattern-based rules (e.g. "file_*" → bypass)
45
+ for (const { pattern, level } of this.patternRules) {
46
+ if (pattern.test(tool.name)) return level
47
+ }
48
+
49
+ // 3. Tool's own permission level
50
+ if (tool.permission) return tool.permission
51
+
52
+ // 4. System default
53
+ return this.defaultLevel
28
54
  }
29
55
 
30
56
  needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
@@ -4,6 +4,7 @@ import {
4
4
  readFileSync,
5
5
  writeFileSync,
6
6
  unlinkSync,
7
+ renameSync,
7
8
  existsSync,
8
9
  } from 'node:fs'
9
10
  import { join } from 'node:path'
@@ -60,7 +61,10 @@ export class SessionStore {
60
61
  messages,
61
62
  }
62
63
 
63
- writeFileSync(path, JSON.stringify(session) + '\n', 'utf-8')
64
+ // Atomic write: write to temp file, then rename (same-fs rename is atomic)
65
+ const tmp = path + '.tmp'
66
+ writeFileSync(tmp, JSON.stringify(session) + '\n', 'utf-8')
67
+ renameSync(tmp, path)
64
68
  }
65
69
 
66
70
  /**
package/src/index.tsx CHANGED
@@ -8,6 +8,8 @@ import { QueryEngine } from './core/engine'
8
8
  import { SessionStore } from './core/session-store'
9
9
  import { SkillsLoader } from './skills/loader'
10
10
  import { createToolRegistry } from './tools'
11
+ import { McpClient } from './mcp/client'
12
+ import { HookEngine } from './core/hooks'
11
13
 
12
14
  interface RunOptions {
13
15
  model?: string
@@ -24,6 +26,17 @@ export async function runApp(options: RunOptions): Promise<void> {
24
26
  // Load configuration
25
27
  const config = loadConfig()
26
28
 
29
+ // Connect MCP servers (fire-and-forget — do not block startup)
30
+ const mcpServers = config.skills?.mcpServers ?? []
31
+ if (mcpServers.length > 0) {
32
+ const mcp = McpClient.getInstance()
33
+ for (const server of mcpServers) {
34
+ mcp.connect(server).catch((err) => {
35
+ process.stderr.write(`[mcp] Failed to connect "${server.name}": ${String(err)}\n`)
36
+ })
37
+ }
38
+ }
39
+
27
40
  // Bootstrap providers
28
41
  const defaultProvider = options.provider || config.defaultProvider
29
42
  const defaultModel = options.model || config.defaultModel
@@ -60,8 +73,20 @@ export async function runApp(options: RunOptions): Promise<void> {
60
73
  // Create tool registry with all 16 tools
61
74
  const tools = createToolRegistry()
62
75
 
76
+ // Initialize hook engine — register skill-defined hooks
77
+ const hookEngine = new HookEngine()
78
+ for (const skill of skillsLoader.list()) {
79
+ if (skill.hooks) {
80
+ for (const hook of skill.hooks) {
81
+ hookEngine.register(hook)
82
+ }
83
+ }
84
+ }
85
+
63
86
  // Create query engine
64
87
  const engine = new QueryEngine(registry, context, tools)
88
+ engine.setHookEngine(hookEngine)
89
+ engine.setupContextSummarizer()
65
90
 
66
91
  // Auto-save session on exit
67
92
  let autoSaveName: string | undefined
@@ -6,6 +6,7 @@ import type {
6
6
  ContentBlock,
7
7
  } from '../shared/index.ts'
8
8
  import type { ProviderInstance, ChatRequest } from './registry'
9
+ import { fetchWithRetry } from './fetch-utils'
9
10
 
10
11
  interface AnthropicContentBlock {
11
12
  type: string
@@ -72,7 +73,7 @@ export class AnthropicProvider implements ProviderInstance {
72
73
  }))
73
74
  }
74
75
 
75
- const response = await fetch(`${this.baseUrl}/messages`, {
76
+ const response = await fetchWithRetry(`${this.baseUrl}/messages`, {
76
77
  method: 'POST',
77
78
  headers: {
78
79
  'Content-Type': 'application/json',
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared fetch utilities — retry, timeout, backoff.
3
+ *
4
+ * Both AnthropicProvider and OpenAICompatProvider use these for
5
+ * resilient API communication.
6
+ */
7
+
8
+ export interface FetchWithRetryOptions {
9
+ /** Request timeout in ms (applied via AbortController). */
10
+ timeout?: number
11
+ /** Maximum retry attempts (default 0 = no retry). */
12
+ maxRetries?: number
13
+ /** Base delay for exponential backoff in ms (default 1000). */
14
+ baseDelay?: number
15
+ }
16
+
17
+ const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504])
18
+
19
+ function isRetryableError(err: unknown): boolean {
20
+ if (err instanceof DOMException && err.name === 'AbortError') return false
21
+ return true
22
+ }
23
+
24
+ /**
25
+ * Fetch with optional timeout and retry with exponential backoff.
26
+ *
27
+ * Retries on: network errors, 5xx, 429.
28
+ * Does NOT retry on: timeout (AbortError), 4xx (except 429).
29
+ */
30
+ export async function fetchWithRetry(
31
+ url: string,
32
+ init: RequestInit,
33
+ options: FetchWithRetryOptions = {},
34
+ ): Promise<Response> {
35
+ const { timeout = 60_000, maxRetries = 2, baseDelay = 1000 } = options
36
+
37
+ let lastErr: unknown
38
+
39
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
40
+ const controller = new AbortController()
41
+ const timer = setTimeout(() => controller.abort(), timeout)
42
+ const signal = init.signal
43
+ ? anySignal([init.signal, controller.signal])
44
+ : controller.signal
45
+
46
+ try {
47
+ const response = await fetch(url, { ...init, signal })
48
+
49
+ // 429 / 5xx → retry
50
+ if (RETRYABLE_STATUSES.has(response.status) && attempt < maxRetries) {
51
+ const retryAfter = response.headers.get('Retry-After')
52
+ const delay = retryAfter
53
+ ? parseInt(retryAfter, 10) * 1000
54
+ : baseDelay * Math.pow(2, attempt)
55
+ await sleep(delay)
56
+ continue
57
+ }
58
+
59
+ return response
60
+ } catch (err) {
61
+ lastErr = err
62
+ if (!isRetryableError(err) || attempt >= maxRetries) throw err
63
+ await sleep(baseDelay * Math.pow(2, attempt))
64
+ } finally {
65
+ clearTimeout(timer)
66
+ // Clean up combined signal if we created one
67
+ if (init.signal) {
68
+ try {
69
+ controller.abort()
70
+ } catch {
71
+ /* best effort */
72
+ }
73
+ }
74
+ }
75
+ }
76
+
77
+ throw lastErr
78
+ }
79
+
80
+ /** Simple promise-based sleep. */
81
+ function sleep(ms: number): Promise<void> {
82
+ return new Promise((resolve) => setTimeout(resolve, ms))
83
+ }
84
+
85
+ /**
86
+ * Combine multiple AbortSignals into one — any signal aborting
87
+ * triggers the combined signal.
88
+ */
89
+ function anySignal(signals: AbortSignal[]): AbortSignal {
90
+ const controller = new AbortController()
91
+ const onAbort = () => {
92
+ controller.abort()
93
+ for (const s of signals) s.removeEventListener('abort', onAbort)
94
+ }
95
+ for (const s of signals) {
96
+ if (s.aborted) {
97
+ controller.abort()
98
+ return controller.signal
99
+ }
100
+ s.addEventListener('abort', onAbort)
101
+ }
102
+ return controller.signal
103
+ }
@@ -6,6 +6,7 @@ import type {
6
6
  ContentBlock,
7
7
  } from '../shared/index.ts'
8
8
  import type { ProviderInstance, ChatRequest } from './registry'
9
+ import { fetchWithRetry } from './fetch-utils'
9
10
 
10
11
  export class OpenAICompatProvider implements ProviderInstance {
11
12
  constructor(public config: ProviderConfig) {}
@@ -23,7 +24,7 @@ export class OpenAICompatProvider implements ProviderInstance {
23
24
  tools: req.tools?.map((t) => ({ type: 'function', function: t })),
24
25
  }
25
26
 
26
- const response = await fetch(`${baseUrl}/chat/completions`, {
27
+ const response = await fetchWithRetry(`${baseUrl}/chat/completions`, {
27
28
  method: 'POST',
28
29
  headers: {
29
30
  'Content-Type': 'application/json',
@@ -47,6 +48,12 @@ export class OpenAICompatProvider implements ProviderInstance {
47
48
  const decoder = new TextDecoder()
48
49
  let buffer = ''
49
50
 
51
+ // Track incremental tool calls across streaming deltas
52
+ const pendingToolCalls = new Map<
53
+ number,
54
+ { id: string; name: string; arguments: string }
55
+ >()
56
+
50
57
  while (true) {
51
58
  const { done, value } = await reader.read()
52
59
  if (done) break
@@ -60,6 +67,18 @@ export class OpenAICompatProvider implements ProviderInstance {
60
67
  if (!trimmed || !trimmed.startsWith('data: ')) continue
61
68
  const data = trimmed.slice(6)
62
69
  if (data === '[DONE]') {
70
+ // Emit any pending tool calls before stopping
71
+ for (const [, tc] of pendingToolCalls) {
72
+ yield {
73
+ type: 'tool_use',
74
+ toolUse: {
75
+ type: 'tool_use',
76
+ id: tc.id,
77
+ name: tc.name,
78
+ input: this.safeParseJson(tc.arguments),
79
+ },
80
+ }
81
+ }
63
82
  yield { type: 'stop' }
64
83
  return
65
84
  }
@@ -73,21 +92,39 @@ export class OpenAICompatProvider implements ProviderInstance {
73
92
 
74
93
  if (delta?.tool_calls) {
75
94
  for (const tc of delta.tool_calls) {
95
+ const idx = tc.index ?? 0
96
+ const pending = pendingToolCalls.get(idx) || {
97
+ id: '',
98
+ name: '',
99
+ arguments: '',
100
+ }
101
+
102
+ if (tc.id) pending.id = tc.id
103
+ if (tc.function?.name) pending.name = tc.function.name
104
+ if (tc.function?.arguments) pending.arguments += tc.function.arguments
105
+
106
+ pendingToolCalls.set(idx, pending)
107
+ }
108
+ }
109
+
110
+ if (delta?.content) {
111
+ yield { type: 'text', content: delta.content }
112
+ }
113
+
114
+ if (choice.finish_reason === 'tool_calls') {
115
+ // Emit fully accumulated tool calls
116
+ for (const [, tc] of pendingToolCalls) {
76
117
  yield {
77
118
  type: 'tool_use',
78
119
  toolUse: {
79
120
  type: 'tool_use',
80
121
  id: tc.id || `call_${Date.now()}`,
81
- name: tc.function?.name || '',
82
- input: tc.function?.arguments ? JSON.parse(tc.function.arguments) : {},
122
+ name: tc.name,
123
+ input: this.safeParseJson(tc.arguments),
83
124
  },
84
125
  }
85
126
  }
86
- continue
87
- }
88
-
89
- if (delta?.content) {
90
- yield { type: 'text', content: delta.content }
127
+ pendingToolCalls.clear()
91
128
  }
92
129
 
93
130
  if (choice.finish_reason === 'stop') {
@@ -148,6 +185,16 @@ export class OpenAICompatProvider implements ProviderInstance {
148
185
  return result
149
186
  }
150
187
 
188
+ /** Safely parse JSON, returning an empty object on failure. */
189
+ private safeParseJson(raw: string): Record<string, unknown> {
190
+ if (!raw) return {}
191
+ try {
192
+ return JSON.parse(raw)
193
+ } catch {
194
+ return { _raw: raw }
195
+ }
196
+ }
197
+
151
198
  private resolveApiKey(keyTemplate: string): string {
152
199
  const match = keyTemplate.match(/^\$\{(.+)\}$/)
153
200
  if (match?.[1]) {
@@ -14,6 +14,7 @@ export interface ChatRequest {
14
14
  tools?: Record<string, unknown>[]
15
15
  maxTokens?: number
16
16
  temperature?: number
17
+ signal?: AbortSignal
17
18
  }
18
19
 
19
20
  export class ProviderRegistry {
package/src/ui/app.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { useState, useCallback } from 'react'
1
+ import React, { useState, useCallback, useRef } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import type { QueryEngine } from '../core/engine'
4
4
  import type { MiphamConfig } from '../shared/index.ts'
@@ -23,12 +23,26 @@ interface AppProps {
23
23
  skillsLoader?: SkillsLoader
24
24
  }
25
25
 
26
+ export interface ToolMeta {
27
+ name: string
28
+ input: string
29
+ output?: string
30
+ collapsed: boolean
31
+ }
32
+
26
33
  export interface ChatMessage {
27
34
  role: 'user' | 'assistant' | 'system'
28
35
  content: string
36
+ toolMeta?: ToolMeta
29
37
  }
30
38
 
31
- const VERSION = '0.2.0'
39
+ interface AgentProgress {
40
+ name: string
41
+ description: string
42
+ startTime: number
43
+ }
44
+
45
+ const VERSION = '0.3.0'
32
46
 
33
47
  type PermissionMode = 'auto' | 'ask' | 'bypass'
34
48
 
@@ -58,6 +72,18 @@ export function App({
58
72
  const [focusMode, setFocusMode] = useState(false)
59
73
  const [goalText, setGoalText] = useState('')
60
74
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('auto')
75
+ const abortRef = useRef<AbortController | null>(null)
76
+ const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
77
+ const [agentElapsed, setAgentElapsed] = useState(0)
78
+
79
+ // Agent elapsed timer
80
+ React.useEffect(() => {
81
+ if (!agentProgress) { setAgentElapsed(0); return }
82
+ const interval = setInterval(() => {
83
+ setAgentElapsed(Math.floor((Date.now() - agentProgress.startTime) / 1000))
84
+ }, 1000)
85
+ return () => clearInterval(interval)
86
+ }, [agentProgress])
61
87
 
62
88
  const mkCtx = useCallback(
63
89
  (): CommandContext => ({
@@ -161,10 +187,13 @@ export function App({
161
187
  setMessages((prev) => [...prev, { role: 'user', content: input }])
162
188
  setIsLoading(true)
163
189
 
190
+ const controller = new AbortController()
191
+ abortRef.current = controller
192
+
164
193
  let assistantContent = ''
165
194
 
166
195
  try {
167
- for await (const chunk of engine.process(input)) {
196
+ for await (const chunk of engine.process(input, controller.signal)) {
168
197
  if (chunk.type === 'text' && chunk.content) {
169
198
  assistantContent += chunk.content
170
199
  setMessages((prev) => {
@@ -180,24 +209,50 @@ export function App({
180
209
  }
181
210
 
182
211
  if (chunk.type === 'tool_use' && chunk.toolUse) {
212
+ const toolName = chunk.toolUse.name
213
+ const inputStr = JSON.stringify(chunk.toolUse.input)
214
+ const isAgent = toolName === 'Agent' || toolName === 'Task'
215
+
216
+ if (isAgent) {
217
+ setAgentProgress({
218
+ name: (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
219
+ description: (chunk.toolUse.input.description as string) ||
220
+ (chunk.toolUse.input.prompt as string) || '',
221
+ startTime: Date.now(),
222
+ })
223
+ }
224
+
225
+ const collapsed = toolName !== 'Agent' // agents always expanded
183
226
  setMessages((prev) => [
184
227
  ...prev,
185
228
  {
186
229
  role: 'system',
187
- content: `🔧 Using tool: ${chunk.toolUse!.name}(${JSON.stringify(chunk.toolUse!.input)})`,
230
+ content: collapsed
231
+ ? `⏺ ${toolName} · ${inputStr.slice(0, 50)}${inputStr.length > 50 ? '...' : ''} (Ctrl+O to expand)`
232
+ : `🔧 ${toolName}: ${inputStr.slice(0, 200)}`,
233
+ toolMeta: { name: toolName, input: inputStr, collapsed },
188
234
  },
189
235
  ])
190
236
  }
191
237
 
192
238
  if (chunk.type === 'tool_result') {
193
- const resultPreview =
194
- chunk.content && chunk.content.length > 200
195
- ? chunk.content.slice(0, 200) + '...'
196
- : chunk.content || '(empty)'
197
- setMessages((prev) => [
198
- ...prev,
199
- { role: 'system', content: `📋 Tool result: ${resultPreview}` },
200
- ])
239
+ setAgentProgress(null)
240
+ setMessages((prev) => {
241
+ const updated = [...prev]
242
+ for (let i = updated.length - 1; i >= 0; i--) {
243
+ if (updated[i]?.toolMeta && !updated[i]!.toolMeta!.output) {
244
+ updated[i] = {
245
+ ...updated[i]!,
246
+ content: updated[i]!.toolMeta!.collapsed
247
+ ? updated[i]!.content
248
+ : `📋 ${updated[i]!.toolMeta!.name} result: ${(chunk.content || '(empty)').slice(0, 200)}`,
249
+ toolMeta: { ...updated[i]!.toolMeta!, output: chunk.content || '' },
250
+ }
251
+ break
252
+ }
253
+ }
254
+ return updated
255
+ })
201
256
  }
202
257
 
203
258
  if (chunk.type === 'error') {
@@ -211,6 +266,7 @@ export function App({
211
266
  setMessages((prev) => [...prev, { role: 'system', content: `Error: ${String(err)}` }])
212
267
  } finally {
213
268
  setIsLoading(false)
269
+ abortRef.current = null
214
270
  // Auto-save checkpoint after each AI response
215
271
  if (assistantContent) {
216
272
  engine.getContext().saveCheckpoint('post-turn')
@@ -227,6 +283,10 @@ export function App({
227
283
  setPickerOpen(false)
228
284
  return
229
285
  }
286
+ if (isLoading && abortRef.current) {
287
+ abortRef.current.abort()
288
+ return
289
+ }
230
290
  process.exit(0)
231
291
  }
232
292
  // Ctrl+P → open model picker
@@ -234,6 +294,34 @@ export function App({
234
294
  setPickerOpen((prev) => !prev)
235
295
  return
236
296
  }
297
+ // Ctrl+O → toggle last tool call expand/collapse
298
+ if (_input === '\x0f') {
299
+ setMessages((prev) => {
300
+ const msgs = [...prev]
301
+ for (let i = msgs.length - 1; i >= 0; i--) {
302
+ if (msgs[i]?.toolMeta) {
303
+ const meta = msgs[i]!.toolMeta!
304
+ if (meta.collapsed) {
305
+ msgs[i] = {
306
+ ...msgs[i]!,
307
+ content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
308
+ toolMeta: { ...meta, collapsed: false },
309
+ }
310
+ } else {
311
+ const short = meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
312
+ msgs[i] = {
313
+ ...msgs[i]!,
314
+ content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
315
+ toolMeta: { ...meta, collapsed: true },
316
+ }
317
+ }
318
+ break
319
+ }
320
+ }
321
+ return msgs
322
+ })
323
+ return
324
+ }
237
325
  // Shift+Tab → cycle permission mode (auto → ask → bypass → auto)
238
326
  if (key.shift && key.tab) {
239
327
  setPermissionMode((prev) => {
@@ -255,7 +343,7 @@ export function App({
255
343
  <Text bold color="cyan">
256
344
  Mipham Code
257
345
  </Text>
258
- <Text dimColor> v0.2.0</Text>
346
+ <Text dimColor> v0.3.0</Text>
259
347
  {sessionTitle ? (
260
348
  <Text color="yellow"> — {sessionTitle}</Text>
261
349
  ) : (
@@ -278,7 +366,7 @@ export function App({
278
366
  ● {PERMISSION_LABELS[permissionMode]}
279
367
  </Text>
280
368
  <Text dimColor> (Shift+Tab to cycle)</Text>
281
- <Text dimColor> · Ctrl+P pick · /help · Esc exit</Text>
369
+ <Text dimColor> · Ctrl+P pick · /help · Esc cancel</Text>
282
370
  </Box>
283
371
  {goalText && (
284
372
  <Box>
@@ -287,6 +375,21 @@ export function App({
287
375
  )}
288
376
  </Box>
289
377
 
378
+ {/* Agent progress banner */}
379
+ {agentProgress && (
380
+ <Box flexDirection="column" marginY={1}>
381
+ <Text color="cyan" bold>
382
+ ⏺ {agentProgress.name}{' '}
383
+ <Text color="yellow">{agentElapsed}s</Text>
384
+ </Text>
385
+ <Text dimColor>
386
+ {agentProgress.description.length > 100
387
+ ? `"${agentProgress.description.slice(0, 100)}..."`
388
+ : `"${agentProgress.description}"`}
389
+ </Text>
390
+ </Box>
391
+ )}
392
+
290
393
  {/* Chat panel */}
291
394
  <ChatPanel messages={messages} focusMode={focusMode} />
292
395
 
package/src/ui/chat.tsx CHANGED
@@ -37,17 +37,35 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
37
37
  exit
38
38
  </Text>
39
39
  </Box>
40
+ <Box marginTop={1}>
41
+ <Text dimColor>
42
+ Tip: Use <Text color="yellow">/clear</Text> to start fresh when switching topics
43
+ and free up context
44
+ </Text>
45
+ </Box>
40
46
  </Box>
41
47
  )}
42
48
  {displayMessages.map((msg, i) => (
43
49
  <Box key={i} flexDirection="column" marginY={1}>
44
- <Text
45
- bold
46
- color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
47
- >
48
- {msg.role === 'user' ? '▸ You' : msg.role === 'assistant' ? 'Mipham Code' : '⚠ System'}:
49
- </Text>
50
- <Text>{msg.content}</Text>
50
+ {msg.toolMeta ? (
51
+ <Box flexDirection="column">
52
+ <Text color="yellow">
53
+ {msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'}{' '}
54
+ {msg.toolMeta.name}
55
+ </Text>
56
+ <Text dimColor>{msg.content}</Text>
57
+ </Box>
58
+ ) : (
59
+ <>
60
+ <Text
61
+ bold
62
+ color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
63
+ >
64
+ {msg.role === 'user' ? '▸ You' : msg.role === 'assistant' ? 'Mipham Code' : '⚠ System'}:
65
+ </Text>
66
+ <Text>{msg.content}</Text>
67
+ </>
68
+ )}
51
69
  </Box>
52
70
  ))}
53
71
  </Box>