@miphamai/cli 0.15.0 → 0.16.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/src/ui/app.tsx CHANGED
@@ -1,13 +1,17 @@
1
- import React, { useState, useCallback, useRef, useMemo } from 'react'
1
+ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
+ import { readFileSync } from 'node:fs'
3
4
  import type { QueryEngine } from '../core/engine'
4
5
  import type { MiphamConfig } from '../shared/index.ts'
5
6
  import type { SkillsLoader } from '../skills/loader'
6
7
  import type { PluginManager } from '../plugin/plugin-manager'
8
+ import { getPreference, setPreference } from '../config/preferences'
7
9
  import { AgentRegistry } from '../agent/agent-registry'
10
+ import { getBackgroundAgentRegistry } from '../agent/background-registry'
8
11
  import { ChatPanel } from './chat'
9
12
  import { InputBar } from './input'
10
13
  import { ModelPicker } from './picker'
14
+ import { AgentFooter, type AgentEntry } from './agent-footer'
11
15
  import {
12
16
  getCommand,
13
17
  looksLikeSlashCommand,
@@ -26,6 +30,7 @@ interface AppProps {
26
30
  skillsLoader?: SkillsLoader
27
31
  pluginManager?: PluginManager
28
32
  version?: string
33
+ sessionId?: string
29
34
  }
30
35
 
31
36
  export interface ToolMeta {
@@ -45,6 +50,10 @@ interface AgentProgress {
45
50
  name: string
46
51
  description: string
47
52
  startTime: number
53
+ tokensUsed?: number
54
+ isTask?: boolean
55
+ /** Unique identifier for multi-agent tracking. */
56
+ id?: string
48
57
  }
49
58
 
50
59
  // Version is read fresh from package.json at startup via runApp prop
@@ -75,6 +84,34 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
75
84
  bypassPermissions: 'red',
76
85
  }
77
86
 
87
+ /** Format a tool's input parameters into a compact one-line detail string. */
88
+ function formatToolDetail(name: string, input: Record<string, unknown>): string {
89
+ switch (name) {
90
+ case 'Bash':
91
+ return (input.command as string) || ''
92
+ case 'Read':
93
+ return (input.file_path as string) || ''
94
+ case 'Write':
95
+ return (input.file_path as string) || ''
96
+ case 'Edit':
97
+ return (input.file_path as string) || ''
98
+ case 'Grep':
99
+ return (input.pattern as string) || ''
100
+ case 'Glob':
101
+ return (input.pattern as string) || ''
102
+ case 'Agent':
103
+ return `${(input.subagent_type as string) || 'general'}, "${((input.description as string) || (input.prompt as string) || '').slice(0, 80)}"`
104
+ case 'WebSearch':
105
+ return (input.query as string) || ''
106
+ case 'WebFetch':
107
+ return (input.url as string) || ''
108
+ case 'Task':
109
+ return `"${(input.subject as string) || ''}"`
110
+ default:
111
+ return JSON.stringify(input).slice(0, 80)
112
+ }
113
+ }
114
+
78
115
  export function App({
79
116
  engine,
80
117
  config,
@@ -84,6 +121,7 @@ export function App({
84
121
  skillsLoader,
85
122
  pluginManager,
86
123
  version,
124
+ sessionId,
87
125
  }: AppProps) {
88
126
  const [messages, setMessages] = useState<ChatMessage[]>([])
89
127
  const [isLoading, setIsLoading] = useState(false)
@@ -94,23 +132,81 @@ export function App({
94
132
  const [fastMode, setFastMode] = useState(false)
95
133
  const [effort, setEffort] = useState('high')
96
134
  const [focusMode, setFocusMode] = useState(false)
135
+ const [_ultracodeMode, setUltracodeMode] = useState(false)
97
136
  const [goalText, setGoalText] = useState('')
98
137
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
99
138
  const abortRef = useRef<AbortController | null>(null)
100
139
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
101
- const [agentElapsed, setAgentElapsed] = useState(0)
140
+ // Multi-agent tracking: keyed by agent/task ID, shows all running + recently completed agents
141
+ const [runningAgents, setRunningAgents] = useState<Record<string, AgentEntry>>({})
142
+ const [gitBranch, setGitBranch] = useState('')
143
+ const [agentTick, setAgentTick] = useState(0)
144
+
145
+ // Detect git branch on mount
146
+ useEffect(() => {
147
+ try {
148
+ const head = readFileSync('.git/HEAD', 'utf8').trim()
149
+ const m = head.match(/^ref: refs\/heads\/(.+)$/)
150
+ setGitBranch(m ? m[1]! : head.slice(0, 7))
151
+ } catch {
152
+ // Not a git repo — leave empty
153
+ }
154
+ }, [])
102
155
 
103
- // Agent elapsed timer
104
- React.useEffect(() => {
105
- if (!agentProgress) {
106
- setAgentElapsed(0)
107
- return
156
+ // Tick timer for agent elapsed displays (re-renders every second while agents are running)
157
+ useEffect(() => {
158
+ const hasRunning =
159
+ Object.values(runningAgents).some((a) => a.status === 'running') || agentProgress !== null
160
+ if (!hasRunning) return
161
+ const i = setInterval(() => setAgentTick((t) => t + 1), 1000)
162
+ return () => clearInterval(i)
163
+ }, [runningAgents, agentProgress])
164
+
165
+ // Sync running agents from BackgroundAgentRegistry into React state.
166
+ // Called after Agent tool results and task notifications to keep the footer current.
167
+ const syncBgAgents = useCallback(() => {
168
+ const bgRegistry = getBackgroundAgentRegistry()
169
+ const all = bgRegistry.list()
170
+ setRunningAgents((prev) => {
171
+ const next: Record<string, AgentEntry> = {}
172
+ for (const task of all) {
173
+ // Preserve existing entry if it has more data (e.g. tokens accumulated during streaming)
174
+ const existing = prev[task.id]
175
+ next[task.id] = {
176
+ id: task.id,
177
+ name: existing?.name || task.agentType.charAt(0).toUpperCase() + task.agentType.slice(1),
178
+ description: existing?.description || task.description,
179
+ startTime: existing?.startTime || task.startedAt.getTime(),
180
+ tokensUsed: existing?.tokensUsed || 0,
181
+ status: task.status === 'running' ? 'running' : 'completed',
182
+ }
183
+ }
184
+ return next
185
+ })
186
+
187
+ // Register onComplete for running tasks to auto-dismiss them
188
+ for (const task of all) {
189
+ if (task.status === 'running') {
190
+ bgRegistry.onComplete(task.id, () => {
191
+ setRunningAgents((prev) => {
192
+ const next = { ...prev }
193
+ if (next[task.id]) {
194
+ next[task.id] = { ...next[task.id], status: 'completed' } as AgentEntry
195
+ }
196
+ return next
197
+ })
198
+ // Auto-dismiss after 5 seconds
199
+ setTimeout(() => {
200
+ setRunningAgents((prev) => {
201
+ const next = { ...prev }
202
+ delete next[task.id]
203
+ return next
204
+ })
205
+ }, 5000)
206
+ })
207
+ }
108
208
  }
109
- const interval = setInterval(() => {
110
- setAgentElapsed(Math.floor((Date.now() - agentProgress.startTime) / 1000))
111
- }, 1000)
112
- return () => clearInterval(interval)
113
- }, [agentProgress])
209
+ }, [])
114
210
 
115
211
  // Initialize agent registry (one-time, on mount)
116
212
  useMemo(() => {
@@ -131,15 +227,20 @@ export function App({
131
227
  providerId,
132
228
  modelId,
133
229
  version: version || '0.0.0',
230
+ sessionId: sessionId || '',
134
231
  setSessionTitle: (title: string) => setSessionTitle(title),
135
232
  setFastMode: (on: boolean) => setFastMode(on),
136
- setEffort: (level: string) => setEffort(level),
233
+ setEffort: (level: string) => {
234
+ setEffort(level)
235
+ setPreference('lastCodeReviewEffort', level)
236
+ },
137
237
  setFocusMode: (on: boolean) => setFocusMode(on),
138
238
  setGoal: (text: string) => setGoalText(text),
239
+ setUltracodeMode: (on: boolean) => setUltracodeMode(on),
139
240
  skillsLoader,
140
241
  pluginManager,
141
242
  }),
142
- [engine, config, providerId, modelId, skillsLoader, pluginManager],
243
+ [engine, config, providerId, modelId, skillsLoader, pluginManager, sessionId],
143
244
  )
144
245
 
145
246
  const handleSubmit = useCallback(
@@ -294,10 +395,11 @@ export function App({
294
395
  if (chunk.type === 'tool_use' && chunk.toolUse) {
295
396
  const toolName = chunk.toolUse.name
296
397
  const isAgent = toolName === 'Agent' || toolName === 'Task'
398
+ const detail = formatToolDetail(toolName, chunk.toolUse.input)
297
399
 
298
400
  if (isAgent) {
299
401
  setAgentProgress({
300
- name: (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
402
+ name: detail || (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
301
403
  description:
302
404
  (chunk.toolUse.input.description as string) ||
303
405
  (chunk.toolUse.input.prompt as string) ||
@@ -305,14 +407,47 @@ export function App({
305
407
  startTime: Date.now(),
306
408
  })
307
409
  }
308
- // Silent tools: don't show Bash/Glob/Read/etc lines — just the spinner
410
+
411
+ // Show tool call as a visible, collapsed message
412
+ setMessages((prev) => [
413
+ ...prev,
414
+ {
415
+ role: 'system' as const,
416
+ content: detail,
417
+ toolMeta: { name: toolName, input: detail, collapsed: true },
418
+ },
419
+ ])
309
420
  // Mark that next text chunk starts a new turn
310
421
  isNewTurn = true
311
422
  }
312
423
 
313
424
  if (chunk.type === 'tool_result') {
314
425
  setAgentProgress(null)
315
- // Silent: tool results don't need visible lines
426
+ // Sync background agents (e.g. Agent tool may have spawned them)
427
+ syncBgAgents()
428
+ // Show tool result as a compact indented line
429
+ const output = chunk.content ? String(chunk.content).trim() : ''
430
+ if (output) {
431
+ const firstLine = output.split('\n')[0]!.slice(0, 200)
432
+ const preview = firstLine.length < output.length ? `${firstLine}...` : firstLine
433
+ setMessages((prev) => [
434
+ ...prev,
435
+ {
436
+ role: 'system' as const,
437
+ content: ` ⎿ ${preview}`,
438
+ toolMeta: { name: '', input: '', output: preview, collapsed: true },
439
+ },
440
+ ])
441
+ }
442
+ }
443
+
444
+ if (chunk.type === 'usage' && agentProgress) {
445
+ const totalTokens = (chunk.inputTokens || 0) + (chunk.outputTokens || 0)
446
+ if (totalTokens > 0) {
447
+ setAgentProgress((prev) =>
448
+ prev ? { ...prev, tokensUsed: (prev.tokensUsed || 0) + totalTokens } : null,
449
+ )
450
+ }
316
451
  }
317
452
 
318
453
  if (chunk.type === 'error') {
@@ -324,8 +459,8 @@ export function App({
324
459
 
325
460
  if (chunk.type === 'task_notification' && chunk.taskNotification) {
326
461
  const tn = chunk.taskNotification
327
- const emoji = tn.status === 'completed' ? '✅' : '❌'
328
- const label = tn.status === 'completed' ? 'completed' : 'failed'
462
+ const isDone = tn.status === 'completed'
463
+ const symbol = isDone ? '◼' : '✳'
329
464
  const preview = tn.content
330
465
  ? tn.content.slice(0, 120) + (tn.content.length > 120 ? '...' : '')
331
466
  : tn.error
@@ -335,9 +470,17 @@ export function App({
335
470
  ...prev,
336
471
  {
337
472
  role: 'system',
338
- content: `${emoji} Background Agent ${label}: ${tn.description}\n ID: ${tn.taskId}\n ${preview}`,
473
+ content: `${symbol} ${tn.description}${isDone ? ` · finished` : ''}`,
474
+ toolMeta: {
475
+ name: isDone ? '' : 'task',
476
+ input: tn.description || '',
477
+ output: preview,
478
+ collapsed: isDone,
479
+ },
339
480
  },
340
481
  ])
482
+ // Sync agent footer — mark completed background agents
483
+ syncBgAgents()
341
484
  }
342
485
  }
343
486
  } catch (err) {
@@ -349,9 +492,11 @@ export function App({
349
492
  if (assistantContent) {
350
493
  engine.getContext().saveCheckpoint('post-turn')
351
494
  }
495
+ // Final sync of background agents after the turn completes
496
+ syncBgAgents()
352
497
  }
353
498
  },
354
- [engine, mkCtx],
499
+ [engine, mkCtx, syncBgAgents],
355
500
  )
356
501
 
357
502
  useInput((_input, key) => {
@@ -420,20 +565,6 @@ export function App({
420
565
 
421
566
  return (
422
567
  <Box flexDirection="column" padding={1} height="100%">
423
- {/* Agent progress banner */}
424
- {agentProgress && (
425
- <Box flexDirection="column" marginY={1}>
426
- <Text color="cyan" bold>
427
- ⏺ {agentProgress.name} <Text color="yellow">{agentElapsed}s</Text>
428
- </Text>
429
- <Text dimColor>
430
- {agentProgress.description.length > 100
431
- ? `"${agentProgress.description.slice(0, 100)}..."`
432
- : `"${agentProgress.description}"`}
433
- </Text>
434
- </Box>
435
- )}
436
-
437
568
  {/* Header — left-aligned */}
438
569
  <Box flexDirection="column" marginBottom={1}>
439
570
  <Text color="#FFD700" bold>
@@ -473,6 +604,9 @@ export function App({
473
604
  </Box>
474
605
  )}
475
606
 
607
+ {/* Agent status footer — shows running background agents */}
608
+ <AgentFooter agents={Object.values(runningAgents)} gitBranch={gitBranch} tick={agentTick} />
609
+
476
610
  {/* Status line */}
477
611
  <Box marginTop={1} flexDirection="column">
478
612
  {goalText && (
package/src/ui/chat.tsx CHANGED
@@ -17,6 +17,81 @@ function displayCwd(): string {
17
17
  return cwd
18
18
  }
19
19
 
20
+ /** Map tool names to dot colors — Claude Code parity */
21
+ function toolColor(name: string): string {
22
+ switch (name) {
23
+ case 'Bash':
24
+ case 'Read':
25
+ case 'Glob':
26
+ case 'Grep':
27
+ return 'white'
28
+ case 'Write':
29
+ case 'Edit':
30
+ return 'red'
31
+ case 'Agent':
32
+ case 'Task':
33
+ case 'Skill':
34
+ case 'Workflow':
35
+ return 'magenta'
36
+ case 'WebSearch':
37
+ case 'WebFetch':
38
+ return 'blue'
39
+ default:
40
+ return 'white'
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Memoized single message row — skips re-render when content hasn't changed.
46
+ * This prevents O(n) re-renders of the entire chat history on every streaming chunk.
47
+ * The custom comparator returns true (skip render) when content, role, and collapse
48
+ * state are identical to the previous render.
49
+ */
50
+ const MessageRow = React.memo(
51
+ function MessageRow({ msg }: { msg: ChatMessage; isLast: boolean }) {
52
+ return (
53
+ <Box
54
+ flexDirection="column"
55
+ marginTop={msg.toolMeta ? 0 : 1}
56
+ marginBottom={msg.toolMeta ? 0 : 1}
57
+ >
58
+ {msg.toolMeta ? (
59
+ <Box flexDirection="column">
60
+ <Text color={toolColor(msg.toolMeta.name)}>
61
+ {msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'} {msg.toolMeta.name}
62
+ {msg.toolMeta.input ? `(${msg.toolMeta.input.slice(0, 120)})` : ''}
63
+ </Text>
64
+ {msg.toolMeta.output ? (
65
+ <Text dimColor> ⎿ {msg.toolMeta.output.slice(0, 300)}</Text>
66
+ ) : msg.content && !msg.toolMeta.collapsed ? (
67
+ <Text dimColor>{msg.content}</Text>
68
+ ) : null}
69
+ </Box>
70
+ ) : (
71
+ <>
72
+ <Text
73
+ bold
74
+ color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
75
+ >
76
+ {msg.role === 'user'
77
+ ? `▸ ${displayCwd()}`
78
+ : msg.role === 'assistant'
79
+ ? 'Mipham Code'
80
+ : '⚠ System'}
81
+ :
82
+ </Text>
83
+ <Text>{msg.content}</Text>
84
+ </>
85
+ )}
86
+ </Box>
87
+ )
88
+ },
89
+ (prev, next) =>
90
+ prev.msg.content === next.msg.content &&
91
+ prev.msg.role === next.msg.role &&
92
+ prev.msg.toolMeta?.collapsed === next.msg.toolMeta?.collapsed,
93
+ )
94
+
20
95
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
21
96
  // In focus mode, compact tool activity into summary lines
22
97
  const displayMessages = focusMode ? compactForFocus(messages) : messages
@@ -56,36 +131,11 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
56
131
  </Box>
57
132
  )}
58
133
  {displayMessages.map((msg, i) => (
59
- <Box
60
- key={i}
61
- flexDirection="column"
62
- marginTop={msg.toolMeta ? 0 : 1}
63
- marginBottom={msg.toolMeta ? 0 : 1}
64
- >
65
- {msg.toolMeta ? (
66
- <Box flexDirection="column">
67
- <Text color="yellow">
68
- {msg.toolMeta.collapsed ? '⏺' : '⏺ ▼'} {msg.toolMeta.name}
69
- </Text>
70
- <Text dimColor>{msg.content}</Text>
71
- </Box>
72
- ) : (
73
- <>
74
- <Text
75
- bold
76
- color={msg.role === 'user' ? 'green' : msg.role === 'system' ? 'yellow' : 'blue'}
77
- >
78
- {msg.role === 'user'
79
- ? `▸ ${displayCwd()}`
80
- : msg.role === 'assistant'
81
- ? 'Mipham Code'
82
- : '⚠ System'}
83
- :
84
- </Text>
85
- <Text>{msg.content}</Text>
86
- </>
87
- )}
88
- </Box>
134
+ <MessageRow
135
+ key={`${i}-${msg.role}-${typeof msg.content === 'string' ? msg.content.length : 0}`}
136
+ msg={msg}
137
+ isLast={i === displayMessages.length - 1}
138
+ />
89
139
  ))}
90
140
  </Box>
91
141
  )