tokengolf 0.3.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/.claude/settings.local.json +36 -0
- package/CLAUDE.md +320 -0
- package/README.md +235 -0
- package/dist/cli.js +897 -0
- package/hooks/post-tool-use.js +43 -0
- package/hooks/pre-compact.js +35 -0
- package/hooks/session-end.js +172 -0
- package/hooks/session-start.js +100 -0
- package/hooks/session-stop.js +25 -0
- package/hooks/statusline.sh +72 -0
- package/hooks/user-prompt-submit.js +29 -0
- package/package.json +27 -0
- package/src/cli.js +115 -0
- package/src/components/ActiveRun.js +85 -0
- package/src/components/ScoreCard.js +157 -0
- package/src/components/StartRun.js +156 -0
- package/src/components/StatsView.js +112 -0
- package/src/lib/cost.js +149 -0
- package/src/lib/install.js +163 -0
- package/src/lib/score.js +330 -0
- package/src/lib/state.js +35 -0
- package/src/lib/store.js +76 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useState, useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { ProgressBar } from '@inkjs/ui';
|
|
4
|
+
import { getCurrentRun } from '../lib/state.js';
|
|
5
|
+
import { getModelClass, getEfficiencyRating, getBudgetPct, formatCost, formatElapsed, FLOORS } from '../lib/score.js';
|
|
6
|
+
|
|
7
|
+
export function ActiveRun({ run: initialRun }) {
|
|
8
|
+
const { exit } = useApp();
|
|
9
|
+
const [run, setRun] = useState(initialRun);
|
|
10
|
+
const [tick, setTick] = useState(0);
|
|
11
|
+
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
const interval = setInterval(() => {
|
|
14
|
+
const latest = getCurrentRun();
|
|
15
|
+
if (latest) setRun(latest);
|
|
16
|
+
setTick(t => t + 1);
|
|
17
|
+
}, 2000);
|
|
18
|
+
return () => clearInterval(interval);
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
useInput((input) => { if (input === 'q') exit(); });
|
|
22
|
+
|
|
23
|
+
const mc = getModelClass(run.model);
|
|
24
|
+
const pct = getBudgetPct(run.spent, run.budget);
|
|
25
|
+
const efficiency = getEfficiencyRating(run.spent, run.budget);
|
|
26
|
+
const barColor = pct >= 80 ? 'red' : pct >= 50 ? 'yellow' : 'green';
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
|
|
30
|
+
<Box gap={2}>
|
|
31
|
+
<Text bold color="yellow">⛳ TokenGolf</Text>
|
|
32
|
+
<Text color="gray">Active Run</Text>
|
|
33
|
+
<Text color="gray" dimColor>{formatElapsed(run.startedAt)}</Text>
|
|
34
|
+
</Box>
|
|
35
|
+
|
|
36
|
+
<Box borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1} flexDirection="column" gap={1}>
|
|
37
|
+
<Text bold color="white">{run.quest}</Text>
|
|
38
|
+
|
|
39
|
+
<Box gap={3}>
|
|
40
|
+
<Text>{mc.emoji} <Text color="cyan">{mc.name}</Text></Text>
|
|
41
|
+
<Text color="gray">Budget <Text color="green">${run.budget.toFixed(2)}</Text></Text>
|
|
42
|
+
<Text color="gray">Spent <Text color={barColor}>{formatCost(run.spent)}</Text></Text>
|
|
43
|
+
<Text color={efficiency.color}>{efficiency.emoji} {efficiency.label}</Text>
|
|
44
|
+
</Box>
|
|
45
|
+
|
|
46
|
+
<Box gap={1} alignItems="center">
|
|
47
|
+
<Text color="gray">💰 </Text>
|
|
48
|
+
<Box width={24}><ProgressBar value={Math.min(pct, 100)} /></Box>
|
|
49
|
+
<Text color={barColor}> {pct}%</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
|
|
52
|
+
<Box flexDirection="column" gap={0} marginTop={1}>
|
|
53
|
+
{FLOORS.map((floor, i) => {
|
|
54
|
+
const n = i + 1;
|
|
55
|
+
const done = n < run.floor;
|
|
56
|
+
const active = n === run.floor;
|
|
57
|
+
return (
|
|
58
|
+
<Box key={i} gap={1}>
|
|
59
|
+
<Text color={done ? 'green' : active ? 'yellow' : 'gray'}>
|
|
60
|
+
{done ? '✓' : active ? '▶' : '○'}
|
|
61
|
+
</Text>
|
|
62
|
+
<Text color={done ? 'green' : active ? 'white' : 'gray'} dimColor={!done && !active}>
|
|
63
|
+
Floor {n}: {floor}
|
|
64
|
+
</Text>
|
|
65
|
+
</Box>
|
|
66
|
+
);
|
|
67
|
+
})}
|
|
68
|
+
</Box>
|
|
69
|
+
|
|
70
|
+
<Box gap={3} marginTop={1}>
|
|
71
|
+
<Text color="gray">Prompts <Text color="white">{run.promptCount || 0}</Text></Text>
|
|
72
|
+
<Text color="gray">Tools <Text color="white">{run.totalToolCalls || 0}</Text></Text>
|
|
73
|
+
</Box>
|
|
74
|
+
|
|
75
|
+
{pct >= 80 && pct < 100 && (
|
|
76
|
+
<Box borderStyle="single" borderColor="red" paddingX={1}>
|
|
77
|
+
<Text color="red" bold>⚠️ BUDGET WARNING — {formatCost(run.budget - run.spent)} left</Text>
|
|
78
|
+
</Box>
|
|
79
|
+
)}
|
|
80
|
+
</Box>
|
|
81
|
+
|
|
82
|
+
<Text color="gray" dimColor>tokengolf win [--spent 0.18] · tokengolf bust · q to close</Text>
|
|
83
|
+
</Box>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { getTier, getModelClass, getEffortLevel, getEfficiencyRating, getBudgetPct, formatCost, getHaikuPct } from '../lib/score.js';
|
|
4
|
+
|
|
5
|
+
export function ScoreCard({ run }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
const won = run.status === 'won';
|
|
8
|
+
|
|
9
|
+
useInput((input) => { if (input === 'q') exit(); });
|
|
10
|
+
|
|
11
|
+
useEffect(() => {
|
|
12
|
+
const t = setTimeout(() => exit(), 60000);
|
|
13
|
+
return () => clearTimeout(t);
|
|
14
|
+
}, [exit]);
|
|
15
|
+
|
|
16
|
+
const tier = getTier(run.spent);
|
|
17
|
+
const mc = getModelClass(run.model);
|
|
18
|
+
const flowMode = !run.budget;
|
|
19
|
+
const efficiency = flowMode ? null : getEfficiencyRating(run.spent, run.budget);
|
|
20
|
+
const pct = flowMode ? null : getBudgetPct(run.spent, run.budget);
|
|
21
|
+
const haikuPct = getHaikuPct(run.modelBreakdown, run.spent);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
|
|
25
|
+
|
|
26
|
+
{/* Big status header */}
|
|
27
|
+
<Box borderStyle="double" borderColor={won ? 'yellow' : 'red'} paddingX={2} paddingY={1} flexDirection="column" gap={1}>
|
|
28
|
+
<Text bold color={won ? 'yellow' : 'red'} >
|
|
29
|
+
{won ? '🏆 SESSION COMPLETE' : '💀 BUDGET BUSTED'}
|
|
30
|
+
</Text>
|
|
31
|
+
|
|
32
|
+
<Text color="white" bold>{run.quest ?? <Text color="gray">Flow Mode</Text>}</Text>
|
|
33
|
+
|
|
34
|
+
{/* Score row */}
|
|
35
|
+
<Box gap={4} flexWrap="wrap" marginTop={1}>
|
|
36
|
+
<Box flexDirection="column">
|
|
37
|
+
<Text color="gray" dimColor>SPENT</Text>
|
|
38
|
+
<Text bold color={won ? 'green' : 'red'}>{formatCost(run.spent)}</Text>
|
|
39
|
+
</Box>
|
|
40
|
+
{!flowMode && (
|
|
41
|
+
<>
|
|
42
|
+
<Box flexDirection="column">
|
|
43
|
+
<Text color="gray" dimColor>BUDGET</Text>
|
|
44
|
+
<Text color="white">${run.budget.toFixed(2)}</Text>
|
|
45
|
+
</Box>
|
|
46
|
+
<Box flexDirection="column">
|
|
47
|
+
<Text color="gray" dimColor>USED</Text>
|
|
48
|
+
<Text color={pct > 100 ? 'red' : pct > 80 ? 'yellow' : 'green'}>{pct}%</Text>
|
|
49
|
+
</Box>
|
|
50
|
+
</>
|
|
51
|
+
)}
|
|
52
|
+
<Box flexDirection="column">
|
|
53
|
+
<Text color="gray" dimColor>MODEL</Text>
|
|
54
|
+
<Text color="cyan">{mc.emoji} {mc.name}{[
|
|
55
|
+
run.effort && run.effort !== 'medium' ? getEffortLevel(run.effort)?.label : null,
|
|
56
|
+
run.fastMode ? 'Fast' : null,
|
|
57
|
+
].filter(Boolean).map(s => `·${s}`).join('')}</Text>
|
|
58
|
+
</Box>
|
|
59
|
+
{run.effort && (
|
|
60
|
+
<Box flexDirection="column">
|
|
61
|
+
<Text color="gray" dimColor>EFFORT</Text>
|
|
62
|
+
<Text color={getEffortLevel(run.effort)?.color}>{getEffortLevel(run.effort)?.emoji} {getEffortLevel(run.effort)?.label}</Text>
|
|
63
|
+
</Box>
|
|
64
|
+
)}
|
|
65
|
+
{run.fastMode && (
|
|
66
|
+
<Box flexDirection="column">
|
|
67
|
+
<Text color="gray" dimColor>MODE</Text>
|
|
68
|
+
<Text color="yellow">↯ Fast</Text>
|
|
69
|
+
</Box>
|
|
70
|
+
)}
|
|
71
|
+
<Box flexDirection="column">
|
|
72
|
+
<Text color="gray" dimColor>TIER</Text>
|
|
73
|
+
<Text color={tier.color}>{tier.emoji} {tier.label}</Text>
|
|
74
|
+
</Box>
|
|
75
|
+
</Box>
|
|
76
|
+
|
|
77
|
+
{/* Efficiency (roguelike mode only) */}
|
|
78
|
+
{efficiency && (
|
|
79
|
+
<Box gap={2}>
|
|
80
|
+
<Text bold color={efficiency.color}>{efficiency.emoji} {efficiency.label}</Text>
|
|
81
|
+
</Box>
|
|
82
|
+
)}
|
|
83
|
+
|
|
84
|
+
{/* Achievements */}
|
|
85
|
+
{run.achievements?.length > 0 && (
|
|
86
|
+
<Box flexDirection="column" gap={0} marginTop={1}>
|
|
87
|
+
<Text color="gray" dimColor>Achievements unlocked:</Text>
|
|
88
|
+
{run.achievements.map((a, i) => (
|
|
89
|
+
<Text key={i} color="yellow"> {a.emoji} {a.label}</Text>
|
|
90
|
+
))}
|
|
91
|
+
</Box>
|
|
92
|
+
)}
|
|
93
|
+
|
|
94
|
+
{/* Extended thinking */}
|
|
95
|
+
{run.thinkingInvocations > 0 && (
|
|
96
|
+
<Box flexDirection="column" gap={0} marginTop={1}>
|
|
97
|
+
<Box gap={3} alignItems="center">
|
|
98
|
+
<Text color="gray" dimColor>Extended thinking:</Text>
|
|
99
|
+
<Text color="magenta">🔮 {run.thinkingInvocations}× invoked</Text>
|
|
100
|
+
</Box>
|
|
101
|
+
</Box>
|
|
102
|
+
)}
|
|
103
|
+
|
|
104
|
+
{/* Model breakdown */}
|
|
105
|
+
{run.modelBreakdown && Object.keys(run.modelBreakdown).length > 0 && (
|
|
106
|
+
<Box flexDirection="column" gap={0} marginTop={1}>
|
|
107
|
+
<Box gap={2} alignItems="center">
|
|
108
|
+
<Text color="gray" dimColor>Model usage:</Text>
|
|
109
|
+
{haikuPct !== null && (
|
|
110
|
+
<Text color={haikuPct >= 75 ? 'magenta' : haikuPct >= 50 ? 'cyan' : 'yellow'}>
|
|
111
|
+
🏹 {haikuPct}% Haiku
|
|
112
|
+
</Text>
|
|
113
|
+
)}
|
|
114
|
+
</Box>
|
|
115
|
+
<Box gap={3} flexWrap="wrap">
|
|
116
|
+
{Object.entries(run.modelBreakdown).map(([model, cost]) => {
|
|
117
|
+
const short = model.includes('haiku') ? 'Haiku' : model.includes('sonnet') ? 'Sonnet' : 'Opus';
|
|
118
|
+
const pctOfTotal = Math.round((cost / run.spent) * 100);
|
|
119
|
+
return (
|
|
120
|
+
<Text key={model} color="gray">{short} <Text color="white">{pctOfTotal}%</Text> <Text dimColor>{formatCost(cost)}</Text></Text>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</Box>
|
|
124
|
+
</Box>
|
|
125
|
+
)}
|
|
126
|
+
|
|
127
|
+
{/* Tool breakdown */}
|
|
128
|
+
{run.toolCalls && Object.keys(run.toolCalls).length > 0 && (
|
|
129
|
+
<Box flexDirection="column" gap={0} marginTop={1}>
|
|
130
|
+
<Text color="gray" dimColor>Tool calls:</Text>
|
|
131
|
+
<Box gap={2} flexWrap="wrap">
|
|
132
|
+
{Object.entries(run.toolCalls).map(([tool, count]) => (
|
|
133
|
+
<Text key={tool} color="gray"><Text color="white">{tool}</Text> ×{count}</Text>
|
|
134
|
+
))}
|
|
135
|
+
</Box>
|
|
136
|
+
</Box>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
{/* Death tip */}
|
|
140
|
+
{!won && run.budget && (
|
|
141
|
+
<Box borderStyle="single" borderColor="red" paddingX={1} marginTop={1} flexDirection="column">
|
|
142
|
+
<Text color="red" bold>Cause of death: Budget exceeded by {formatCost(run.spent - run.budget)}</Text>
|
|
143
|
+
<Text color="gray" dimColor>Tip: Use Read with line ranges instead of full file reads.</Text>
|
|
144
|
+
</Box>
|
|
145
|
+
)}
|
|
146
|
+
</Box>
|
|
147
|
+
|
|
148
|
+
<Box gap={2}>
|
|
149
|
+
<Text color="gray" dimColor>tokengolf start — run again</Text>
|
|
150
|
+
<Text color="gray" dimColor>·</Text>
|
|
151
|
+
<Text color="gray" dimColor>tokengolf stats — career stats</Text>
|
|
152
|
+
<Text color="gray" dimColor>·</Text>
|
|
153
|
+
<Text color="gray" dimColor>q to exit</Text>
|
|
154
|
+
</Box>
|
|
155
|
+
</Box>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Box, Text, useApp } from 'ink';
|
|
3
|
+
import { TextInput, Select, ConfirmInput } from '@inkjs/ui';
|
|
4
|
+
import { setCurrentRun } from '../lib/state.js';
|
|
5
|
+
import { getModelClass, getEffortLevel, getModelBudgets, FLOORS } from '../lib/score.js';
|
|
6
|
+
|
|
7
|
+
const MODEL_OPTIONS = [
|
|
8
|
+
{ label: '⚔️ Sonnet — Balanced. The default run. [Normal]', value: 'claude-sonnet-4-6' },
|
|
9
|
+
{ label: '🏹 Haiku — Glass cannon. Hard mode. [Hard]', value: 'claude-haiku-4-5-20251001' },
|
|
10
|
+
{ label: '🧙 Opus — Powerful but expensive. [Easy]', value: 'claude-opus-4-6' },
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
const EFFORT_OPTIONS_BASE = [
|
|
14
|
+
{ label: '⚖️ Medium — Balanced (Anthropic recommended for Sonnet)', value: 'medium' },
|
|
15
|
+
{ label: '🪶 Low — Fewest tokens, fastest, cheapest', value: 'low' },
|
|
16
|
+
{ label: '🔥 High — Most thorough, costs more', value: 'high' },
|
|
17
|
+
];
|
|
18
|
+
const EFFORT_OPTIONS_OPUS = [
|
|
19
|
+
...EFFORT_OPTIONS_BASE,
|
|
20
|
+
{ label: '💥 Max — Absolute max, no token constraints (Opus only)', value: 'max' },
|
|
21
|
+
];
|
|
22
|
+
const getEffortOptions = (model) =>
|
|
23
|
+
model.toLowerCase().includes('opus') ? EFFORT_OPTIONS_OPUS : EFFORT_OPTIONS_BASE;
|
|
24
|
+
|
|
25
|
+
function getBudgetOptions(model) {
|
|
26
|
+
const b = getModelBudgets(model);
|
|
27
|
+
return [
|
|
28
|
+
{ label: `💎 Diamond — $${b.diamond.toFixed(2)} surgical micro-task`, value: String(b.diamond) },
|
|
29
|
+
{ label: `🥇 Gold — $${b.gold.toFixed(2)} focused small task`, value: String(b.gold) },
|
|
30
|
+
{ label: `🥈 Silver — $${b.silver.toFixed(2)} medium task`, value: String(b.silver) },
|
|
31
|
+
{ label: `🥉 Bronze — $${b.bronze.toFixed(2)} heavy / complex`, value: String(b.bronze) },
|
|
32
|
+
{ label: `✏️ Custom — set your own`, value: 'custom' },
|
|
33
|
+
];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function StartRun() {
|
|
37
|
+
const { exit } = useApp();
|
|
38
|
+
const [step, setStep] = useState('quest');
|
|
39
|
+
const [quest, setQuest] = useState('');
|
|
40
|
+
const [model, setModel] = useState('');
|
|
41
|
+
const [effort, setEffort] = useState('medium');
|
|
42
|
+
const [budgetVal, setBudgetVal] = useState('');
|
|
43
|
+
|
|
44
|
+
const budget = parseFloat(budgetVal) || 0;
|
|
45
|
+
const mc = model ? getModelClass(model) : null;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<Box flexDirection="column" gap={1} paddingX={1} paddingY={1}>
|
|
49
|
+
<Box gap={2}>
|
|
50
|
+
<Text bold color="yellow">⛳ TokenGolf</Text>
|
|
51
|
+
<Text color="gray">New Run</Text>
|
|
52
|
+
</Box>
|
|
53
|
+
|
|
54
|
+
<Box flexDirection="column" gap={1} borderStyle="single" borderColor="gray" paddingX={1} paddingY={1}>
|
|
55
|
+
|
|
56
|
+
{/* Quest */}
|
|
57
|
+
<Box gap={2} alignItems="flex-start">
|
|
58
|
+
<Text color={step === 'quest' ? 'cyan' : 'gray'}>📋 Quest </Text>
|
|
59
|
+
{step === 'quest'
|
|
60
|
+
? <TextInput placeholder="What are you shipping?" onSubmit={v => { if (v.trim()) { setQuest(v.trim()); setStep('model'); } }} />
|
|
61
|
+
: <Text color="white">{quest}</Text>}
|
|
62
|
+
</Box>
|
|
63
|
+
|
|
64
|
+
{/* Model */}
|
|
65
|
+
{step !== 'quest' && (
|
|
66
|
+
<Box flexDirection="column" gap={0}>
|
|
67
|
+
<Box gap={2}>
|
|
68
|
+
<Text color={step === 'model' ? 'cyan' : 'gray'}>🎮 Class </Text>
|
|
69
|
+
{step !== 'model' && <Text color="white">{mc?.emoji} {mc?.name} [{mc?.difficulty}]</Text>}
|
|
70
|
+
</Box>
|
|
71
|
+
{step === 'model' && <Select options={MODEL_OPTIONS} onChange={v => {
|
|
72
|
+
setModel(v);
|
|
73
|
+
if (v.toLowerCase().includes('haiku')) { setEffort(null); setStep('budget'); }
|
|
74
|
+
else setStep('effort');
|
|
75
|
+
}} />}
|
|
76
|
+
</Box>
|
|
77
|
+
)}
|
|
78
|
+
|
|
79
|
+
{/* Effort */}
|
|
80
|
+
{(step === 'effort' || step === 'budget' || step === 'custom' || step === 'confirm') && effort !== null && (
|
|
81
|
+
<Box flexDirection="column" gap={0}>
|
|
82
|
+
<Box gap={2}>
|
|
83
|
+
<Text color={step === 'effort' ? 'cyan' : 'gray'}>⚡ Effort </Text>
|
|
84
|
+
{step !== 'effort' && effort && <Text color="white">{getEffortLevel(effort)?.emoji} {getEffortLevel(effort)?.label}</Text>}
|
|
85
|
+
</Box>
|
|
86
|
+
{step === 'effort' && (
|
|
87
|
+
<Select options={getEffortOptions(model)} onChange={v => { setEffort(v); setStep('budget'); }} />
|
|
88
|
+
)}
|
|
89
|
+
</Box>
|
|
90
|
+
)}
|
|
91
|
+
|
|
92
|
+
{/* Budget */}
|
|
93
|
+
{(step === 'budget' || step === 'custom' || step === 'confirm') && (
|
|
94
|
+
<Box flexDirection="column" gap={0}>
|
|
95
|
+
<Box gap={2}>
|
|
96
|
+
<Text color={step === 'budget' || step === 'custom' ? 'cyan' : 'gray'}>💰 Budget </Text>
|
|
97
|
+
{step === 'confirm' && <Text color="green">${budget.toFixed(2)}</Text>}
|
|
98
|
+
</Box>
|
|
99
|
+
{step === 'budget' && (
|
|
100
|
+
<Select options={getBudgetOptions(model)} onChange={v => {
|
|
101
|
+
if (v === 'custom') { setStep('custom'); }
|
|
102
|
+
else { setBudgetVal(v); setStep('confirm'); }
|
|
103
|
+
}} />
|
|
104
|
+
)}
|
|
105
|
+
{step === 'custom' && (
|
|
106
|
+
<TextInput placeholder="Enter amount e.g. 0.50" onSubmit={v => {
|
|
107
|
+
const n = parseFloat(v);
|
|
108
|
+
if (!isNaN(n) && n > 0) { setBudgetVal(String(n)); setStep('confirm'); }
|
|
109
|
+
}} />
|
|
110
|
+
)}
|
|
111
|
+
</Box>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{/* Confirm */}
|
|
115
|
+
{step === 'confirm' && (
|
|
116
|
+
<Box flexDirection="column" gap={1} marginTop={1}>
|
|
117
|
+
<Box borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1} flexDirection="column">
|
|
118
|
+
<Text bold color="yellow">Ready?</Text>
|
|
119
|
+
<Text color="gray">Quest <Text color="white">{quest}</Text></Text>
|
|
120
|
+
<Text color="gray">Model <Text color="white">{mc?.emoji} {mc?.name} [{mc?.difficulty}]</Text></Text>
|
|
121
|
+
{effort && <Text color="gray">Effort <Text color="white">{getEffortLevel(effort)?.emoji} {getEffortLevel(effort)?.label}</Text></Text>}
|
|
122
|
+
<Text color="gray">Budget <Text color="green">${budget.toFixed(2)}</Text></Text>
|
|
123
|
+
</Box>
|
|
124
|
+
<Box gap={1}>
|
|
125
|
+
<Text color="gray">Confirm? </Text>
|
|
126
|
+
<ConfirmInput
|
|
127
|
+
onConfirm={() => {
|
|
128
|
+
setCurrentRun({
|
|
129
|
+
quest, model, budget, effort,
|
|
130
|
+
spent: 0,
|
|
131
|
+
status: 'active',
|
|
132
|
+
floor: 1,
|
|
133
|
+
totalFloors: FLOORS.length,
|
|
134
|
+
promptCount: 0,
|
|
135
|
+
totalToolCalls: 0,
|
|
136
|
+
toolCalls: {},
|
|
137
|
+
startedAt: new Date().toISOString(),
|
|
138
|
+
});
|
|
139
|
+
exit();
|
|
140
|
+
}}
|
|
141
|
+
onCancel={() => setStep('quest')}
|
|
142
|
+
/>
|
|
143
|
+
</Box>
|
|
144
|
+
</Box>
|
|
145
|
+
)}
|
|
146
|
+
</Box>
|
|
147
|
+
|
|
148
|
+
{step !== 'confirm' && (
|
|
149
|
+
<Text color="gray" dimColor>Use ↑↓ to navigate, Enter to select</Text>
|
|
150
|
+
)}
|
|
151
|
+
{step === 'confirm' && (
|
|
152
|
+
<Text color="gray" dimColor>After confirming, work normally in Claude Code. Run `tokengolf win` or `tokengolf bust` when done.</Text>
|
|
153
|
+
)}
|
|
154
|
+
</Box>
|
|
155
|
+
);
|
|
156
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Box, Text, useApp, useInput } from 'ink';
|
|
3
|
+
import { getTier, getModelClass, getBudgetPct, formatCost } from '../lib/score.js';
|
|
4
|
+
|
|
5
|
+
export function StatsView({ stats }) {
|
|
6
|
+
const { exit } = useApp();
|
|
7
|
+
|
|
8
|
+
useInput((input) => { if (input === 'q') exit(); });
|
|
9
|
+
|
|
10
|
+
if (stats.total === 0) {
|
|
11
|
+
return (
|
|
12
|
+
<Box paddingX={1} paddingY={1} flexDirection="column" gap={1}>
|
|
13
|
+
<Text bold color="yellow">⛳ TokenGolf Stats</Text>
|
|
14
|
+
<Text color="gray">No completed runs yet.</Text>
|
|
15
|
+
<Text color="gray">Start one: <Text color="cyan">tokengolf start</Text></Text>
|
|
16
|
+
</Box>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Box flexDirection="column" paddingX={1} paddingY={1} gap={1}>
|
|
22
|
+
<Box gap={2}>
|
|
23
|
+
<Text bold color="yellow">⛳ TokenGolf</Text>
|
|
24
|
+
<Text color="gray">Career Stats</Text>
|
|
25
|
+
</Box>
|
|
26
|
+
|
|
27
|
+
{/* Top line */}
|
|
28
|
+
<Box borderStyle="single" borderColor="gray" paddingX={1} paddingY={1} gap={4}>
|
|
29
|
+
<Box flexDirection="column">
|
|
30
|
+
<Text color="gray" dimColor>RUNS</Text>
|
|
31
|
+
<Text bold color="white">{stats.total}</Text>
|
|
32
|
+
</Box>
|
|
33
|
+
<Box flexDirection="column">
|
|
34
|
+
<Text color="gray" dimColor>WINS</Text>
|
|
35
|
+
<Text bold color="green">{stats.wins}</Text>
|
|
36
|
+
</Box>
|
|
37
|
+
<Box flexDirection="column">
|
|
38
|
+
<Text color="gray" dimColor>DEATHS</Text>
|
|
39
|
+
<Text bold color="red">{stats.deaths}</Text>
|
|
40
|
+
</Box>
|
|
41
|
+
<Box flexDirection="column">
|
|
42
|
+
<Text color="gray" dimColor>WIN RATE</Text>
|
|
43
|
+
<Text bold color={stats.winRate >= 70 ? 'green' : stats.winRate >= 40 ? 'yellow' : 'red'}>
|
|
44
|
+
{stats.winRate}%
|
|
45
|
+
</Text>
|
|
46
|
+
</Box>
|
|
47
|
+
<Box flexDirection="column">
|
|
48
|
+
<Text color="gray" dimColor>AVG SPEND</Text>
|
|
49
|
+
<Text bold color="cyan">{formatCost(stats.avgSpend)}</Text>
|
|
50
|
+
</Box>
|
|
51
|
+
</Box>
|
|
52
|
+
|
|
53
|
+
{/* Personal best */}
|
|
54
|
+
{stats.bestRun && (() => {
|
|
55
|
+
const bestTier = getTier(stats.bestRun.spent);
|
|
56
|
+
const bestMc = getModelClass(stats.bestRun.model);
|
|
57
|
+
return (
|
|
58
|
+
<Box flexDirection="column" gap={0}>
|
|
59
|
+
<Text color="yellow">🏆 Personal Best</Text>
|
|
60
|
+
<Box borderStyle="round" borderColor="yellow" paddingX={1} paddingY={1} flexDirection="column">
|
|
61
|
+
<Text color="white">{stats.bestRun.quest}</Text>
|
|
62
|
+
<Box gap={3} marginTop={1}>
|
|
63
|
+
<Text color="green">{formatCost(stats.bestRun.spent)}</Text>
|
|
64
|
+
<Text color="gray">of ${stats.bestRun.budget?.toFixed(2)}</Text>
|
|
65
|
+
<Text>{bestMc.emoji}</Text>
|
|
66
|
+
<Text color={bestTier.color}>{bestTier.emoji} {bestTier.label}</Text>
|
|
67
|
+
</Box>
|
|
68
|
+
</Box>
|
|
69
|
+
</Box>
|
|
70
|
+
);
|
|
71
|
+
})()}
|
|
72
|
+
|
|
73
|
+
{/* Recent runs */}
|
|
74
|
+
<Box flexDirection="column" gap={0}>
|
|
75
|
+
<Text color="gray" dimColor>Recent runs:</Text>
|
|
76
|
+
{stats.recentRuns.slice(0, 8).map((run, i) => {
|
|
77
|
+
const won = run.status === 'won';
|
|
78
|
+
const tier = getTier(run.spent);
|
|
79
|
+
const mc = getModelClass(run.model);
|
|
80
|
+
const pct = getBudgetPct(run.spent, run.budget);
|
|
81
|
+
return (
|
|
82
|
+
<Box key={i} gap={2}>
|
|
83
|
+
<Text color={won ? 'green' : 'red'}>{won ? '✓' : '✗'}</Text>
|
|
84
|
+
<Text color="white">{(run.quest || '').slice(0, 34).padEnd(34)}</Text>
|
|
85
|
+
<Text color={won ? 'green' : 'red'}>{formatCost(run.spent)}</Text>
|
|
86
|
+
<Text color="gray">/{formatCost(run.budget)}</Text>
|
|
87
|
+
<Text>{mc.emoji}</Text>
|
|
88
|
+
<Text color={tier.color}>{tier.emoji}</Text>
|
|
89
|
+
<Text color="gray" dimColor>{pct}%</Text>
|
|
90
|
+
</Box>
|
|
91
|
+
);
|
|
92
|
+
})}
|
|
93
|
+
</Box>
|
|
94
|
+
|
|
95
|
+
{/* Achievements */}
|
|
96
|
+
{stats.achievements.length > 0 && (
|
|
97
|
+
<Box flexDirection="column" gap={0}>
|
|
98
|
+
<Text color="gray" dimColor>Recent achievements:</Text>
|
|
99
|
+
<Box flexWrap="wrap" gap={1}>
|
|
100
|
+
{stats.achievements.slice(0, 12).map((a, i) => (
|
|
101
|
+
<Box key={i} borderStyle="single" borderColor="gray" paddingX={1}>
|
|
102
|
+
<Text>{a.emoji} <Text color="gray" dimColor>{a.label}</Text></Text>
|
|
103
|
+
</Box>
|
|
104
|
+
))}
|
|
105
|
+
</Box>
|
|
106
|
+
</Box>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
<Text color="gray" dimColor>q to exit</Text>
|
|
110
|
+
</Box>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/lib/cost.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
|
|
5
|
+
// Pricing per million tokens (Anthropic list prices)
|
|
6
|
+
const PRICING = {
|
|
7
|
+
'claude-opus-4': { input: 15.00, output: 75.00, cacheWrite: 18.75, cacheRead: 1.50 },
|
|
8
|
+
'claude-sonnet-4': { input: 3.00, output: 15.00, cacheWrite: 3.75, cacheRead: 0.30 },
|
|
9
|
+
'claude-haiku-4': { input: 0.80, output: 4.00, cacheWrite: 1.00, cacheRead: 0.08 },
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function getPrice(model) {
|
|
13
|
+
const lower = (model || '').toLowerCase();
|
|
14
|
+
for (const [key, price] of Object.entries(PRICING)) {
|
|
15
|
+
if (lower.includes(key)) return price;
|
|
16
|
+
}
|
|
17
|
+
return PRICING['claude-sonnet-4'];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getProjectDir(cwd) {
|
|
21
|
+
return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/\//g, '-'));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseCostFromTranscript(transcriptPath) {
|
|
25
|
+
try {
|
|
26
|
+
const lines = fs.readFileSync(transcriptPath, 'utf8').trim().split('\n');
|
|
27
|
+
let total = 0;
|
|
28
|
+
const byModel = {};
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
try {
|
|
31
|
+
const entry = JSON.parse(line);
|
|
32
|
+
if (entry.type === 'assistant' && entry.message?.usage && entry.message?.model) {
|
|
33
|
+
const model = entry.message.model;
|
|
34
|
+
const p = getPrice(model);
|
|
35
|
+
const u = entry.message.usage;
|
|
36
|
+
const cost = (u.input_tokens || 0) / 1e6 * p.input
|
|
37
|
+
+ (u.output_tokens || 0) / 1e6 * p.output
|
|
38
|
+
+ (u.cache_creation_input_tokens || 0) / 1e6 * p.cacheWrite
|
|
39
|
+
+ (u.cache_read_input_tokens || 0) / 1e6 * p.cacheRead;
|
|
40
|
+
total += cost;
|
|
41
|
+
byModel[model] = (byModel[model] || 0) + cost;
|
|
42
|
+
}
|
|
43
|
+
} catch { /* skip malformed lines */ }
|
|
44
|
+
}
|
|
45
|
+
return total > 0 ? { total, byModel } : null;
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Returns all transcript paths modified at or after sinceMs
|
|
52
|
+
function findTranscriptsSince(projectDir, sinceMs) {
|
|
53
|
+
try {
|
|
54
|
+
return fs.readdirSync(projectDir)
|
|
55
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
56
|
+
.map(f => ({ p: path.join(projectDir, f), mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
|
|
57
|
+
.filter(({ mtime }) => mtime >= sinceMs)
|
|
58
|
+
.map(({ p }) => p);
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function parseThinkingFromTranscripts(paths) {
|
|
65
|
+
let invocations = 0;
|
|
66
|
+
let tokens = 0;
|
|
67
|
+
for (const p of paths) {
|
|
68
|
+
try {
|
|
69
|
+
const lines = fs.readFileSync(p, 'utf8').trim().split('\n');
|
|
70
|
+
for (const line of lines) {
|
|
71
|
+
try {
|
|
72
|
+
const entry = JSON.parse(line);
|
|
73
|
+
if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) {
|
|
74
|
+
const thinkBlocks = entry.message.content.filter(b => b.type === 'thinking');
|
|
75
|
+
if (thinkBlocks.length > 0) {
|
|
76
|
+
invocations++;
|
|
77
|
+
for (const block of thinkBlocks) {
|
|
78
|
+
tokens += Math.round((block.thinking?.length || 0) / 4);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
} catch { /* skip malformed lines */ }
|
|
83
|
+
}
|
|
84
|
+
} catch { /* skip unreadable files */ }
|
|
85
|
+
}
|
|
86
|
+
return invocations > 0 ? { thinkingInvocations: invocations, thinkingTokens: tokens } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Aggregate costs across multiple transcript files
|
|
90
|
+
function parseAllTranscripts(paths) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
const byModel = {};
|
|
93
|
+
for (const p of paths) {
|
|
94
|
+
const result = parseCostFromTranscript(p);
|
|
95
|
+
if (!result) continue;
|
|
96
|
+
total += result.total;
|
|
97
|
+
for (const [model, cost] of Object.entries(result.byModel)) {
|
|
98
|
+
byModel[model] = (byModel[model] || 0) + cost;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return total > 0 ? { total, byModel } : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function findTranscript(sessionId, projectDir) {
|
|
105
|
+
if (sessionId) {
|
|
106
|
+
try {
|
|
107
|
+
const p = path.join(projectDir, `${sessionId}.jsonl`);
|
|
108
|
+
fs.accessSync(p);
|
|
109
|
+
return p;
|
|
110
|
+
} catch { /* fall through */ }
|
|
111
|
+
}
|
|
112
|
+
// Fall back to most recently modified transcript
|
|
113
|
+
try {
|
|
114
|
+
const files = fs.readdirSync(projectDir)
|
|
115
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
116
|
+
.map(f => ({ f, mtime: fs.statSync(path.join(projectDir, f)).mtimeMs }))
|
|
117
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
118
|
+
return files.length ? path.join(projectDir, files[0].f) : null;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Returns { spent, modelBreakdown } or null
|
|
125
|
+
export function autoDetectCost(run) {
|
|
126
|
+
const projectDir = getProjectDir(process.cwd());
|
|
127
|
+
|
|
128
|
+
// Scan all transcripts modified since session start to capture subagent sidechains
|
|
129
|
+
const sinceMs = run.startedAt ? new Date(run.startedAt).getTime() : 0;
|
|
130
|
+
const paths = sinceMs > 0
|
|
131
|
+
? findTranscriptsSince(projectDir, sinceMs)
|
|
132
|
+
: [findTranscript(run.sessionId, projectDir)].filter(Boolean);
|
|
133
|
+
|
|
134
|
+
const parsed = paths.length > 0 ? parseAllTranscripts(paths) : null;
|
|
135
|
+
|
|
136
|
+
// Always prefer fresh transcript parse; fall back to run.spent if no transcripts found
|
|
137
|
+
const spent = parsed?.total ?? (run.spent > 0 ? run.spent : null);
|
|
138
|
+
if (spent === null) return null;
|
|
139
|
+
|
|
140
|
+
// Always use parsed model breakdown (Stop hook doesn't capture it)
|
|
141
|
+
const modelBreakdown = parsed?.byModel ?? run.modelBreakdown ?? null;
|
|
142
|
+
const thinking = parseThinkingFromTranscripts(paths);
|
|
143
|
+
return {
|
|
144
|
+
spent,
|
|
145
|
+
modelBreakdown,
|
|
146
|
+
thinkingInvocations: thinking?.thinkingInvocations ?? 0,
|
|
147
|
+
thinkingTokens: thinking?.thinkingTokens ?? 0,
|
|
148
|
+
};
|
|
149
|
+
}
|