tokengolf 0.3.0 → 0.4.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.
- package/.husky/pre-commit +4 -0
- package/.prettierignore +2 -0
- package/.prettierrc +6 -0
- package/.vscode/settings.json +15 -0
- package/CHANGELOG.md +254 -0
- package/CLAUDE.md +136 -10
- package/README.md +89 -47
- package/assets/demo-hud.png +0 -0
- package/assets/scorecard.png +0 -0
- package/dist/cli.js +790 -103
- package/docs/assets/demo-hud.png +0 -0
- package/docs/assets/scorecard.png +0 -0
- package/docs/assets/tokengolf-bg-min.jpg +0 -0
- package/docs/index.html +1080 -0
- package/eslint.config.js +39 -0
- package/hooks/post-tool-use-failure.js +27 -0
- package/hooks/post-tool-use.js +11 -7
- package/hooks/pre-compact.js +9 -3
- package/hooks/session-end.js +168 -42
- package/hooks/session-start.js +31 -11
- package/hooks/session-stop.js +6 -2
- package/hooks/statusline.sh +16 -7
- package/hooks/stop.js +27 -0
- package/hooks/subagent-start.js +27 -0
- package/hooks/user-prompt-submit.js +8 -6
- package/package.json +16 -3
- package/src/cli.js +23 -6
- package/src/components/ActiveRun.js +76 -24
- package/src/components/ScoreCard.js +132 -37
- package/src/components/StartRun.js +156 -53
- package/src/components/StatsView.js +89 -37
- package/src/lib/__tests__/score.test.js +596 -0
- package/src/lib/cost.js +84 -21
- package/src/lib/demo.js +186 -0
- package/src/lib/install.js +92 -62
- package/src/lib/score.js +433 -136
- package/src/lib/store.js +11 -11
- package/.claude/settings.local.json +0 -36
|
@@ -6,30 +6,36 @@ import { getModelClass, getEffortLevel, getModelBudgets, FLOORS } from '../lib/s
|
|
|
6
6
|
|
|
7
7
|
const MODEL_OPTIONS = [
|
|
8
8
|
{ label: '⚔️ Sonnet — Balanced. The default run. [Normal]', value: 'claude-sonnet-4-6' },
|
|
9
|
-
{ label: '🏹 Haiku — Glass cannon. Hard mode. [Hard]',
|
|
10
|
-
{ label: '
|
|
9
|
+
{ label: '🏹 Haiku — Glass cannon. Hard mode. [Hard]', value: 'claude-haiku-4-5-20251001' },
|
|
10
|
+
{ label: '⚜️ Paladin — Opus plans, Sonnet executes. [Calculated]', value: 'opusplan' },
|
|
11
|
+
{ label: '🧙 Opus — Powerful but expensive. [Easy]', value: 'claude-opus-4-6' },
|
|
11
12
|
];
|
|
12
13
|
|
|
13
14
|
const EFFORT_OPTIONS_BASE = [
|
|
14
15
|
{ label: '⚖️ Medium — Balanced (Anthropic recommended for Sonnet)', value: 'medium' },
|
|
15
|
-
{ label: '🪶 Low — Fewest tokens, fastest, cheapest',
|
|
16
|
-
{ label: '🔥 High — Most thorough, costs more',
|
|
16
|
+
{ label: '🪶 Low — Fewest tokens, fastest, cheapest', value: 'low' },
|
|
17
|
+
{ label: '🔥 High — Most thorough, costs more', value: 'high' },
|
|
17
18
|
];
|
|
18
19
|
const EFFORT_OPTIONS_OPUS = [
|
|
19
20
|
...EFFORT_OPTIONS_BASE,
|
|
20
|
-
{ label: '💥 Max — Absolute max, no token constraints (Opus only)', value: 'max'
|
|
21
|
+
{ label: '💥 Max — Absolute max, no token constraints (Opus only)', value: 'max' },
|
|
21
22
|
];
|
|
22
23
|
const getEffortOptions = (model) =>
|
|
23
|
-
model.toLowerCase().includes('opus')
|
|
24
|
+
model.toLowerCase().includes('opus') && !model.toLowerCase().includes('opusplan')
|
|
25
|
+
? EFFORT_OPTIONS_OPUS
|
|
26
|
+
: EFFORT_OPTIONS_BASE;
|
|
24
27
|
|
|
25
28
|
function getBudgetOptions(model) {
|
|
26
29
|
const b = getModelBudgets(model);
|
|
27
30
|
return [
|
|
28
|
-
{
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
{ label:
|
|
31
|
+
{
|
|
32
|
+
label: `💎 Diamond — $${b.diamond.toFixed(2)} surgical micro-task`,
|
|
33
|
+
value: String(b.diamond),
|
|
34
|
+
},
|
|
35
|
+
{ label: `🥇 Gold — $${b.gold.toFixed(2)} focused small task`, value: String(b.gold) },
|
|
36
|
+
{ label: `🥈 Silver — $${b.silver.toFixed(2)} medium task`, value: String(b.silver) },
|
|
37
|
+
{ label: `🥉 Bronze — $${b.bronze.toFixed(2)} heavy / complex`, value: String(b.bronze) },
|
|
38
|
+
{ label: `✏️ Custom — set your own`, value: 'custom' },
|
|
33
39
|
];
|
|
34
40
|
}
|
|
35
41
|
|
|
@@ -47,66 +53,121 @@ export function StartRun() {
|
|
|
47
53
|
return (
|
|
48
54
|
<Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
|
|
49
55
|
<Box gap={2}>
|
|
50
|
-
<Text bold color="yellow"
|
|
56
|
+
<Text bold color="yellow">
|
|
57
|
+
⛳ TokenGolf
|
|
58
|
+
</Text>
|
|
51
59
|
<Text color="gray">New Run</Text>
|
|
52
60
|
</Box>
|
|
53
61
|
|
|
54
|
-
<Box
|
|
55
|
-
|
|
62
|
+
<Box
|
|
63
|
+
flexDirection="column"
|
|
64
|
+
gap={1}
|
|
65
|
+
borderStyle="single"
|
|
66
|
+
borderColor="gray"
|
|
67
|
+
paddingX={1}
|
|
68
|
+
paddingY={1}
|
|
69
|
+
>
|
|
56
70
|
{/* Quest */}
|
|
57
71
|
<Box gap={2} alignItems="flex-start">
|
|
58
|
-
<Text color={step === 'quest' ? 'cyan' : 'gray'}>📋 Quest
|
|
59
|
-
{step === 'quest'
|
|
60
|
-
|
|
61
|
-
|
|
72
|
+
<Text color={step === 'quest' ? 'cyan' : 'gray'}>📋 Quest </Text>
|
|
73
|
+
{step === 'quest' ? (
|
|
74
|
+
<TextInput
|
|
75
|
+
placeholder="What are you shipping?"
|
|
76
|
+
onSubmit={(v) => {
|
|
77
|
+
if (v.trim()) {
|
|
78
|
+
setQuest(v.trim());
|
|
79
|
+
setStep('model');
|
|
80
|
+
}
|
|
81
|
+
}}
|
|
82
|
+
/>
|
|
83
|
+
) : (
|
|
84
|
+
<Text color="white">{quest}</Text>
|
|
85
|
+
)}
|
|
62
86
|
</Box>
|
|
63
87
|
|
|
64
88
|
{/* Model */}
|
|
65
89
|
{step !== 'quest' && (
|
|
66
90
|
<Box flexDirection="column" gap={0}>
|
|
67
91
|
<Box gap={2}>
|
|
68
|
-
<Text color={step === 'model' ? 'cyan' : 'gray'}>🎮 Class
|
|
69
|
-
{step !== 'model' &&
|
|
92
|
+
<Text color={step === 'model' ? 'cyan' : 'gray'}>🎮 Class </Text>
|
|
93
|
+
{step !== 'model' && (
|
|
94
|
+
<Text color="white">
|
|
95
|
+
{mc?.emoji} {mc?.name} [{mc?.difficulty}]
|
|
96
|
+
</Text>
|
|
97
|
+
)}
|
|
70
98
|
</Box>
|
|
71
|
-
{step === 'model' &&
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
99
|
+
{step === 'model' && (
|
|
100
|
+
<Select
|
|
101
|
+
options={MODEL_OPTIONS}
|
|
102
|
+
onChange={(v) => {
|
|
103
|
+
setModel(v);
|
|
104
|
+
if (v.toLowerCase().includes('haiku')) {
|
|
105
|
+
setEffort(null);
|
|
106
|
+
setStep('budget');
|
|
107
|
+
} else setStep('effort');
|
|
108
|
+
}}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
76
111
|
</Box>
|
|
77
112
|
)}
|
|
78
113
|
|
|
79
114
|
{/* Effort */}
|
|
80
|
-
{(step === 'effort' || step === 'budget' || step === 'custom' || step === 'confirm') &&
|
|
81
|
-
|
|
82
|
-
<Box gap={
|
|
83
|
-
<
|
|
84
|
-
|
|
115
|
+
{(step === 'effort' || step === 'budget' || step === 'custom' || step === 'confirm') &&
|
|
116
|
+
effort !== null && (
|
|
117
|
+
<Box flexDirection="column" gap={0}>
|
|
118
|
+
<Box gap={2}>
|
|
119
|
+
<Text color={step === 'effort' ? 'cyan' : 'gray'}>⚡ Effort </Text>
|
|
120
|
+
{step !== 'effort' && effort && (
|
|
121
|
+
<Text color="white">
|
|
122
|
+
{getEffortLevel(effort)?.emoji} {getEffortLevel(effort)?.label}
|
|
123
|
+
</Text>
|
|
124
|
+
)}
|
|
125
|
+
</Box>
|
|
126
|
+
{step === 'effort' && (
|
|
127
|
+
<Select
|
|
128
|
+
options={getEffortOptions(model)}
|
|
129
|
+
onChange={(v) => {
|
|
130
|
+
setEffort(v);
|
|
131
|
+
setStep('budget');
|
|
132
|
+
}}
|
|
133
|
+
/>
|
|
134
|
+
)}
|
|
85
135
|
</Box>
|
|
86
|
-
|
|
87
|
-
<Select options={getEffortOptions(model)} onChange={v => { setEffort(v); setStep('budget'); }} />
|
|
88
|
-
)}
|
|
89
|
-
</Box>
|
|
90
|
-
)}
|
|
136
|
+
)}
|
|
91
137
|
|
|
92
138
|
{/* Budget */}
|
|
93
139
|
{(step === 'budget' || step === 'custom' || step === 'confirm') && (
|
|
94
140
|
<Box flexDirection="column" gap={0}>
|
|
95
141
|
<Box gap={2}>
|
|
96
|
-
<Text color={step === 'budget' || step === 'custom' ? 'cyan' : 'gray'}
|
|
142
|
+
<Text color={step === 'budget' || step === 'custom' ? 'cyan' : 'gray'}>
|
|
143
|
+
💰 Budget{' '}
|
|
144
|
+
</Text>
|
|
97
145
|
{step === 'confirm' && <Text color="green">${budget.toFixed(2)}</Text>}
|
|
98
146
|
</Box>
|
|
99
147
|
{step === 'budget' && (
|
|
100
|
-
<Select
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
148
|
+
<Select
|
|
149
|
+
options={getBudgetOptions(model)}
|
|
150
|
+
onChange={(v) => {
|
|
151
|
+
if (v === 'custom') {
|
|
152
|
+
setStep('custom');
|
|
153
|
+
} else {
|
|
154
|
+
setBudgetVal(v);
|
|
155
|
+
setStep('confirm');
|
|
156
|
+
}
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
104
159
|
)}
|
|
105
160
|
{step === 'custom' && (
|
|
106
|
-
<TextInput
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
161
|
+
<TextInput
|
|
162
|
+
placeholder="Enter amount e.g. 0.50"
|
|
163
|
+
onSubmit={(v) => {
|
|
164
|
+
const n = parseFloat(v);
|
|
165
|
+
if (!isNaN(n) && n > 0) {
|
|
166
|
+
setBudgetVal(String(n));
|
|
167
|
+
setStep('confirm');
|
|
168
|
+
}
|
|
169
|
+
}}
|
|
170
|
+
/>
|
|
110
171
|
)}
|
|
111
172
|
</Box>
|
|
112
173
|
)}
|
|
@@ -114,26 +175,63 @@ export function StartRun() {
|
|
|
114
175
|
{/* Confirm */}
|
|
115
176
|
{step === 'confirm' && (
|
|
116
177
|
<Box flexDirection="column" gap={1} marginTop={1}>
|
|
117
|
-
<Box
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
178
|
+
<Box
|
|
179
|
+
borderStyle="round"
|
|
180
|
+
borderColor="yellow"
|
|
181
|
+
paddingX={1}
|
|
182
|
+
paddingY={1}
|
|
183
|
+
flexDirection="column"
|
|
184
|
+
>
|
|
185
|
+
<Text bold color="yellow">
|
|
186
|
+
Ready?
|
|
187
|
+
</Text>
|
|
188
|
+
<Text color="gray">
|
|
189
|
+
Quest <Text color="white">{quest}</Text>
|
|
190
|
+
</Text>
|
|
191
|
+
<Text color="gray">
|
|
192
|
+
Model{' '}
|
|
193
|
+
<Text color="white">
|
|
194
|
+
{mc?.emoji} {mc?.name} [{mc?.difficulty}]
|
|
195
|
+
</Text>
|
|
196
|
+
</Text>
|
|
197
|
+
{effort && (
|
|
198
|
+
<Text color="gray">
|
|
199
|
+
Effort{' '}
|
|
200
|
+
<Text color="white">
|
|
201
|
+
{getEffortLevel(effort)?.emoji} {getEffortLevel(effort)?.label}
|
|
202
|
+
</Text>
|
|
203
|
+
</Text>
|
|
204
|
+
)}
|
|
205
|
+
<Text color="gray">
|
|
206
|
+
Budget <Text color="green">${budget.toFixed(2)}</Text>
|
|
207
|
+
</Text>
|
|
123
208
|
</Box>
|
|
124
209
|
<Box gap={1}>
|
|
125
210
|
<Text color="gray">Confirm? </Text>
|
|
126
211
|
<ConfirmInput
|
|
127
212
|
onConfirm={() => {
|
|
128
213
|
setCurrentRun({
|
|
129
|
-
|
|
214
|
+
id: `run_${Date.now()}`,
|
|
215
|
+
quest,
|
|
216
|
+
model,
|
|
217
|
+
budget,
|
|
218
|
+
effort,
|
|
130
219
|
spent: 0,
|
|
131
220
|
status: 'active',
|
|
221
|
+
mode: 'roguelike',
|
|
132
222
|
floor: 1,
|
|
133
223
|
totalFloors: FLOORS.length,
|
|
134
224
|
promptCount: 0,
|
|
135
225
|
totalToolCalls: 0,
|
|
136
226
|
toolCalls: {},
|
|
227
|
+
failedToolCalls: 0,
|
|
228
|
+
subagentSpawns: 0,
|
|
229
|
+
turnCount: 0,
|
|
230
|
+
thinkingInvocations: 0,
|
|
231
|
+
thinkingTokens: 0,
|
|
232
|
+
fainted: false,
|
|
233
|
+
sessionCount: 1,
|
|
234
|
+
compactionEvents: [],
|
|
137
235
|
startedAt: new Date().toISOString(),
|
|
138
236
|
});
|
|
139
237
|
exit();
|
|
@@ -146,10 +244,15 @@ export function StartRun() {
|
|
|
146
244
|
</Box>
|
|
147
245
|
|
|
148
246
|
{step !== 'confirm' && (
|
|
149
|
-
<Text color="gray" dimColor>
|
|
247
|
+
<Text color="gray" dimColor>
|
|
248
|
+
Use ↑↓ to navigate, Enter to select
|
|
249
|
+
</Text>
|
|
150
250
|
)}
|
|
151
251
|
{step === 'confirm' && (
|
|
152
|
-
<Text color="gray" dimColor>
|
|
252
|
+
<Text color="gray" dimColor>
|
|
253
|
+
After confirming, work normally in Claude Code. Run `tokengolf win` or `tokengolf bust`
|
|
254
|
+
when done.
|
|
255
|
+
</Text>
|
|
153
256
|
)}
|
|
154
257
|
</Box>
|
|
155
258
|
);
|
|
@@ -5,14 +5,20 @@ import { getTier, getModelClass, getBudgetPct, formatCost } from '../lib/score.j
|
|
|
5
5
|
export function StatsView({ stats }) {
|
|
6
6
|
const { exit } = useApp();
|
|
7
7
|
|
|
8
|
-
useInput((input) => {
|
|
8
|
+
useInput((input) => {
|
|
9
|
+
if (input === 'q') exit();
|
|
10
|
+
});
|
|
9
11
|
|
|
10
12
|
if (stats.total === 0) {
|
|
11
13
|
return (
|
|
12
14
|
<Box paddingX={1} paddingY={1} flexDirection="column" gap={1}>
|
|
13
|
-
<Text bold color="yellow"
|
|
15
|
+
<Text bold color="yellow">
|
|
16
|
+
⛳ TokenGolf Stats
|
|
17
|
+
</Text>
|
|
14
18
|
<Text color="gray">No completed runs yet.</Text>
|
|
15
|
-
<Text color="gray">
|
|
19
|
+
<Text color="gray">
|
|
20
|
+
Start one: <Text color="cyan">tokengolf start</Text>
|
|
21
|
+
</Text>
|
|
16
22
|
</Box>
|
|
17
23
|
);
|
|
18
24
|
}
|
|
@@ -20,73 +26,110 @@ export function StatsView({ stats }) {
|
|
|
20
26
|
return (
|
|
21
27
|
<Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
|
|
22
28
|
<Box gap={2}>
|
|
23
|
-
<Text bold color="yellow"
|
|
29
|
+
<Text bold color="yellow">
|
|
30
|
+
⛳ TokenGolf
|
|
31
|
+
</Text>
|
|
24
32
|
<Text color="gray">Career Stats</Text>
|
|
25
33
|
</Box>
|
|
26
34
|
|
|
27
35
|
{/* Top line */}
|
|
28
36
|
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1} gap={4}>
|
|
29
37
|
<Box flexDirection="column">
|
|
30
|
-
<Text color="gray" dimColor>
|
|
31
|
-
|
|
38
|
+
<Text color="gray" dimColor>
|
|
39
|
+
RUNS
|
|
40
|
+
</Text>
|
|
41
|
+
<Text bold color="white">
|
|
42
|
+
{stats.total}
|
|
43
|
+
</Text>
|
|
32
44
|
</Box>
|
|
33
45
|
<Box flexDirection="column">
|
|
34
|
-
<Text color="gray" dimColor>
|
|
35
|
-
|
|
46
|
+
<Text color="gray" dimColor>
|
|
47
|
+
WINS
|
|
48
|
+
</Text>
|
|
49
|
+
<Text bold color="green">
|
|
50
|
+
{stats.wins}
|
|
51
|
+
</Text>
|
|
36
52
|
</Box>
|
|
37
53
|
<Box flexDirection="column">
|
|
38
|
-
<Text color="gray" dimColor>
|
|
39
|
-
|
|
54
|
+
<Text color="gray" dimColor>
|
|
55
|
+
DEATHS
|
|
56
|
+
</Text>
|
|
57
|
+
<Text bold color="red">
|
|
58
|
+
{stats.deaths}
|
|
59
|
+
</Text>
|
|
40
60
|
</Box>
|
|
41
61
|
<Box flexDirection="column">
|
|
42
|
-
<Text color="gray" dimColor>
|
|
62
|
+
<Text color="gray" dimColor>
|
|
63
|
+
WIN RATE
|
|
64
|
+
</Text>
|
|
43
65
|
<Text bold color={stats.winRate >= 70 ? 'green' : stats.winRate >= 40 ? 'yellow' : 'red'}>
|
|
44
66
|
{stats.winRate}%
|
|
45
67
|
</Text>
|
|
46
68
|
</Box>
|
|
47
69
|
<Box flexDirection="column">
|
|
48
|
-
<Text color="gray" dimColor>
|
|
49
|
-
|
|
70
|
+
<Text color="gray" dimColor>
|
|
71
|
+
AVG SPEND
|
|
72
|
+
</Text>
|
|
73
|
+
<Text bold color="cyan">
|
|
74
|
+
{formatCost(stats.avgSpend)}
|
|
75
|
+
</Text>
|
|
50
76
|
</Box>
|
|
51
77
|
</Box>
|
|
52
78
|
|
|
53
79
|
{/* Personal best */}
|
|
54
|
-
{stats.bestRun &&
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
<
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
{stats.bestRun &&
|
|
81
|
+
(() => {
|
|
82
|
+
const bestTier = getTier(stats.bestRun.spent);
|
|
83
|
+
const bestMc = getModelClass(stats.bestRun.model);
|
|
84
|
+
return (
|
|
85
|
+
<Box flexDirection="column" gap={0}>
|
|
86
|
+
<Text color="yellow">🏆 Personal Best</Text>
|
|
87
|
+
<Box
|
|
88
|
+
borderStyle="round"
|
|
89
|
+
borderColor="yellow"
|
|
90
|
+
paddingX={1}
|
|
91
|
+
paddingY={1}
|
|
92
|
+
flexDirection="column"
|
|
93
|
+
>
|
|
94
|
+
<Text color="white">{stats.bestRun.quest}</Text>
|
|
95
|
+
<Box gap={3} marginTop={1}>
|
|
96
|
+
<Text color="green">{formatCost(stats.bestRun.spent)}</Text>
|
|
97
|
+
{stats.bestRun.budget ? (
|
|
98
|
+
<Text color="gray">of ${stats.bestRun.budget.toFixed(2)}</Text>
|
|
99
|
+
) : null}
|
|
100
|
+
<Text>{bestMc.emoji}</Text>
|
|
101
|
+
<Text color={bestTier.color}>
|
|
102
|
+
{bestTier.emoji} {bestTier.label}
|
|
103
|
+
</Text>
|
|
104
|
+
</Box>
|
|
67
105
|
</Box>
|
|
68
106
|
</Box>
|
|
69
|
-
|
|
70
|
-
)
|
|
71
|
-
})()}
|
|
107
|
+
);
|
|
108
|
+
})()}
|
|
72
109
|
|
|
73
110
|
{/* Recent runs */}
|
|
74
111
|
<Box flexDirection="column" gap={0}>
|
|
75
|
-
<Text color="gray" dimColor>
|
|
112
|
+
<Text color="gray" dimColor>
|
|
113
|
+
Recent runs:
|
|
114
|
+
</Text>
|
|
76
115
|
{stats.recentRuns.slice(0, 8).map((run, i) => {
|
|
77
116
|
const won = run.status === 'won';
|
|
78
117
|
const tier = getTier(run.spent);
|
|
79
118
|
const mc = getModelClass(run.model);
|
|
80
|
-
const pct = getBudgetPct(run.spent, run.budget);
|
|
119
|
+
const pct = run.budget ? getBudgetPct(run.spent, run.budget) : null;
|
|
81
120
|
return (
|
|
82
121
|
<Box key={i} gap={2}>
|
|
83
122
|
<Text color={won ? 'green' : 'red'}>{won ? '✓' : '✗'}</Text>
|
|
84
|
-
<Text color="white">{(run.quest || '').slice(0, 34).padEnd(34)}</Text>
|
|
123
|
+
<Text color="white">{(run.quest || 'Flow').slice(0, 34).padEnd(34)}</Text>
|
|
85
124
|
<Text color={won ? 'green' : 'red'}>{formatCost(run.spent)}</Text>
|
|
86
|
-
<Text color="gray">/{formatCost(run.budget)}</Text>
|
|
125
|
+
{run.budget ? <Text color="gray">/{formatCost(run.budget)}</Text> : null}
|
|
87
126
|
<Text>{mc.emoji}</Text>
|
|
88
127
|
<Text color={tier.color}>{tier.emoji}</Text>
|
|
89
|
-
|
|
128
|
+
{pct !== null && (
|
|
129
|
+
<Text color="gray" dimColor>
|
|
130
|
+
{pct}%
|
|
131
|
+
</Text>
|
|
132
|
+
)}
|
|
90
133
|
</Box>
|
|
91
134
|
);
|
|
92
135
|
})}
|
|
@@ -95,18 +138,27 @@ export function StatsView({ stats }) {
|
|
|
95
138
|
{/* Achievements */}
|
|
96
139
|
{stats.achievements.length > 0 && (
|
|
97
140
|
<Box flexDirection="column" gap={0}>
|
|
98
|
-
<Text color="gray" dimColor>
|
|
141
|
+
<Text color="gray" dimColor>
|
|
142
|
+
Recent achievements:
|
|
143
|
+
</Text>
|
|
99
144
|
<Box flexWrap="wrap" gap={1}>
|
|
100
145
|
{stats.achievements.slice(0, 12).map((a, i) => (
|
|
101
146
|
<Box key={i} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
102
|
-
<Text>
|
|
147
|
+
<Text>
|
|
148
|
+
{a.emoji}{' '}
|
|
149
|
+
<Text color="gray" dimColor>
|
|
150
|
+
{a.label}
|
|
151
|
+
</Text>
|
|
152
|
+
</Text>
|
|
103
153
|
</Box>
|
|
104
154
|
))}
|
|
105
155
|
</Box>
|
|
106
156
|
</Box>
|
|
107
157
|
)}
|
|
108
158
|
|
|
109
|
-
<Text color="gray" dimColor>
|
|
159
|
+
<Text color="gray" dimColor>
|
|
160
|
+
q to exit
|
|
161
|
+
</Text>
|
|
110
162
|
</Box>
|
|
111
163
|
);
|
|
112
164
|
}
|