@miphamai/cli 0.16.0 → 0.16.2

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,14 +1,17 @@
1
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'
7
8
  import { getPreference, setPreference } from '../config/preferences'
8
9
  import { AgentRegistry } from '../agent/agent-registry'
10
+ import { getBackgroundAgentRegistry } from '../agent/background-registry'
9
11
  import { ChatPanel } from './chat'
10
12
  import { InputBar } from './input'
11
13
  import { ModelPicker } from './picker'
14
+ import { AgentFooter, type AgentEntry } from './agent-footer'
12
15
  import {
13
16
  getCommand,
14
17
  looksLikeSlashCommand,
@@ -47,6 +50,10 @@ interface AgentProgress {
47
50
  name: string
48
51
  description: string
49
52
  startTime: number
53
+ tokensUsed?: number
54
+ isTask?: boolean
55
+ /** Unique identifier for multi-agent tracking. */
56
+ id?: string
50
57
  }
51
58
 
52
59
  // Version is read fresh from package.json at startup via runApp prop
@@ -61,12 +68,12 @@ const PERMISSION_MODES: PermissionMode[] = [
61
68
  'bypassPermissions',
62
69
  ]
63
70
  const PERMISSION_LABELS: Record<PermissionMode, string> = {
64
- default: 'default · reads free',
65
- acceptEdits: 'accept edits · reads + edits free',
71
+ default: 'reads free',
72
+ acceptEdits: 'accept edits on',
66
73
  plan: 'plan mode · read-only',
67
- auto: 'auto mode · hooks gate',
68
- dontAsk: "don't ask · pre-approved only",
69
- bypassPermissions: 'bypass · skip all checks',
74
+ auto: 'auto mode',
75
+ dontAsk: "don't ask",
76
+ bypassPermissions: 'bypass',
70
77
  }
71
78
  const PERMISSION_COLORS: Record<PermissionMode, string> = {
72
79
  default: 'white',
@@ -77,6 +84,34 @@ const PERMISSION_COLORS: Record<PermissionMode, string> = {
77
84
  bypassPermissions: 'red',
78
85
  }
79
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
+
80
115
  export function App({
81
116
  engine,
82
117
  config,
@@ -97,29 +132,81 @@ export function App({
97
132
  const [fastMode, setFastMode] = useState(false)
98
133
  const [effort, setEffort] = useState('high')
99
134
  const [focusMode, setFocusMode] = useState(false)
135
+ const [_ultracodeMode, setUltracodeMode] = useState(false)
100
136
  const [goalText, setGoalText] = useState('')
101
137
  const [permissionMode, setPermissionMode] = useState<PermissionMode>('default')
102
138
  const abortRef = useRef<AbortController | null>(null)
103
139
  const [agentProgress, setAgentProgress] = useState<AgentProgress | null>(null)
104
- 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)
105
144
 
106
- // Load persisted effort on mount
145
+ // Detect git branch on mount
107
146
  useEffect(() => {
108
- const saved = getPreference('lastCodeReviewEffort', 'high')
109
- setEffort(saved)
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
+ }
110
154
  }, [])
111
155
 
112
- // Agent elapsed timer
113
- React.useEffect(() => {
114
- if (!agentProgress) {
115
- setAgentElapsed(0)
116
- 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
+ }
117
208
  }
118
- const interval = setInterval(() => {
119
- setAgentElapsed(Math.floor((Date.now() - agentProgress.startTime) / 1000))
120
- }, 1000)
121
- return () => clearInterval(interval)
122
- }, [agentProgress])
209
+ }, [])
123
210
 
124
211
  // Initialize agent registry (one-time, on mount)
125
212
  useMemo(() => {
@@ -149,6 +236,7 @@ export function App({
149
236
  },
150
237
  setFocusMode: (on: boolean) => setFocusMode(on),
151
238
  setGoal: (text: string) => setGoalText(text),
239
+ setUltracodeMode: (on: boolean) => setUltracodeMode(on),
152
240
  skillsLoader,
153
241
  pluginManager,
154
242
  }),
@@ -307,10 +395,11 @@ export function App({
307
395
  if (chunk.type === 'tool_use' && chunk.toolUse) {
308
396
  const toolName = chunk.toolUse.name
309
397
  const isAgent = toolName === 'Agent' || toolName === 'Task'
398
+ const detail = formatToolDetail(toolName, chunk.toolUse.input)
310
399
 
311
400
  if (isAgent) {
312
401
  setAgentProgress({
313
- name: (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
402
+ name: detail || (chunk.toolUse.input.subagent_type as string) || 'General-purpose',
314
403
  description:
315
404
  (chunk.toolUse.input.description as string) ||
316
405
  (chunk.toolUse.input.prompt as string) ||
@@ -318,14 +407,47 @@ export function App({
318
407
  startTime: Date.now(),
319
408
  })
320
409
  }
321
- // 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
+ ])
322
420
  // Mark that next text chunk starts a new turn
323
421
  isNewTurn = true
324
422
  }
325
423
 
326
424
  if (chunk.type === 'tool_result') {
327
425
  setAgentProgress(null)
328
- // 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
+ }
329
451
  }
330
452
 
331
453
  if (chunk.type === 'error') {
@@ -337,8 +459,8 @@ export function App({
337
459
 
338
460
  if (chunk.type === 'task_notification' && chunk.taskNotification) {
339
461
  const tn = chunk.taskNotification
340
- const emoji = tn.status === 'completed' ? '✅' : '❌'
341
- const label = tn.status === 'completed' ? 'completed' : 'failed'
462
+ const isDone = tn.status === 'completed'
463
+ const symbol = isDone ? '◼' : '✳'
342
464
  const preview = tn.content
343
465
  ? tn.content.slice(0, 120) + (tn.content.length > 120 ? '...' : '')
344
466
  : tn.error
@@ -348,9 +470,17 @@ export function App({
348
470
  ...prev,
349
471
  {
350
472
  role: 'system',
351
- 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
+ },
352
480
  },
353
481
  ])
482
+ // Sync agent footer — mark completed background agents
483
+ syncBgAgents()
354
484
  }
355
485
  }
356
486
  } catch (err) {
@@ -362,13 +492,16 @@ export function App({
362
492
  if (assistantContent) {
363
493
  engine.getContext().saveCheckpoint('post-turn')
364
494
  }
495
+ // Final sync of background agents after the turn completes
496
+ syncBgAgents()
365
497
  }
366
498
  },
367
- [engine, mkCtx],
499
+ [engine, mkCtx, syncBgAgents],
368
500
  )
369
501
 
370
502
  useInput((_input, key) => {
371
- // Global hotkeys
503
+ // Escape: close picker → abort loading (does NOT exit app)
504
+ // Vim mode toggle is handled by InputBar
372
505
  if (key.escape) {
373
506
  if (pickerOpen) {
374
507
  setPickerOpen(false)
@@ -378,75 +511,16 @@ export function App({
378
511
  abortRef.current.abort()
379
512
  return
380
513
  }
381
- process.exit(0)
382
- }
383
- // Ctrl+P → open model picker
384
- if (_input === '\x10') {
385
- setPickerOpen((prev) => !prev)
386
- return
387
- }
388
- // Ctrl+F → toggle focus mode
389
- if (_input === '\x06') {
390
- setFocusMode((prev) => !prev)
391
- return
392
- }
393
- // Ctrl+O → toggle last tool call expand/collapse
394
- if (_input === '\x0f') {
395
- setMessages((prev) => {
396
- const msgs = [...prev]
397
- for (let i = msgs.length - 1; i >= 0; i--) {
398
- if (msgs[i]?.toolMeta) {
399
- const meta = msgs[i]!.toolMeta!
400
- if (meta.collapsed) {
401
- msgs[i] = {
402
- ...msgs[i]!,
403
- content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
404
- toolMeta: { ...meta, collapsed: false },
405
- }
406
- } else {
407
- const short = meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
408
- msgs[i] = {
409
- ...msgs[i]!,
410
- content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
411
- toolMeta: { ...meta, collapsed: true },
412
- }
413
- }
414
- break
415
- }
416
- }
417
- return msgs
418
- })
419
- return
420
- }
421
- // Shift+Tab → cycle through all 6 permission modes
422
- if (key.shift && key.tab) {
423
- setPermissionMode((prev) => {
424
- const idx = PERMISSION_MODES.indexOf(prev)
425
- const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
426
- // Sync to engine — use setMode for proper mode semantics
427
- engine.getPermission().setMode(next)
428
- return next
429
- })
514
+ // When input is empty and not loading, let InputBar handle Escape
515
+ // (it toggles vim mode or clears pending sequences)
430
516
  return
431
517
  }
518
+ // All other global hotkeys (Shift+Tab, Ctrl+P, Ctrl+F, Ctrl+O)
519
+ // are handled in InputBar to avoid ink-text-input conflicts
432
520
  })
433
521
 
434
522
  return (
435
523
  <Box flexDirection="column" padding={1} height="100%">
436
- {/* Agent progress banner */}
437
- {agentProgress && (
438
- <Box flexDirection="column" marginY={1}>
439
- <Text color="cyan" bold>
440
- ⏺ {agentProgress.name} <Text color="yellow">{agentElapsed}s</Text>
441
- </Text>
442
- <Text dimColor>
443
- {agentProgress.description.length > 100
444
- ? `"${agentProgress.description.slice(0, 100)}..."`
445
- : `"${agentProgress.description}"`}
446
- </Text>
447
- </Box>
448
- )}
449
-
450
524
  {/* Header — left-aligned */}
451
525
  <Box flexDirection="column" marginBottom={1}>
452
526
  <Text color="#FFD700" bold>
@@ -481,12 +555,60 @@ export function App({
481
555
  /* Input bar (hidden when picker is open) */
482
556
  <Box flexDirection="column">
483
557
  <Text dimColor>──────────────────────────────</Text>
484
- <InputBar onSubmit={handleSubmit} isLoading={isLoading} />
558
+ <InputBar
559
+ onSubmit={handleSubmit}
560
+ isLoading={isLoading}
561
+ onTogglePicker={() => setPickerOpen((prev) => !prev)}
562
+ onToggleFocus={() => setFocusMode((prev) => !prev)}
563
+ onToggleExpand={() => {
564
+ setMessages((prev) => {
565
+ const msgs = [...prev]
566
+ for (let i = msgs.length - 1; i >= 0; i--) {
567
+ if (msgs[i]?.toolMeta) {
568
+ const meta = msgs[i]!.toolMeta!
569
+ if (meta.collapsed) {
570
+ msgs[i] = {
571
+ ...msgs[i]!,
572
+ content: `🔧 ${meta.name}: ${meta.input}\n📋 Result: ${meta.output || '(pending)'}`,
573
+ toolMeta: { ...meta, collapsed: false },
574
+ }
575
+ } else {
576
+ const short =
577
+ meta.input.length > 50 ? meta.input.slice(0, 50) + '...' : meta.input
578
+ msgs[i] = {
579
+ ...msgs[i]!,
580
+ content: `⏺ ${meta.name} · ${short} (Ctrl+O to expand)`,
581
+ toolMeta: { ...meta, collapsed: true },
582
+ }
583
+ }
584
+ break
585
+ }
586
+ }
587
+ return msgs
588
+ })
589
+ }}
590
+ onCyclePermission={() => {
591
+ setPermissionMode((prev) => {
592
+ const idx = PERMISSION_MODES.indexOf(prev)
593
+ const next = PERMISSION_MODES[(idx + 1) % PERMISSION_MODES.length]!
594
+ engine.getPermission().setMode(next)
595
+ return next
596
+ })
597
+ }}
598
+ onCancel={() => {
599
+ if (abortRef.current) {
600
+ abortRef.current.abort()
601
+ }
602
+ }}
603
+ />
485
604
  <Text dimColor>──────────────────────────────</Text>
486
605
  </Box>
487
606
  )}
488
607
 
489
- {/* Status line */}
608
+ {/* Agent status footer — shows running background agents */}
609
+ <AgentFooter agents={Object.values(runningAgents)} gitBranch={gitBranch} tick={agentTick} />
610
+
611
+ {/* Status line — Claude Code style: compact, action-oriented */}
490
612
  <Box marginTop={1} flexDirection="column">
491
613
  {goalText && (
492
614
  <Box>
@@ -495,11 +617,10 @@ export function App({
495
617
  )}
496
618
  <Box flexDirection="row">
497
619
  <Text color={PERMISSION_COLORS[permissionMode]}>
498
- ● {PERMISSION_LABELS[permissionMode]}
620
+ ⏵⏵ {PERMISSION_LABELS[permissionMode]} (shift+tab to cycle)
499
621
  </Text>
500
- <Text dimColor> · Shift+Tab mode</Text>
501
- <Text dimColor> · Ctrl+P model · Ctrl+F focus · Ctrl+O expand</Text>
502
- <Text dimColor> · Esc cancel · /help · ← agents</Text>
622
+ <Text dimColor> · Esc to interrupt</Text>
623
+ <Text dimColor> · ← agents</Text>
503
624
  </Box>
504
625
  </Box>
505
626
  </Box>
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
  )