tokmon-app 0.1.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/LICENSE +21 -0
- package/README.md +74 -0
- package/SKILL.md +35 -0
- package/bin/tokmon.mjs +47 -0
- package/commands/tokens.md +9 -0
- package/install.sh +11 -0
- package/package.json +45 -0
- package/scripts/dragon-sprite.mjs +38 -0
- package/scripts/lib.mjs +277 -0
- package/scripts/setup.mjs +81 -0
- package/scripts/snapshot.mjs +32 -0
- package/scripts/statusline.mjs +33 -0
- package/scripts/watch.mjs +161 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mateus Castro
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# tokmon
|
|
2
|
+
|
|
3
|
+
[](https://github.com/MateusCastro2203/tokmon/actions/workflows/test.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
|
|
6
|
+
Skill do [Claude Code](https://claude.com/claude-code) que lê o transcript JSONL que o próprio Claude Code já grava por sessão e mostra: total de tokens gasto, ranking de qual prompt custou mais, quebra por skill/subagente, e % de context window usado. Sem hook, sem API externa, sem auth — só lê o arquivo que já existe em `~/.claude/projects/<projeto>/<sessão>.jsonl`.
|
|
7
|
+
|
|
8
|
+
Fora de escopo: custo em $, e cota/limite de plano (5h/semanal/mensal) — isso já é resolvido pelo [CodexBar](https://github.com/steipete/CodexBar).
|
|
9
|
+
|
|
10
|
+
## Instalar
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g tokmon-app
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Isso expõe o comando `tokmon` no seu terminal.
|
|
17
|
+
|
|
18
|
+
## Usar
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
tokmon snapshot # snapshot de texto da sessão ativa no diretório atual
|
|
22
|
+
tokmon watch # dashboard ao vivo, atualiza sozinho (Ctrl+C sai)
|
|
23
|
+
tokmon help # ajuda
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`tokmon watch` mostra algo assim, com cor de contexto mudando (verde/amarelo/vermelho) conforme o uso sobe:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
╭─ token-monitor — meu-projeto (sessão) ───────────╮
|
|
30
|
+
│ 🔥 Total: 7.7M tok │
|
|
31
|
+
│ in 47.5k · out 103.5k · cache-w 419.3k · ... │
|
|
32
|
+
│ 🧠 Contexto: 70.1% usado (140.2k / 200.0k) │
|
|
33
|
+
╰────────────────────────────────────────────────────╯
|
|
34
|
+
╭─ 🏆 Top prompts ──────────────────────────────────╮
|
|
35
|
+
│ 1. 6.0M "Base directory for this skill..." │
|
|
36
|
+
│ 2. 1.5M "coloque a experiencia em mobile..." │
|
|
37
|
+
╰────────────────────────────────────────────────────╯
|
|
38
|
+
╭─ 🧩 Por skill/agente ─────────────────────────────╮
|
|
39
|
+
│ main 7.7M │
|
|
40
|
+
╰────────────────────────────────────────────────────╯
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Integração com o Claude Code
|
|
44
|
+
|
|
45
|
+
Pra ter a skill disponível dentro do próprio Claude Code (slash command `/tokens` + statusline nativa sempre visível), rode:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
tokmon setup
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Isso:
|
|
52
|
+
1. Symlinka o pacote pra `~/.claude/skills/token-monitor` (a skill em si, com `SKILL.md`).
|
|
53
|
+
2. Copia o comando `/tokens` pra `~/.claude/commands/tokens.md`.
|
|
54
|
+
3. Registra a `statusLine` em `~/.claude/settings.json` automaticamente — preserva todo o resto do arquivo, e nunca sobrescreve uma `statusLine` diferente que você já tenha configurado (a menos que rode `tokmon setup --force`).
|
|
55
|
+
|
|
56
|
+
Depois disso, uma linha compacta (`🔥 sessão: ... · contexto: ...% usado · prompt mais caro: ...`) aparece sozinha no rodapé do terminal do Claude Code, sem precisar rodar nada manualmente.
|
|
57
|
+
|
|
58
|
+
## Privacidade
|
|
59
|
+
|
|
60
|
+
Roda 100% local. Não faz nenhuma chamada de rede, não envia telemetria, não tem chave de API. A única coisa que lê é o `.jsonl` que o próprio Claude Code já grava no seu disco (`~/.claude/projects/**/*.jsonl`) — nada sai da sua máquina.
|
|
61
|
+
|
|
62
|
+
## Desenvolvimento
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone git@github.com:MateusCastro2203/tokmon.git
|
|
66
|
+
cd tokmon
|
|
67
|
+
npm install
|
|
68
|
+
npm link # expõe `tokmon` local pra testar sem publicar
|
|
69
|
+
npm test # node:test — sem dependência extra de test runner
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Licença
|
|
73
|
+
|
|
74
|
+
MIT — veja [LICENSE](LICENSE).
|
package/SKILL.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: token-monitor
|
|
3
|
+
description: Mostra consumo de token da sessão atual do Claude Code — total, ranking de prompt mais caro, quebra por skill/subagente e % de context window. Use quando o usuário perguntar "quanto gastei de token", "qual prompt custou mais", "quebra por skill" ou similar.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# token-monitor
|
|
7
|
+
|
|
8
|
+
Skill pra responder perguntas sobre consumo de token da sessão atual, lendo o transcript JSONL que o próprio Claude Code já grava (sem hook, sem API externa).
|
|
9
|
+
|
|
10
|
+
## Quando usar
|
|
11
|
+
|
|
12
|
+
Quando o usuário perguntar algo como:
|
|
13
|
+
- "quanto gastei de token nessa sessão"
|
|
14
|
+
- "qual prompt custou mais token"
|
|
15
|
+
- "quebra o consumo por skill/agente"
|
|
16
|
+
- "quanto da context window já usei"
|
|
17
|
+
|
|
18
|
+
## Como usar
|
|
19
|
+
|
|
20
|
+
Rode:
|
|
21
|
+
```bash
|
|
22
|
+
node ~/.claude/skills/token-monitor/scripts/snapshot.mjs "$(pwd)"
|
|
23
|
+
```
|
|
24
|
+
e mostre a saída pro usuário — ela já vem formatada, não precisa reformatar.
|
|
25
|
+
|
|
26
|
+
## Dashboard ao vivo (manual, opcional)
|
|
27
|
+
|
|
28
|
+
Pra visão contínua tipo `htop`, o usuário roda isso numa aba de terminal separada (fora do Claude Code):
|
|
29
|
+
```bash
|
|
30
|
+
node ~/.claude/skills/token-monitor/scripts/watch.mjs
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Statusline (visão passiva, sem rodar nada)
|
|
34
|
+
|
|
35
|
+
Se `tokmon setup` (ou `install.sh`) já rodou, `scripts/statusline.mjs` fica registrado em `~/.claude/settings.json` (`statusLine`) e mostra uma linha compacta sempre visível no terminal do Claude Code, atualizando sozinha.
|
package/bin/tokmon.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { runSnapshot } from '../scripts/snapshot.mjs';
|
|
3
|
+
import { runWatch } from '../scripts/watch.mjs';
|
|
4
|
+
import { runStatusline } from '../scripts/statusline.mjs';
|
|
5
|
+
import { runSetup } from '../scripts/setup.mjs';
|
|
6
|
+
|
|
7
|
+
function printHelp() {
|
|
8
|
+
console.log(`tokmon — monitor de consumo de token do Claude Code
|
|
9
|
+
|
|
10
|
+
Uso:
|
|
11
|
+
tokmon setup [--force] Configura a integração com o Claude Code (skill, /tokens, statusLine)
|
|
12
|
+
tokmon snapshot [cwd] Snapshot de texto: total, ranking de prompt, quebra por skill, context window
|
|
13
|
+
tokmon watch [cwd] Dashboard ao vivo no terminal (atualiza sozinho)
|
|
14
|
+
tokmon statusline Modo statusLine — lê JSON do stdin (uso interno, configurado em ~/.claude/settings.json)
|
|
15
|
+
tokmon help Mostra esta ajuda`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const [subcommand, ...rest] = process.argv.slice(2);
|
|
20
|
+
|
|
21
|
+
switch (subcommand) {
|
|
22
|
+
case 'setup':
|
|
23
|
+
runSetup(rest);
|
|
24
|
+
break;
|
|
25
|
+
case 'snapshot':
|
|
26
|
+
runSnapshot(rest);
|
|
27
|
+
break;
|
|
28
|
+
case 'watch':
|
|
29
|
+
await runWatch(rest);
|
|
30
|
+
break;
|
|
31
|
+
case 'statusline':
|
|
32
|
+
runStatusline();
|
|
33
|
+
break;
|
|
34
|
+
case 'help':
|
|
35
|
+
case '--help':
|
|
36
|
+
case '-h':
|
|
37
|
+
case undefined:
|
|
38
|
+
printHelp();
|
|
39
|
+
break;
|
|
40
|
+
default:
|
|
41
|
+
console.error(`tokmon: comando desconhecido "${subcommand}"\n`);
|
|
42
|
+
printHelp();
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
main();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Mostra snapshot de consumo de token da sessão atual (total, ranking de prompt, quebra por skill, % context window).
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
Rode:
|
|
6
|
+
```bash
|
|
7
|
+
node ~/.claude/skills/token-monitor/scripts/snapshot.mjs "$(pwd)"
|
|
8
|
+
```
|
|
9
|
+
Mostre a saída do comando pro usuário tal como veio (já formatada), sem resumir ou reformatar.
|
package/install.sh
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
5
|
+
|
|
6
|
+
if [ ! -d "$REPO_DIR/node_modules" ]; then
|
|
7
|
+
echo "Instalando dependências (ink, react)..."
|
|
8
|
+
(cd "$REPO_DIR" && npm install --no-audit --no-fund)
|
|
9
|
+
fi
|
|
10
|
+
|
|
11
|
+
node "$REPO_DIR/bin/tokmon.mjs" setup "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokmon-app",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Skill do Claude Code para monitorar consumo de token por sessao (ranking de prompt, breakdown por skill, context window).",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokmon": "bin/tokmon.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"scripts",
|
|
12
|
+
"commands",
|
|
13
|
+
"SKILL.md",
|
|
14
|
+
"install.sh",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"test": "node --test"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"claude-code",
|
|
26
|
+
"cli",
|
|
27
|
+
"token-usage",
|
|
28
|
+
"monitor",
|
|
29
|
+
"dashboard"
|
|
30
|
+
],
|
|
31
|
+
"author": "Mateus Castro",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/MateusCastro2203/tokmon.git"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/MateusCastro2203/tokmon#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/MateusCastro2203/tokmon/issues"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"ink": "^5.0.1",
|
|
43
|
+
"react": "^18.3.1"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// 16x16 pixel-art "Coral Drake" mascot, same design approved in the landing
|
|
2
|
+
// page brainstorm (docs/superpowers/specs/2026-07-23-landing-page-design.md).
|
|
3
|
+
// One character per pixel; rendered in the terminal as two space-wide
|
|
4
|
+
// background-colored cells so it reads roughly square in a monospace font.
|
|
5
|
+
export const DRAGON_SPRITE_ROWS = [
|
|
6
|
+
'................',
|
|
7
|
+
'.....h....h.....',
|
|
8
|
+
'.....bbbbbb.....',
|
|
9
|
+
'.....bebbeb.....',
|
|
10
|
+
'.....bbbbbb.....',
|
|
11
|
+
'.www.bbhhbb.www.',
|
|
12
|
+
'wwwwbbbbbbbbwwww',
|
|
13
|
+
'wwwwbbllllbbwwww',
|
|
14
|
+
'wwwwbbllllbbwwww',
|
|
15
|
+
'.wwwbbllllbbwww.',
|
|
16
|
+
'....bbbbbbbb....',
|
|
17
|
+
'.....bb..bbb....',
|
|
18
|
+
'.....bbggbb.b...',
|
|
19
|
+
'......ffff...ff.',
|
|
20
|
+
'.......gg....ff.',
|
|
21
|
+
'................',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const DRAGON_PALETTE = {
|
|
25
|
+
h: '#641220', // horn / outline / mouth
|
|
26
|
+
b: '#e63946', // body
|
|
27
|
+
e: '#1a1a1a', // eye
|
|
28
|
+
w: '#1d3557', // wings
|
|
29
|
+
l: '#f1faee', // belly
|
|
30
|
+
g: '#ffb703', // fire (breath / tail glow)
|
|
31
|
+
f: '#fb8500', // fire (breath / tail tip, brighter)
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export function hpBarSegments(percentage, totalSegments = 10) {
|
|
35
|
+
const pct = Math.max(0, Math.min(100, percentage ?? 0));
|
|
36
|
+
const filled = Math.round((pct / 100) * totalSegments);
|
|
37
|
+
return { filled, empty: totalSegments - filled, totalSegments };
|
|
38
|
+
}
|
package/scripts/lib.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { readdirSync, statSync, existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export function encodeProjectDir(cwd) {
|
|
5
|
+
return cwd.replace(/[\/._]/g, '-');
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function isMainModule(moduleUrl) {
|
|
9
|
+
if (!process.argv[1]) return false;
|
|
10
|
+
try {
|
|
11
|
+
return moduleUrl === `file://${realpathSync(process.argv[1])}`;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function listSessionFiles(projectDir) {
|
|
18
|
+
if (!existsSync(projectDir)) return [];
|
|
19
|
+
return readdirSync(projectDir)
|
|
20
|
+
.filter((name) => name.endsWith('.jsonl'))
|
|
21
|
+
.map((name) => path.join(projectDir, name))
|
|
22
|
+
.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function findActiveSession(projectDir) {
|
|
26
|
+
const files = listSessionFiles(projectDir);
|
|
27
|
+
return files[0] ?? null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isRealUserMessage(entry) {
|
|
31
|
+
if (entry.message?.role !== 'user') return false;
|
|
32
|
+
if (entry.toolUseResult) return false;
|
|
33
|
+
const content = entry.message.content;
|
|
34
|
+
if (typeof content === 'string') return content.trim().length > 0;
|
|
35
|
+
if (Array.isArray(content)) {
|
|
36
|
+
const hasToolResult = content.some((block) => block.type === 'tool_result');
|
|
37
|
+
if (hasToolResult) return false;
|
|
38
|
+
return content.some((block) => block.type === 'text' && block.text.trim().length > 0);
|
|
39
|
+
}
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function truncatePreview(text, maxLen = 60) {
|
|
44
|
+
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
45
|
+
if (trimmed.length <= maxLen) return trimmed;
|
|
46
|
+
const sliced = trimmed.slice(0, maxLen);
|
|
47
|
+
const lastSpace = sliced.lastIndexOf(' ');
|
|
48
|
+
const cut = lastSpace > 0 ? sliced.slice(0, lastSpace) : sliced;
|
|
49
|
+
return `${cut}…`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function extractTextPreview(entry) {
|
|
53
|
+
const content = entry.message?.content;
|
|
54
|
+
let text = null;
|
|
55
|
+
if (typeof content === 'string') text = content;
|
|
56
|
+
else if (Array.isArray(content)) {
|
|
57
|
+
const block = content.find((b) => b.type === 'text' && typeof b.text === 'string');
|
|
58
|
+
text = block?.text ?? null;
|
|
59
|
+
}
|
|
60
|
+
if (!text) return null;
|
|
61
|
+
return truncatePreview(text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeEvent(entry) {
|
|
65
|
+
const role = entry.message?.role;
|
|
66
|
+
if (role !== 'user' && role !== 'assistant') return null;
|
|
67
|
+
const usageRaw = entry.message?.usage;
|
|
68
|
+
const usage = usageRaw
|
|
69
|
+
? {
|
|
70
|
+
input_tokens: usageRaw.input_tokens ?? 0,
|
|
71
|
+
output_tokens: usageRaw.output_tokens ?? 0,
|
|
72
|
+
cache_creation_input_tokens: usageRaw.cache_creation_input_tokens ?? 0,
|
|
73
|
+
cache_read_input_tokens: usageRaw.cache_read_input_tokens ?? 0,
|
|
74
|
+
}
|
|
75
|
+
: null;
|
|
76
|
+
return {
|
|
77
|
+
role,
|
|
78
|
+
ts: entry.timestamp ?? null,
|
|
79
|
+
uuid: entry.uuid ?? null,
|
|
80
|
+
parentUuid: entry.parentUuid ?? null,
|
|
81
|
+
isSidechain: entry.isSidechain === true,
|
|
82
|
+
attributionSkill: entry.attributionSkill ?? null,
|
|
83
|
+
model: entry.message?.model ?? null,
|
|
84
|
+
isRealUserText: isRealUserMessage(entry),
|
|
85
|
+
textPreview: extractTextPreview(entry),
|
|
86
|
+
usage,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseSession(filePath) {
|
|
91
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
92
|
+
const lines = raw.split('\n').filter((l) => l.trim().length > 0);
|
|
93
|
+
const events = [];
|
|
94
|
+
let warnings = 0;
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
let entry;
|
|
97
|
+
try {
|
|
98
|
+
entry = JSON.parse(line);
|
|
99
|
+
} catch {
|
|
100
|
+
warnings += 1;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const normalized = normalizeEvent(entry);
|
|
104
|
+
if (normalized) events.push(normalized);
|
|
105
|
+
}
|
|
106
|
+
return { events, warnings };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function groupIntoTurns(events) {
|
|
110
|
+
const turns = [];
|
|
111
|
+
let current = null;
|
|
112
|
+
for (const event of events) {
|
|
113
|
+
if (event.role === 'user' && event.isRealUserText && !event.isSidechain) {
|
|
114
|
+
current = {
|
|
115
|
+
id: event.uuid,
|
|
116
|
+
ts: event.ts,
|
|
117
|
+
textPreview: event.textPreview ?? '',
|
|
118
|
+
usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total: 0 },
|
|
119
|
+
bySkill: {},
|
|
120
|
+
};
|
|
121
|
+
turns.push(current);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!current || !event.usage) continue;
|
|
125
|
+
const bucket = event.isSidechain ? (event.attributionSkill ?? 'subagent') : 'main';
|
|
126
|
+
const turnTotal =
|
|
127
|
+
event.usage.input_tokens +
|
|
128
|
+
event.usage.output_tokens +
|
|
129
|
+
event.usage.cache_creation_input_tokens +
|
|
130
|
+
event.usage.cache_read_input_tokens;
|
|
131
|
+
current.usage.input_tokens += event.usage.input_tokens;
|
|
132
|
+
current.usage.output_tokens += event.usage.output_tokens;
|
|
133
|
+
current.usage.cache_creation_input_tokens += event.usage.cache_creation_input_tokens;
|
|
134
|
+
current.usage.cache_read_input_tokens += event.usage.cache_read_input_tokens;
|
|
135
|
+
current.usage.total += turnTotal;
|
|
136
|
+
current.bySkill[bucket] = (current.bySkill[bucket] ?? 0) + turnTotal;
|
|
137
|
+
}
|
|
138
|
+
return turns;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const DEFAULT_MODEL_LIMITS = { sonnet: 200000, opus: 200000, haiku: 200000 };
|
|
142
|
+
|
|
143
|
+
export function formatTokenCount(n) {
|
|
144
|
+
if (n < 1000) return String(Math.round(n));
|
|
145
|
+
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
|
|
146
|
+
return `${(n / 1_000_000).toFixed(1)}M`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function resolveModelLimit(modelId, modelLimits) {
|
|
150
|
+
if (!modelId) return 200000;
|
|
151
|
+
const lower = modelId.toLowerCase();
|
|
152
|
+
for (const [key, limit] of Object.entries(modelLimits)) {
|
|
153
|
+
if (lower.includes(key.toLowerCase())) return limit;
|
|
154
|
+
}
|
|
155
|
+
return 200000;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function computeTotals(events, turns, opts = {}) {
|
|
159
|
+
const modelLimits = opts.modelLimits ?? DEFAULT_MODEL_LIMITS;
|
|
160
|
+
|
|
161
|
+
const totals = { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, total: 0 };
|
|
162
|
+
const bySkillMap = {};
|
|
163
|
+
for (const turn of turns) {
|
|
164
|
+
totals.input_tokens += turn.usage.input_tokens;
|
|
165
|
+
totals.output_tokens += turn.usage.output_tokens;
|
|
166
|
+
totals.cache_creation_input_tokens += turn.usage.cache_creation_input_tokens;
|
|
167
|
+
totals.cache_read_input_tokens += turn.usage.cache_read_input_tokens;
|
|
168
|
+
totals.total += turn.usage.total;
|
|
169
|
+
for (const [skill, tokens] of Object.entries(turn.bySkill)) {
|
|
170
|
+
bySkillMap[skill] = (bySkillMap[skill] ?? 0) + tokens;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const bySkill = Object.entries(bySkillMap)
|
|
174
|
+
.map(([skill, total]) => ({ skill, total }))
|
|
175
|
+
.sort((a, b) => b.total - a.total);
|
|
176
|
+
const topTurns = [...turns].sort((a, b) => b.usage.total - a.usage.total);
|
|
177
|
+
|
|
178
|
+
let contextWindow = null;
|
|
179
|
+
const lastMainAssistant = [...events].reverse().find((e) => e.role === 'assistant' && !e.isSidechain && e.usage);
|
|
180
|
+
if (lastMainAssistant) {
|
|
181
|
+
const totalInputTokens =
|
|
182
|
+
lastMainAssistant.usage.input_tokens +
|
|
183
|
+
lastMainAssistant.usage.cache_read_input_tokens +
|
|
184
|
+
lastMainAssistant.usage.cache_creation_input_tokens;
|
|
185
|
+
const contextWindowSize = resolveModelLimit(lastMainAssistant.model, modelLimits);
|
|
186
|
+
contextWindow = {
|
|
187
|
+
totalInputTokens,
|
|
188
|
+
contextWindowSize,
|
|
189
|
+
usedPercentage: Math.round((totalInputTokens / contextWindowSize) * 1000) / 10,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { totals, bySkill, topTurns, contextWindow };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function formatStatusLine({ totals, contextWindowOverride }) {
|
|
197
|
+
const top = totals.topTurns[0];
|
|
198
|
+
const parts = [`🔥 sessão: ${formatTokenCount(totals.totals.total)} tok`];
|
|
199
|
+
const pct = contextWindowOverride ?? totals.contextWindow?.usedPercentage ?? null;
|
|
200
|
+
if (pct !== null) parts.push(`contexto: ${pct}% usado`);
|
|
201
|
+
if (top) parts.push(`prompt mais caro: ${formatTokenCount(top.usage.total)} tok`);
|
|
202
|
+
return parts.join(' · ');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function formatSnapshot(totals, meta) {
|
|
206
|
+
const { sessionId, projectLabel, warnings = 0, topN = 5 } = meta;
|
|
207
|
+
const lines = [];
|
|
208
|
+
lines.push(`Sessão ativa: ${sessionId} (${projectLabel})`);
|
|
209
|
+
const t = totals.totals;
|
|
210
|
+
lines.push(
|
|
211
|
+
`Total: ${formatTokenCount(t.total)} tok (in ${formatTokenCount(t.input_tokens)} / out ${formatTokenCount(
|
|
212
|
+
t.output_tokens
|
|
213
|
+
)} / cache-w ${formatTokenCount(t.cache_creation_input_tokens)} / cache-r ${formatTokenCount(
|
|
214
|
+
t.cache_read_input_tokens
|
|
215
|
+
)})`
|
|
216
|
+
);
|
|
217
|
+
if (totals.contextWindow) {
|
|
218
|
+
lines.push(
|
|
219
|
+
`Context window: ${totals.contextWindow.usedPercentage}% (${formatTokenCount(
|
|
220
|
+
totals.contextWindow.totalInputTokens
|
|
221
|
+
)} / ${formatTokenCount(totals.contextWindow.contextWindowSize)})`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
lines.push('');
|
|
225
|
+
lines.push('Top prompts:');
|
|
226
|
+
const top = totals.topTurns.slice(0, topN);
|
|
227
|
+
if (top.length === 0) {
|
|
228
|
+
lines.push(' (nenhum prompt registrado ainda)');
|
|
229
|
+
} else {
|
|
230
|
+
top.forEach((turn, i) => {
|
|
231
|
+
lines.push(` ${i + 1}. ${formatTokenCount(turn.usage.total)} "${turn.textPreview}"`);
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
lines.push('');
|
|
235
|
+
lines.push('Por skill/agente:');
|
|
236
|
+
if (totals.bySkill.length === 0) {
|
|
237
|
+
lines.push(' (sem dados)');
|
|
238
|
+
} else {
|
|
239
|
+
totals.bySkill.forEach(({ skill, total }) => {
|
|
240
|
+
lines.push(` ${skill.padEnd(12)} ${formatTokenCount(total)}`);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
if (warnings > 0) {
|
|
244
|
+
lines.push('');
|
|
245
|
+
lines.push(`(${warnings} linha(s) de log ignorada(s) por estarem corrompidas)`);
|
|
246
|
+
}
|
|
247
|
+
return lines.join('\n');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function buildViewModel(totals, meta) {
|
|
251
|
+
const { sessionId, projectLabel, warnings = 0 } = meta;
|
|
252
|
+
const pct = totals.contextWindow?.usedPercentage ?? null;
|
|
253
|
+
const contextSeverity = pct === null ? null : pct >= 80 ? 'danger' : pct >= 50 ? 'warn' : 'ok';
|
|
254
|
+
return {
|
|
255
|
+
header: `token-monitor — ${projectLabel} (${sessionId})`,
|
|
256
|
+
totalTokens: formatTokenCount(totals.totals.total),
|
|
257
|
+
breakdown: `in ${formatTokenCount(totals.totals.input_tokens)} · out ${formatTokenCount(
|
|
258
|
+
totals.totals.output_tokens
|
|
259
|
+
)} · cache-w ${formatTokenCount(totals.totals.cache_creation_input_tokens)} · cache-r ${formatTokenCount(
|
|
260
|
+
totals.totals.cache_read_input_tokens
|
|
261
|
+
)}`,
|
|
262
|
+
contextPercentage: pct,
|
|
263
|
+
contextSeverity,
|
|
264
|
+
contextDetail: totals.contextWindow
|
|
265
|
+
? `${formatTokenCount(totals.contextWindow.totalInputTokens)} / ${formatTokenCount(
|
|
266
|
+
totals.contextWindow.contextWindowSize
|
|
267
|
+
)}`
|
|
268
|
+
: null,
|
|
269
|
+
topRows: totals.topTurns.slice(0, 8).map((turn, i) => ({
|
|
270
|
+
rank: i + 1,
|
|
271
|
+
tokens: formatTokenCount(turn.usage.total),
|
|
272
|
+
preview: turn.textPreview,
|
|
273
|
+
})),
|
|
274
|
+
skillRows: totals.bySkill.map(({ skill, total }) => ({ skill, tokens: formatTokenCount(total) })),
|
|
275
|
+
warningsLine: warnings > 0 ? `${warnings} linha(s) ignorada(s) (log corrompido)` : null,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, symlinkSync, lstatSync, copyFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { isMainModule } from './lib.mjs';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
const PACKAGE_ROOT = path.join(__dirname, '..');
|
|
9
|
+
|
|
10
|
+
export function mergeStatusLine(existingSettings, statusLineConfig, opts = {}) {
|
|
11
|
+
const { force = false } = opts;
|
|
12
|
+
const current = existingSettings.statusLine;
|
|
13
|
+
if (current) {
|
|
14
|
+
const same = JSON.stringify(current) === JSON.stringify(statusLineConfig);
|
|
15
|
+
if (same) return { settings: existingSettings, changed: false, skippedReason: null };
|
|
16
|
+
if (!force) {
|
|
17
|
+
return {
|
|
18
|
+
settings: existingSettings,
|
|
19
|
+
changed: false,
|
|
20
|
+
skippedReason: 'statusLine já configurada com outro comando — rode `tokmon setup --force` pra sobrescrever',
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return { settings: { ...existingSettings, statusLine: statusLineConfig }, changed: true, skippedReason: null };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function runSetup(argv = process.argv.slice(2)) {
|
|
28
|
+
const force = argv.includes('--force');
|
|
29
|
+
const claudeHome = process.env.TOKEN_MONITOR_HOME ?? path.join(process.env.HOME ?? '', '.claude');
|
|
30
|
+
const skillLink = path.join(claudeHome, 'skills', 'token-monitor');
|
|
31
|
+
const commandFile = path.join(claudeHome, 'commands', 'tokens.md');
|
|
32
|
+
const settingsFile = path.join(claudeHome, 'settings.json');
|
|
33
|
+
|
|
34
|
+
mkdirSync(path.join(claudeHome, 'skills'), { recursive: true });
|
|
35
|
+
mkdirSync(path.join(claudeHome, 'commands'), { recursive: true });
|
|
36
|
+
|
|
37
|
+
if (existsSync(skillLink)) {
|
|
38
|
+
if (!lstatSync(skillLink).isSymbolicLink()) {
|
|
39
|
+
console.error(`erro: ${skillLink} existe e não é um symlink — remova manualmente antes de rodar o setup.`);
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
symlinkSync(PACKAGE_ROOT, skillLink, 'dir');
|
|
45
|
+
}
|
|
46
|
+
console.log(`✓ skill linkada em ${skillLink}`);
|
|
47
|
+
|
|
48
|
+
copyFileSync(path.join(PACKAGE_ROOT, 'commands', 'tokens.md'), commandFile);
|
|
49
|
+
console.log(`✓ comando /tokens copiado pra ${commandFile}`);
|
|
50
|
+
|
|
51
|
+
let settings = {};
|
|
52
|
+
if (existsSync(settingsFile)) {
|
|
53
|
+
try {
|
|
54
|
+
settings = JSON.parse(readFileSync(settingsFile, 'utf8'));
|
|
55
|
+
} catch {
|
|
56
|
+
console.error(`erro: ${settingsFile} não é um JSON válido — corrija manualmente antes de rodar o setup.`);
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const statusLineConfig = {
|
|
63
|
+
type: 'command',
|
|
64
|
+
command: `node ${path.join(skillLink, 'scripts', 'statusline.mjs')}`,
|
|
65
|
+
};
|
|
66
|
+
const { settings: merged, changed, skippedReason } = mergeStatusLine(settings, statusLineConfig, { force });
|
|
67
|
+
if (skippedReason) {
|
|
68
|
+
console.log(`⚠ statusLine não alterada: ${skippedReason}`);
|
|
69
|
+
} else if (changed) {
|
|
70
|
+
writeFileSync(settingsFile, `${JSON.stringify(merged, null, 2)}\n`);
|
|
71
|
+
console.log(`✓ statusLine configurada em ${settingsFile}`);
|
|
72
|
+
} else {
|
|
73
|
+
console.log('✓ statusLine já configurada corretamente');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
console.log('\nPronto. Abra uma nova sessão do Claude Code pra ver a statusline, e rode `tokmon watch` ou `tokmon snapshot` quando quiser.');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (isMainModule(import.meta.url)) {
|
|
80
|
+
runSetup();
|
|
81
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import {
|
|
4
|
+
encodeProjectDir,
|
|
5
|
+
findActiveSession,
|
|
6
|
+
parseSession,
|
|
7
|
+
groupIntoTurns,
|
|
8
|
+
computeTotals,
|
|
9
|
+
formatSnapshot,
|
|
10
|
+
isMainModule,
|
|
11
|
+
} from './lib.mjs';
|
|
12
|
+
|
|
13
|
+
export function runSnapshot(argv = process.argv.slice(2)) {
|
|
14
|
+
const cwd = argv[0] ?? process.cwd();
|
|
15
|
+
const home = process.env.HOME ?? '';
|
|
16
|
+
const projectDir = path.join(home, '.claude', 'projects', encodeProjectDir(cwd));
|
|
17
|
+
const sessionFile = findActiveSession(projectDir);
|
|
18
|
+
if (!sessionFile) {
|
|
19
|
+
console.log(`token-monitor: nenhuma sessão encontrada em ${projectDir}`);
|
|
20
|
+
console.log('Rode este comando de dentro de um projeto com uma sessão ativa do Claude Code.');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const { events, warnings } = parseSession(sessionFile);
|
|
24
|
+
const turns = groupIntoTurns(events);
|
|
25
|
+
const totals = computeTotals(events, turns);
|
|
26
|
+
const sessionId = path.basename(sessionFile, '.jsonl');
|
|
27
|
+
console.log(formatSnapshot(totals, { sessionId, projectLabel: path.basename(cwd), warnings }));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (isMainModule(import.meta.url)) {
|
|
31
|
+
runSnapshot();
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { parseSession, groupIntoTurns, computeTotals, formatStatusLine, isMainModule } from './lib.mjs';
|
|
4
|
+
|
|
5
|
+
export function runStatusline() {
|
|
6
|
+
let input;
|
|
7
|
+
try {
|
|
8
|
+
input = JSON.parse(readFileSync(0, 'utf8'));
|
|
9
|
+
} catch {
|
|
10
|
+
console.log('token-monitor: sem sessão');
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
const transcriptPath = input?.transcript_path;
|
|
14
|
+
if (!transcriptPath) {
|
|
15
|
+
console.log('token-monitor: sem sessão');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
let totals;
|
|
19
|
+
try {
|
|
20
|
+
const { events } = parseSession(transcriptPath);
|
|
21
|
+
const turns = groupIntoTurns(events);
|
|
22
|
+
totals = computeTotals(events, turns);
|
|
23
|
+
} catch {
|
|
24
|
+
console.log('token-monitor: sem sessão');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const contextWindowOverride = input?.context_window?.used_percentage ?? null;
|
|
28
|
+
console.log(formatStatusLine({ totals, contextWindowOverride }));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (isMainModule(import.meta.url)) {
|
|
32
|
+
runStatusline();
|
|
33
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { watch } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
encodeProjectDir,
|
|
6
|
+
findActiveSession,
|
|
7
|
+
parseSession,
|
|
8
|
+
groupIntoTurns,
|
|
9
|
+
computeTotals,
|
|
10
|
+
buildViewModel,
|
|
11
|
+
isMainModule,
|
|
12
|
+
} from './lib.mjs';
|
|
13
|
+
import { DRAGON_SPRITE_ROWS, DRAGON_PALETTE, hpBarSegments } from './dragon-sprite.mjs';
|
|
14
|
+
|
|
15
|
+
function loadViewModel(cwd) {
|
|
16
|
+
const home = process.env.HOME ?? '';
|
|
17
|
+
const projectDir = path.join(home, '.claude', 'projects', encodeProjectDir(cwd));
|
|
18
|
+
const sessionFile = findActiveSession(projectDir);
|
|
19
|
+
if (!sessionFile) return { error: `sem sessão ativa em ${projectDir}`, sessionFile: null };
|
|
20
|
+
const { events, warnings } = parseSession(sessionFile);
|
|
21
|
+
const turns = groupIntoTurns(events);
|
|
22
|
+
const totals = computeTotals(events, turns);
|
|
23
|
+
const sessionId = path.basename(sessionFile, '.jsonl');
|
|
24
|
+
return {
|
|
25
|
+
model: buildViewModel(totals, { sessionId, projectLabel: path.basename(cwd), warnings }),
|
|
26
|
+
sessionFile,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Packs 2 sprite rows into 1 terminal row using the ▀ (upper half block)
|
|
31
|
+
// trick: foreground = top pixel, background = bottom pixel. Halves both the
|
|
32
|
+
// width (1 char/pixel instead of 2) and the height (2 sprite rows/line) of
|
|
33
|
+
// the previous flat-block rendering, and the character's own ~1:2 aspect
|
|
34
|
+
// ratio makes each original square sprite pixel read correctly proportioned.
|
|
35
|
+
function renderDragonSprite({ Box, Text, h }) {
|
|
36
|
+
const lines = [];
|
|
37
|
+
for (let i = 0; i < DRAGON_SPRITE_ROWS.length; i += 2) {
|
|
38
|
+
const top = DRAGON_SPRITE_ROWS[i];
|
|
39
|
+
const bottom = DRAGON_SPRITE_ROWS[i + 1] ?? '.'.repeat(top.length);
|
|
40
|
+
lines.push(
|
|
41
|
+
h(
|
|
42
|
+
Text,
|
|
43
|
+
{ key: `row-${i}` },
|
|
44
|
+
...top.split('').map((topCode, x) => {
|
|
45
|
+
const topColor = DRAGON_PALETTE[topCode];
|
|
46
|
+
const bottomColor = DRAGON_PALETTE[bottom[x]];
|
|
47
|
+
if (!topColor && !bottomColor) return h(Text, { key: x }, ' ');
|
|
48
|
+
if (topColor && !bottomColor) return h(Text, { key: x, color: topColor }, '▀');
|
|
49
|
+
if (!topColor && bottomColor) return h(Text, { key: x, color: bottomColor }, '▄');
|
|
50
|
+
return h(Text, { key: x, color: topColor, backgroundColor: bottomColor }, '▀');
|
|
51
|
+
})
|
|
52
|
+
)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return h(Box, { flexDirection: 'column' }, ...lines);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function renderStatsPanel({ Box, Text, h }, model, contextColor) {
|
|
59
|
+
const bar = hpBarSegments(model.contextPercentage);
|
|
60
|
+
return h(
|
|
61
|
+
Box,
|
|
62
|
+
{ flexDirection: 'column', marginLeft: 2 },
|
|
63
|
+
h(Text, { bold: true, color: 'cyan' }, model.header),
|
|
64
|
+
h(Text, null, ' '),
|
|
65
|
+
h(Text, null, `🔥 Total: ${model.totalTokens} tok`),
|
|
66
|
+
h(Text, { dimColor: true }, model.breakdown),
|
|
67
|
+
h(Text, null, ' '),
|
|
68
|
+
model.contextPercentage !== null
|
|
69
|
+
? h(
|
|
70
|
+
Text,
|
|
71
|
+
null,
|
|
72
|
+
'CTX ',
|
|
73
|
+
h(Text, { color: contextColor }, '█'.repeat(bar.filled)),
|
|
74
|
+
h(Text, { dimColor: true }, '░'.repeat(bar.empty)),
|
|
75
|
+
` ${model.contextPercentage}% (${model.contextDetail})`
|
|
76
|
+
)
|
|
77
|
+
: h(Text, { dimColor: true }, 'CTX sem dados ainda'),
|
|
78
|
+
h(Text, null, ' '),
|
|
79
|
+
model.topRows[0]
|
|
80
|
+
? h(Text, { dimColor: true }, `Top: "${model.topRows[0].preview}"`)
|
|
81
|
+
: h(Text, { dimColor: true }, 'Top: (nenhum prompt registrado ainda)')
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function createApp({ Box, Text, useState, useEffect, h }) {
|
|
86
|
+
return function App({ cwd }) {
|
|
87
|
+
const [state, setState] = useState(() => loadViewModel(cwd));
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
const reload = () => setState(loadViewModel(cwd));
|
|
91
|
+
const interval = setInterval(reload, 2000);
|
|
92
|
+
const watcher = state.sessionFile ? watch(state.sessionFile, { persistent: false }, reload) : null;
|
|
93
|
+
return () => {
|
|
94
|
+
clearInterval(interval);
|
|
95
|
+
watcher?.close();
|
|
96
|
+
};
|
|
97
|
+
}, [cwd, state.sessionFile]);
|
|
98
|
+
|
|
99
|
+
if (state.error) {
|
|
100
|
+
return h(
|
|
101
|
+
Box,
|
|
102
|
+
{ borderStyle: 'round', borderColor: 'yellow', paddingX: 1 },
|
|
103
|
+
h(Text, { color: 'yellow' }, `⚠ token-monitor: ${state.error}`)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const { model } = state;
|
|
107
|
+
const contextColor =
|
|
108
|
+
model.contextSeverity === 'danger' ? 'red' : model.contextSeverity === 'warn' ? 'yellow' : 'green';
|
|
109
|
+
|
|
110
|
+
return h(
|
|
111
|
+
Box,
|
|
112
|
+
{ flexDirection: 'column' },
|
|
113
|
+
h(
|
|
114
|
+
Box,
|
|
115
|
+
{ flexDirection: 'row', borderStyle: 'round', borderColor: 'cyan', paddingX: 1, marginBottom: 1 },
|
|
116
|
+
renderDragonSprite({ Box, Text, h }),
|
|
117
|
+
renderStatsPanel({ Box, Text, h }, model, contextColor)
|
|
118
|
+
),
|
|
119
|
+
h(
|
|
120
|
+
Box,
|
|
121
|
+
{ flexDirection: 'column', borderStyle: 'round', borderColor: 'magenta', paddingX: 1, marginBottom: 1 },
|
|
122
|
+
h(Text, { bold: true, color: 'magenta' }, '🏆 Top prompts'),
|
|
123
|
+
...(model.topRows.length
|
|
124
|
+
? model.topRows.map((row) =>
|
|
125
|
+
h(Text, { key: String(row.rank) }, ` ${row.rank}. ${row.tokens.padStart(7)} "${row.preview}"`)
|
|
126
|
+
)
|
|
127
|
+
: [h(Text, { dimColor: true }, ' (nenhum prompt registrado ainda)')])
|
|
128
|
+
),
|
|
129
|
+
h(
|
|
130
|
+
Box,
|
|
131
|
+
{ flexDirection: 'column', borderStyle: 'round', borderColor: 'blue', paddingX: 1 },
|
|
132
|
+
h(Text, { bold: true, color: 'blue' }, '🧩 Por skill/agente'),
|
|
133
|
+
...(model.skillRows.length
|
|
134
|
+
? model.skillRows.map((row) =>
|
|
135
|
+
h(Text, { key: row.skill }, ` ${row.skill.padEnd(12)} ${row.tokens.padStart(7)}`)
|
|
136
|
+
)
|
|
137
|
+
: [h(Text, { dimColor: true }, ' (sem dados)')])
|
|
138
|
+
),
|
|
139
|
+
model.warningsLine ? h(Text, { color: 'red' }, `⚠ ${model.warningsLine}`) : null
|
|
140
|
+
);
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function runWatch(argv = process.argv.slice(2)) {
|
|
145
|
+
let React, ink;
|
|
146
|
+
try {
|
|
147
|
+
[{ default: React }, ink] = await Promise.all([import('react'), import('ink')]);
|
|
148
|
+
} catch {
|
|
149
|
+
console.error('token-monitor: dependências não instaladas. Rode `npm install` na pasta da skill (~/.claude/skills/token-monitor).');
|
|
150
|
+
process.exitCode = 1;
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const h = React.createElement;
|
|
154
|
+
const App = createApp({ Box: ink.Box, Text: ink.Text, useState: React.useState, useEffect: React.useEffect, h });
|
|
155
|
+
const cwd = argv[0] ?? process.cwd();
|
|
156
|
+
ink.render(h(App, { cwd }));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (isMainModule(import.meta.url)) {
|
|
160
|
+
runWatch();
|
|
161
|
+
}
|