@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/dist/mipham +0 -0
- package/package.json +2 -4
- package/src/agent/message-bus.ts +19 -1
- package/src/agent/sub-agent.ts +25 -2
- package/src/commands/environment.ts +270 -0
- package/src/commands/git.ts +152 -0
- package/src/commands/project.ts +1011 -0
- package/src/config/defaults.ts +4 -0
- package/src/config/preferences.ts +45 -0
- package/src/core/context.ts +9 -0
- package/src/core/engine.ts +17 -5
- package/src/core/permission-config.ts +70 -6
- package/src/core/permission.ts +50 -9
- package/src/core/session-store.ts +7 -2
- package/src/index.tsx +51 -2
- package/src/providers/registry.ts +8 -0
- package/src/shared/sanitize.ts +38 -0
- package/src/shared/types.ts +18 -0
- package/src/skills/community-registry.json +72 -0
- package/src/skills/registry.ts +71 -77
- package/src/tools/exec/bash.ts +40 -8
- package/src/tools/exec/enter-worktree.ts +23 -1
- package/src/tools/exec/exit-worktree.ts +13 -0
- package/src/tools/index.ts +5 -2
- package/src/ui/agent-footer.tsx +89 -0
- package/src/ui/app.tsx +169 -35
- package/src/ui/chat.tsx +80 -30
- package/src/ui/commands.ts +176 -1477
- package/src/ui/strip-indent.ts +19 -0
- package/src/workflow/runtime.ts +29 -31
- package/src/workflow/sandbox.ts +22 -10
package/src/skills/registry.ts
CHANGED
|
@@ -10,6 +10,8 @@ import { join } from 'node:path'
|
|
|
10
10
|
import { homedir } from 'node:os'
|
|
11
11
|
import { spawnSync } from 'node:child_process'
|
|
12
12
|
import { URL } from 'node:url'
|
|
13
|
+
import type { MiphamConfig } from '../shared/types.js'
|
|
14
|
+
import communitySkills from './community-registry.json'
|
|
13
15
|
|
|
14
16
|
const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
|
|
15
17
|
|
|
@@ -38,81 +40,7 @@ export interface SkillEntry {
|
|
|
38
40
|
* Embedded community skill registry.
|
|
39
41
|
* In the future, this can be fetched from a remote URL.
|
|
40
42
|
*/
|
|
41
|
-
const COMMUNITY_SKILLS: SkillEntry[] = [
|
|
42
|
-
{
|
|
43
|
-
name: 'code-review',
|
|
44
|
-
description:
|
|
45
|
-
'Automated code review with multiple dimensions — complexity, security, performance, quality',
|
|
46
|
-
url: 'https://github.com/One-Mipham/skill-code-review',
|
|
47
|
-
file: 'code-review.SKILL.md',
|
|
48
|
-
category: 'Development',
|
|
49
|
-
author: 'MiphamAI',
|
|
50
|
-
builtin: true,
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
name: 'systematic-debugging',
|
|
54
|
-
description: 'Systematic debugging workflow — find root cause before fixing',
|
|
55
|
-
url: 'https://github.com/anthropics/skills',
|
|
56
|
-
file: 'systematic-debugging.SKILL.md',
|
|
57
|
-
category: 'Development',
|
|
58
|
-
author: 'Anthropic',
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
name: 'test-driven-development',
|
|
62
|
-
description: 'TDD workflow — write failing test first, then implement',
|
|
63
|
-
url: 'https://github.com/anthropics/skills',
|
|
64
|
-
file: 'test-driven-development.SKILL.md',
|
|
65
|
-
category: 'Development',
|
|
66
|
-
author: 'Anthropic',
|
|
67
|
-
builtin: true,
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
name: 'web-access',
|
|
71
|
-
description:
|
|
72
|
-
'Web search, scraping, and browser automation — search engines, page fetching, authenticated sites',
|
|
73
|
-
url: 'https://github.com/One-Mipham/skill-web-access',
|
|
74
|
-
file: 'web-access.SKILL.md',
|
|
75
|
-
category: 'Network',
|
|
76
|
-
author: 'MiphamAI',
|
|
77
|
-
builtin: true,
|
|
78
|
-
},
|
|
79
|
-
{
|
|
80
|
-
name: 'doc-generator',
|
|
81
|
-
description: 'Generate API docs, READMEs, and changelogs from code analysis',
|
|
82
|
-
url: 'https://github.com/One-Mipham/skill-doc-generator',
|
|
83
|
-
file: 'doc-generator.SKILL.md',
|
|
84
|
-
category: 'Documentation',
|
|
85
|
-
author: 'MiphamAI',
|
|
86
|
-
builtin: true,
|
|
87
|
-
},
|
|
88
|
-
{
|
|
89
|
-
name: 'github-ops',
|
|
90
|
-
description: 'GitHub PR/issue management and automation — labels, milestones, reviews',
|
|
91
|
-
url: 'https://github.com/One-Mipham/skill-github-ops',
|
|
92
|
-
file: 'github-ops.SKILL.md',
|
|
93
|
-
category: 'DevOps',
|
|
94
|
-
author: 'MiphamAI',
|
|
95
|
-
builtin: true,
|
|
96
|
-
},
|
|
97
|
-
{
|
|
98
|
-
name: 'security-review',
|
|
99
|
-
description:
|
|
100
|
-
'Security audit and vulnerability scanning for code changes — OWASP, secrets, supply chain',
|
|
101
|
-
url: 'https://github.com/One-Mipham/skill-security-review',
|
|
102
|
-
file: 'security-review.SKILL.md',
|
|
103
|
-
category: 'Security',
|
|
104
|
-
author: 'MiphamAI',
|
|
105
|
-
builtin: true,
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
name: 'frontend-design',
|
|
109
|
-
description: 'Distinctive visual design guidance for UI components — typography, color, layout',
|
|
110
|
-
url: 'https://github.com/anthropics/skills',
|
|
111
|
-
file: 'frontend-design.SKILL.md',
|
|
112
|
-
category: 'Design',
|
|
113
|
-
author: 'Anthropic',
|
|
114
|
-
},
|
|
115
|
-
]
|
|
43
|
+
const COMMUNITY_SKILLS: SkillEntry[] = communitySkills as SkillEntry[]
|
|
116
44
|
|
|
117
45
|
// ═══════════════════════════════════════════════════════════════
|
|
118
46
|
// Skill Installer
|
|
@@ -141,13 +69,58 @@ export function searchSkills(query: string): SkillEntry[] {
|
|
|
141
69
|
)
|
|
142
70
|
}
|
|
143
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Match a marketplace pattern against an owner/repo.
|
|
74
|
+
*
|
|
75
|
+
* - "owner/*" → matches any repo under that owner
|
|
76
|
+
* - "owner/repo" → exact match
|
|
77
|
+
*/
|
|
78
|
+
function matchOwnerPattern(pattern: string, owner: string, repo: string): boolean {
|
|
79
|
+
if (pattern.endsWith('/*')) {
|
|
80
|
+
return pattern.slice(0, -2) === owner
|
|
81
|
+
}
|
|
82
|
+
return pattern === repo
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Check whether a GitHub URL (or any URL) is allowed by the marketplace config.
|
|
87
|
+
*
|
|
88
|
+
* Rules (in order):
|
|
89
|
+
* 1. No config → allow everything
|
|
90
|
+
* 2. blockedMarketplaces matched → deny (deny wins over allow)
|
|
91
|
+
* 3. strictKnownMarketplaces set → deny unless matched
|
|
92
|
+
* 4. Non-GitHub URL with no strict list → allow
|
|
93
|
+
* 5. Non-GitHub URL with strict list → deny
|
|
94
|
+
*/
|
|
95
|
+
function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace']): boolean {
|
|
96
|
+
if (!config) return true
|
|
97
|
+
const { strictKnownMarketplaces, blockedMarketplaces } = config
|
|
98
|
+
const match = url.match(/github\.com\/([^/]+\/[^/]+)/)
|
|
99
|
+
if (!match) return !strictKnownMarketplaces?.length // non-GitHub: allow only if no strict list
|
|
100
|
+
const repo = match[1]! // "owner/repo"
|
|
101
|
+
const [owner] = repo.split('/')
|
|
102
|
+
|
|
103
|
+
// Check blocked first (deny wins)
|
|
104
|
+
if (blockedMarketplaces?.some((pattern) => matchOwnerPattern(pattern, owner!, repo))) return false
|
|
105
|
+
// Check strict allowlist
|
|
106
|
+
if (
|
|
107
|
+
strictKnownMarketplaces?.length &&
|
|
108
|
+
!strictKnownMarketplaces.some((pattern) => matchOwnerPattern(pattern, owner!, repo))
|
|
109
|
+
)
|
|
110
|
+
return false
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
144
114
|
/**
|
|
145
115
|
* Install a skill from a GitHub repository.
|
|
146
116
|
*
|
|
147
117
|
* Clones the repo to a temp directory, copies the skill file(s),
|
|
148
118
|
* and cleans up.
|
|
149
119
|
*/
|
|
150
|
-
export function installSkill(
|
|
120
|
+
export function installSkill(
|
|
121
|
+
skillName: string,
|
|
122
|
+
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
123
|
+
): InstallResult {
|
|
151
124
|
const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
|
|
152
125
|
if (!entry) {
|
|
153
126
|
return {
|
|
@@ -157,6 +130,15 @@ export function installSkill(skillName: string): InstallResult {
|
|
|
157
130
|
}
|
|
158
131
|
}
|
|
159
132
|
|
|
133
|
+
// Check marketplace restrictions
|
|
134
|
+
if (!isMarketplaceAllowed(entry.url, marketplaceConfig)) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
name: skillName,
|
|
138
|
+
message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${entry.url}`,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
160
142
|
// Built-in skills are already available — no download needed
|
|
161
143
|
if (entry.builtin) {
|
|
162
144
|
return {
|
|
@@ -214,7 +196,19 @@ export function installSkill(skillName: string): InstallResult {
|
|
|
214
196
|
/**
|
|
215
197
|
* Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
|
|
216
198
|
*/
|
|
217
|
-
export function installSkillFromUrl(
|
|
199
|
+
export function installSkillFromUrl(
|
|
200
|
+
url: string,
|
|
201
|
+
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
202
|
+
): InstallResult {
|
|
203
|
+
// Check marketplace restrictions
|
|
204
|
+
if (!isMarketplaceAllowed(url, marketplaceConfig)) {
|
|
205
|
+
return {
|
|
206
|
+
success: false,
|
|
207
|
+
name: url,
|
|
208
|
+
message: `URL is from a blocked or unapproved marketplace: ${url}`,
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
218
212
|
// Derive skill name from URL
|
|
219
213
|
const name =
|
|
220
214
|
url
|
package/src/tools/exec/bash.ts
CHANGED
|
@@ -28,9 +28,9 @@ const BLOCKED_PATTERNS = [
|
|
|
28
28
|
/\$\(/,
|
|
29
29
|
// Backtick command substitution
|
|
30
30
|
/`[^`]+`/,
|
|
31
|
-
// Interpreter code execution (bypass vector)
|
|
32
|
-
/\
|
|
33
|
-
/\
|
|
31
|
+
// Interpreter code execution (bypass vector) — covers python, python2, python3
|
|
32
|
+
/\bpython[23]?\s+-c\b/,
|
|
33
|
+
/\bpython[23]?\s+-m\b/,
|
|
34
34
|
/\bperl\s+-[ep]\b/,
|
|
35
35
|
/\bruby\s+-e\b/,
|
|
36
36
|
/\bnode\s+-e\b/,
|
|
@@ -52,6 +52,18 @@ const BLOCKED_PATTERNS = [
|
|
|
52
52
|
/\bscp\s+.*(?:\.ssh|\.aws|\.env)/,
|
|
53
53
|
// Write to system paths
|
|
54
54
|
/>\s*\/(?:etc|usr|boot|sys|proc)\//,
|
|
55
|
+
// P0 hardening — ANSI-C quoting bypass (e.g. $'\x72\x6d' = rm)
|
|
56
|
+
/\$'\\x[0-9a-fA-F]{2}/,
|
|
57
|
+
// P0 hardening — nested interpreter invocation
|
|
58
|
+
/\b(?:bash|sh|zsh|dash|ksh)\s+-c\b/,
|
|
59
|
+
// P0 hardening — eval builtin (obfuscation vector)
|
|
60
|
+
/\beval\s+/,
|
|
61
|
+
// P0 hardening — exec redirect bypass (e.g. exec >/dev/sda)
|
|
62
|
+
/\bexec\s+\d*>/,
|
|
63
|
+
// P0 hardening — source/dot builtin (script sourcing)
|
|
64
|
+
/\bsource\s+/,
|
|
65
|
+
// P0 hardening — base64 decode + pipe
|
|
66
|
+
/\bbase64\s+(?:-d|--decode)\b/,
|
|
55
67
|
]
|
|
56
68
|
|
|
57
69
|
const BLOCKED_COMMANDS = [
|
|
@@ -71,18 +83,38 @@ const BLOCKED_COMMANDS = [
|
|
|
71
83
|
'init',
|
|
72
84
|
'telinit',
|
|
73
85
|
'systemctl',
|
|
86
|
+
'eval',
|
|
87
|
+
'exec',
|
|
88
|
+
'source',
|
|
89
|
+
'.',
|
|
74
90
|
]
|
|
75
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Normalize ANSI-C escape sequences ($'...') in a command string.
|
|
94
|
+
* Converts hex escapes (\xHH) back to literal characters so that
|
|
95
|
+
* existing patterns (e.g. rm -rf /) still catch obfuscated payloads.
|
|
96
|
+
*/
|
|
97
|
+
function normalizeEscapes(command: string): string {
|
|
98
|
+
return command.replace(/\$'([^']*)'/g, (_, inner: string) =>
|
|
99
|
+
inner.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex: string) =>
|
|
100
|
+
String.fromCharCode(parseInt(hex, 16)),
|
|
101
|
+
),
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
76
105
|
function isBlocked(command: string): string | null {
|
|
77
|
-
//
|
|
78
|
-
const
|
|
106
|
+
// Normalize ANSI-C escape sequences for defense-in-depth
|
|
107
|
+
const normalized = normalizeEscapes(command)
|
|
108
|
+
|
|
109
|
+
// Check exact blocked commands (on normalized command)
|
|
110
|
+
const firstWord = normalized.trim().split(/\s+/)[0]
|
|
79
111
|
if (firstWord && BLOCKED_COMMANDS.includes(firstWord)) {
|
|
80
|
-
return `Command "${firstWord}"
|
|
112
|
+
return `Command "${firstWord}" rejected by security policy.`
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
// Check dangerous patterns
|
|
115
|
+
// Check dangerous patterns (on both original and normalized)
|
|
84
116
|
for (const pattern of BLOCKED_PATTERNS) {
|
|
85
|
-
if (pattern.test(command)) {
|
|
117
|
+
if (pattern.test(command) || pattern.test(normalized)) {
|
|
86
118
|
return `Command rejected by security policy. Pattern matched: ${pattern.source.slice(0, 40)}...`
|
|
87
119
|
}
|
|
88
120
|
}
|
|
@@ -45,9 +45,31 @@ export const enterWorktreeTool: ToolDefinition = {
|
|
|
45
45
|
'Worktree name may only contain letters, digits, dots, underscores, dashes, and slashes.',
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
+
// Prevent path traversal via .. segments
|
|
49
|
+
if (name.includes('..')) {
|
|
50
|
+
return {
|
|
51
|
+
success: false,
|
|
52
|
+
content: '',
|
|
53
|
+
error: 'Worktree name may not contain ".." path segments.',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
48
56
|
|
|
49
57
|
const cwd = ctx.cwd
|
|
50
|
-
const
|
|
58
|
+
const { resolve } = await import('node:path')
|
|
59
|
+
const worktreePath = resolve(`${cwd}/.claude/worktrees/${name}`)
|
|
60
|
+
const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
|
|
61
|
+
|
|
62
|
+
// Defense-in-depth: verify resolved path is within .claude/worktrees/
|
|
63
|
+
if (
|
|
64
|
+
!worktreePath.startsWith(allowedPrefix + '/') &&
|
|
65
|
+
worktreePath !== allowedPrefix.slice(0, -1)
|
|
66
|
+
) {
|
|
67
|
+
return {
|
|
68
|
+
success: false,
|
|
69
|
+
content: '',
|
|
70
|
+
error: 'Worktree path must be within .claude/worktrees/.',
|
|
71
|
+
}
|
|
72
|
+
}
|
|
51
73
|
|
|
52
74
|
try {
|
|
53
75
|
// Validate we're in a git repo
|
|
@@ -36,6 +36,19 @@ export const exitWorktreeTool: ToolDefinition = {
|
|
|
36
36
|
|
|
37
37
|
// Validate the path is under .claude/worktrees
|
|
38
38
|
const cwd = ctx.cwd
|
|
39
|
+
const { resolve } = await import('node:path')
|
|
40
|
+
const resolvedPath = resolve(worktreePath)
|
|
41
|
+
const allowedPrefix = resolve(`${cwd}/.claude/worktrees/`)
|
|
42
|
+
|
|
43
|
+
if (!resolvedPath.startsWith(allowedPrefix)) {
|
|
44
|
+
return {
|
|
45
|
+
success: false,
|
|
46
|
+
content: '',
|
|
47
|
+
error:
|
|
48
|
+
`Path "${worktreePath}" is not under .claude/worktrees/. ` +
|
|
49
|
+
`Only worktrees created by EnterWorktree can be managed here.`,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
39
52
|
|
|
40
53
|
try {
|
|
41
54
|
// Verify the worktree exists in git's worktree list
|
package/src/tools/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition, ToolResult } from '../shared/index.ts'
|
|
2
|
+
import { sanitizeParams } from '../shared/sanitize'
|
|
2
3
|
import { readTool } from './file/read'
|
|
3
4
|
import { writeTool } from './file/write'
|
|
4
5
|
import { editTool } from './file/edit'
|
|
@@ -96,11 +97,13 @@ function withValidation(tool: ToolDefinition): ToolDefinition {
|
|
|
96
97
|
return {
|
|
97
98
|
...tool,
|
|
98
99
|
async execute(params, ctx): Promise<ToolResult> {
|
|
99
|
-
|
|
100
|
+
// Sanitize dangerous Unicode from all inputs
|
|
101
|
+
const cleanParams = sanitizeParams(params)
|
|
102
|
+
const errors = validateParams(schema, cleanParams)
|
|
100
103
|
if (errors.length > 0) {
|
|
101
104
|
return { success: false, content: '', error: `Invalid parameters: ${errors.join('; ')}` }
|
|
102
105
|
}
|
|
103
|
-
return tool.execute(
|
|
106
|
+
return tool.execute(cleanParams, ctx)
|
|
104
107
|
},
|
|
105
108
|
}
|
|
106
109
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { Box, Text } from 'ink'
|
|
3
|
+
|
|
4
|
+
export interface AgentEntry {
|
|
5
|
+
id: string
|
|
6
|
+
name: string
|
|
7
|
+
description: string
|
|
8
|
+
startTime: number
|
|
9
|
+
tokensUsed: number
|
|
10
|
+
status: 'running' | 'completed'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface AgentFooterProps {
|
|
14
|
+
agents: AgentEntry[]
|
|
15
|
+
gitBranch: string
|
|
16
|
+
/** Tick counter for live elapsed-time re-renders. */
|
|
17
|
+
tick: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Format elapsed seconds into human-readable string (e.g. "2m 51s", "38s"). */
|
|
21
|
+
function formatElapsed(seconds: number): string {
|
|
22
|
+
if (seconds < 60) return `${seconds}s`
|
|
23
|
+
const m = Math.floor(seconds / 60)
|
|
24
|
+
const s = seconds % 60
|
|
25
|
+
return `${m}m ${s}s`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Format token count: 42400 → "42.4k", 1500000 → "1.5M". */
|
|
29
|
+
function formatTokens(n: number): string {
|
|
30
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
31
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`
|
|
32
|
+
return String(n)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function AgentFooter({ agents, gitBranch, tick: _tick }: AgentFooterProps) {
|
|
36
|
+
const hasAgents = agents.length > 0
|
|
37
|
+
const hasBranch = gitBranch.length > 0
|
|
38
|
+
|
|
39
|
+
if (!hasAgents && !hasBranch) return null
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<Box flexDirection="column" marginTop={1}>
|
|
43
|
+
{/* Git branch indicator */}
|
|
44
|
+
{hasBranch && (
|
|
45
|
+
<Box>
|
|
46
|
+
<Text dimColor>
|
|
47
|
+
{' '}⏺ {gitBranch}
|
|
48
|
+
</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
)}
|
|
51
|
+
|
|
52
|
+
{/* Agent status lines */}
|
|
53
|
+
{agents.map((agent) => {
|
|
54
|
+
const elapsed = Math.floor((Date.now() - agent.startTime) / 1000)
|
|
55
|
+
const isRunning = agent.status === 'running'
|
|
56
|
+
const symbol = isRunning ? '◯' : '◼'
|
|
57
|
+
const symbolColor = isRunning ? 'cyan' : undefined
|
|
58
|
+
|
|
59
|
+
// Truncate description to keep lines readable
|
|
60
|
+
const desc =
|
|
61
|
+
agent.description.length > 80 ? agent.description.slice(0, 80) + '...' : agent.description
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<Box key={agent.id}>
|
|
65
|
+
<Text dimColor={!isRunning} color={symbolColor}>
|
|
66
|
+
{' '}
|
|
67
|
+
{symbol}{' '}
|
|
68
|
+
</Text>
|
|
69
|
+
<Text bold color={isRunning ? 'cyan' : undefined} dimColor={!isRunning}>
|
|
70
|
+
{agent.name}
|
|
71
|
+
</Text>
|
|
72
|
+
<Text dimColor> {desc}</Text>
|
|
73
|
+
{/* Spacer pushes stats to the right — Ink handles this naturally */}
|
|
74
|
+
<Text dimColor>
|
|
75
|
+
{' '}
|
|
76
|
+
{isRunning ? formatElapsed(elapsed) : 'finished'}
|
|
77
|
+
</Text>
|
|
78
|
+
{isRunning && agent.tokensUsed > 0 && (
|
|
79
|
+
<Text dimColor>
|
|
80
|
+
{' · ↓ '}
|
|
81
|
+
{formatTokens(agent.tokensUsed)} tokens
|
|
82
|
+
</Text>
|
|
83
|
+
)}
|
|
84
|
+
</Box>
|
|
85
|
+
)
|
|
86
|
+
})}
|
|
87
|
+
</Box>
|
|
88
|
+
)
|
|
89
|
+
}
|