@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/package.json +2 -3
- package/src/agent/message-bus.ts +7 -1
- package/src/agent/sub-agent.ts +7 -1
- package/src/commands/environment.ts +270 -0
- package/src/commands/git.ts +152 -0
- package/src/commands/project.ts +1011 -0
- package/src/core/engine.ts +8 -7
- package/src/core/permission.ts +5 -0
- package/src/core/session-store.ts +4 -1
- package/src/index.tsx +6 -5
- package/src/skills/community-registry.json +72 -0
- package/src/skills/registry.ts +2 -75
- package/src/tools/exec/bash.ts +3 -3
- package/src/tools/exec/enter-worktree.ts +23 -1
- package/src/tools/exec/exit-worktree.ts +13 -0
- package/src/ui/agent-footer.tsx +89 -0
- package/src/ui/app.tsx +217 -96
- package/src/ui/chat.tsx +80 -30
- package/src/ui/commands.ts +202 -1484
- package/src/ui/input.tsx +61 -2
- package/src/ui/strip-indent.ts +19 -0
- package/src/workflow/runtime.ts +11 -1
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: '
|
|
65
|
-
acceptEdits: 'accept edits
|
|
71
|
+
default: 'reads free',
|
|
72
|
+
acceptEdits: 'accept edits on',
|
|
66
73
|
plan: 'plan mode · read-only',
|
|
67
|
-
auto: 'auto mode
|
|
68
|
-
dontAsk: "don't ask
|
|
69
|
-
bypassPermissions: 'bypass
|
|
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
|
-
|
|
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
|
-
//
|
|
145
|
+
// Detect git branch on mount
|
|
107
146
|
useEffect(() => {
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
341
|
-
const
|
|
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: `${
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
{/*
|
|
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
|
-
|
|
620
|
+
⏵⏵ {PERMISSION_LABELS[permissionMode]} (shift+tab to cycle)
|
|
499
621
|
</Text>
|
|
500
|
-
<Text dimColor> ·
|
|
501
|
-
<Text dimColor> ·
|
|
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
|
-
<
|
|
60
|
-
key={i}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
)
|