Barre de taches au-dessus de la saisie : un mot, un etat, un modele et les tokens par agent, plus le compteur de tokens de la session et du projet.

Barre de tâches au-dessus de la saisie de Claude Code : un mot, un état, un modèle et les tokens par agent, puis le compteur de la session et du projet.
◆ TÂCHES ● 2 en cours ✔ 1 terminé
🧠 Réponse ● en cours opus · high
↳ Optimise mes deux mods Claude Code
▸ Edit hooks/register.tsx · étape 4 · 7 outils
└ 🔭 Compter ● en cours ↑800 ↓120 sonnet
↳ Compter les tests du dépôt
▸ Grep "test(" dans plugins · étape 2 · 3 outils
└ 🔎 Lister ✔ terminé ↑2.1k ↓300 4s · haiku
◆ TOKENS ↑14k ↓3.8k cache 210k │ projet demo ↑1.2M ↓140k · 12 sessions
/plugin install task-board --marketplace LouiSvon/claude-code-mods
| Marque | Couleur | Sens |
|---|---|---|
● | bleu | en cours |
✔ | vert | terminé |
✖ | rouge | erreur |
■ | gris | arrêté |
└ marque un sous-agent, décalé de trois colonnes par niveau.↳ l'objectif de la tâche, puis ▸ l'outil du moment avec sa cible (Read hooks/app.ts, Bash claude plugin test…, Grep "motif" dans dossier), l'étape et le nombre d'outils lancés. Une tâche finie se résume à une ligne : durée, modèle, effort.↑ entrée neuve (non cache + écriture en cache), ↓ sortie, sur sa ligne. La ligne principale n'en affiche pas : c'est le compteur de session.+N autres au-delà. Une ligne trop longue pour le terminal est coupée au bord (…), jamais cassée sur la suivante.Chaque agent a un logo et une couleur dans la bande : 🧠 la réponse principale, 🔭 Explore, 🧭 Plan, 🤖 general-purpose, 🔎 chercheur, 🔧 implementeur, 🧐 relecteur, 🎼 orchestrateur, 📘 claude-code-guide. Un autre type reçoit un logo stable tiré de son identifiant.
Une seule ligne ◆ TOKENS : le compteur de la session. Elle ajoute le total du projet seulement quand d'autres sessions du même dossier existent (sinon ce serait le même chiffre). Elle cumule chaque réponse du modèle (agent principal et sous-agents ensemble, y compris celles d'un tour interrompu ensuite) et la requête de résumé d'une compaction. cache est la lecture du cache de prompt, comptée à part : elle se chiffre en millions et masquerait le reste.
--resume), de zéro après /clear./cd) : le compteur repart de zéro pour le nouveau projet ; ce qui a été dépensé avant reste dans l'ancien.Le seul fichier écrit est le compteur, un par session : ~/.claude/mod-tokens/<projet>-<empreinte>/<id-de-session>.json ({input, output, cache, updatedAt}, des nombres, aucun texte de conversation). Rien ne quitte ta machine. Pour repartir de zéro, supprime ce dossier.
claude plugin test .
Vingt-deux tests : les cinq de la barre (attente, terminé puis en cours, tâche principale visible, décalage, agent relancé), l'objectif et l'outil du moment, la cible des outils, les logos, et le compteur de tokens (formatage, clés de projet sans collision, cumul par agent, total projet avec fichier abîmé ignoré, reprise, /clear, réponses simultanées, changement de dossier, tour interrompu, compaction, hors projet). Disque et environnement sont simulés : aucun test n'écrit dans ton ~/.claude.
hooks/register.tsx 383 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Row, Tally } from '../types'
5import { describe, logoOf, tintOf } from './activity'
6import { ZERO, add, dur, fmt, folderName, fromUsage, isScratch, parseTotals, projectKey } from './tokens'
7
8const rows = atom({ plugin: 'task-board', key: 'rows' } as const, [])
9const EMPTY: Tally = { session: ZERO, others: ZERO, sessions: 0, project: '' }
10const tally = atom({ plugin: 'task-board', key: 'tally' } as const, EMPTY)
11const MAIN = 'main'
12const MAX_ROWS = 4
13const BLUE = '#4da3ff'
14const CYAN = '#4dd4d4'
15const ORANGE = '#ff9f43'
16const VIOLET = '#b08cff'
17
18const short = (text: string, n: number) => {
19 const one = text.replace(/\s+/g, ' ').trim()
20 return one.length > n ? `${one.slice(0, n - 1)}...` : one
21}
22
23const SKIP = new Set(['le', 'la', 'les', 'un', 'une', 'des', 'du', 'de', 'd', 'l', 'et', 'a'])
24
25// Un seul mot simple : le premier mot utile de la description (un verbe en general).
26const label = (row: Row) => {
27 if (row.id === MAIN) return 'Réponse'
28 const words = row.goal.split(/[^\p{L}\p{N}]+/u).filter(w => w !== '')
29 const word = words.find(w => w.length > 2 && !SKIP.has(w.toLowerCase())) ?? words[0] ?? row.type
30 return (word.charAt(0).toUpperCase() + word.slice(1)).slice(0, 12)
31}
32
33// "claude-haiku-5-5" devient "haiku".
34const modelName = (model: string) => /^(?:claude-)?([a-z]+)/.exec(model)?.[1] ?? model
35
36const patch = (id: string, fn: (row: Row) => Partial<Row>) => (list: Row[]) =>
37 list.map(row => (row.id === id ? { ...row, ...fn(row) } : row))
38
39// La ligne principale reste toujours ; on borne seulement les sous-agents.
40const cap = (list: Row[]) => {
41 const main = list.filter(r => r.id === MAIN)
42 const others = list.filter(r => r.id !== MAIN).slice(-59)
43 return [...main, ...others]
44}
45
46let model = ''
47let effort = ''
48// Session et dossier vus en dernier : /clear change l'id et /cd la racine, sans relancer session.start.
49let sid = ''
50let sroot = ''
51// Ou ecrire le compteur de la session courante ; fige avec sid/sroot pour ne jamais deriver en cours de route.
52let current: { dir: string; id: string; name: string } | null = null
53
54// Les mises a jour du compteur passent une par une : deux reponses qui finissent en meme temps
55// ne se marchent pas dessus (surtout autour d'un /clear).
56let chain: Promise<unknown> = Promise.resolve()
57const serial = <T,>(fn: () => Promise<T>): Promise<T> => {
58 const run = chain.then(fn, fn)
59 chain = run.then(() => undefined, () => undefined)
60 return run
61}
62
63// Un fichier par session sous ~/.claude/mod-tokens/<projet>/ : aucune ecriture partagee,
64// le total du projet est la somme des fichiers.
65const where = async ($: any, root: string) => {
66 const home: string | undefined = await $.env.get('HOME')
67 if (isScratch(root) || !home) return null
68 return { dir: `${home}/.claude/mod-tokens/${projectKey(root)}`, name: folderName(root) }
69}
70
71// Somme des autres sessions du projet ; un dossier absent ou un fichier illisible comptent pour rien.
72const scanOthers = async ($: any, dir: string, id: string) => {
73 let others = ZERO
74 let sessions = 0
75 let entries: { name: string; kind: string }[] = []
76 try {
77 entries = await $.fs.list(dir)
78 } catch {
79 return { others, sessions }
80 }
81 for (const f of entries) {
82 if (f.kind !== 'file' || !f.name.endsWith('.json') || f.name === `${id}.json`) continue
83 try {
84 const t = parseTotals(await $.fs.read(`${dir}/${f.name}`))
85 if (t !== null) {
86 others = add(others, t)
87 sessions += 1
88 }
89 } catch {
90 // fichier en cours d'ecriture par une autre session
91 }
92 }
93 return { others, sessions }
94}
95
96// Se cale sur la session et le dossier courants : relit son fichier (reprise) et les autres sessions du projet.
97// Un changement de dossier repart de zero pour le nouveau projet : les tokens deja comptes restent dans l'ancien.
98// A appeler dans serial().
99const adopt = async ($: any, force = false) => {
100 const id: string = await $.session.id()
101 const root: string = await $.session.root()
102 if (id === sid && root === sroot && !force) return
103 sid = id
104 sroot = root
105 const place = await where($, root)
106 current = place === null ? null : { ...place, id }
107 let session = ZERO
108 let found = { others: ZERO, sessions: 0 }
109 if (current !== null) {
110 try {
111 session = parseTotals(await $.fs.read(`${current.dir}/${id}.json`)) ?? ZERO
112 } catch {
113 // premiere fois pour cette session
114 }
115 found = await scanOthers($, current.dir, id)
116 }
117 await update($, tally, () => ({
118 session,
119 others: found.others,
120 sessions: found.sessions,
121 project: current?.name ?? '',
122 }))
123}
124
125const save = async ($: any, session: Totals) => {
126 try {
127 if (current === null) return
128 await $.fs.write(`${current.dir}/${current.id}.json`, JSON.stringify({ ...session, updatedAt: await $.clock.now() }))
129 } catch {
130 // le compteur est un confort : un disque en echec ne doit pas casser un tour
131 }
132}
133
134const refreshOthers = async ($: any) => {
135 try {
136 if (current === null) return
137 const found = await scanOthers($, current.dir, current.id)
138 await update($, tally, t => ({ ...t, others: found.others, sessions: found.sessions }))
139 } catch {
140 // idem
141 }
142}
143
144// Ajoute des tokens au compteur de la session (et a la ligne d'un agent, si on la connait).
145const spend = ($: any, spent: Totals, agent?: string) =>
146 serial(async () => {
147 try {
148 await adopt($)
149 if (agent !== undefined) await update($, rows, patch(agent, row => ({ usage: add(row.usage ?? ZERO, spent) })))
150 const t = await update($, tally, cur => ({ ...cur, session: add(cur.session, spent) }))
151 await save($, t.session)
152 } catch {
153 // le compteur est un confort
154 }
155 })
156
157const LOOK = {
158 running: { color: BLUE, icon: '●', text: 'en cours' },
159 done: { color: 'success', icon: '✔', text: 'terminé' },
160 error: { color: 'error', icon: '✖', text: 'erreur' },
161 aborted: { color: 'inactive', icon: '■', text: 'arrêté' },
162} as const
163
164// Une tache : une ligne ; tant qu'elle tourne, deux de plus (l'objectif, puis l'outil du moment).
165const rowView = ({ Box, Text }: any, row: Row) => {
166 const look = LOOK[row.status]
167 const live = row.status === 'running'
168 const extra = [live ? '' : dur(row.durationMs), modelName(row.model), row.effort].filter(p => p !== '').join(' · ')
169 const used = row.usage ?? ZERO
170 // La ligne principale n'affiche pas ses tokens : c'est le compteur de session.
171 const spent = row.id !== MAIN && used.input + used.output > 0
172 const pad = ' '.repeat(row.depth * 3 + 3)
173 const what = row.action === 'reflexion' || row.action === 'demarrage' ? 'réflexion…' : `${row.action} ${row.detail ?? ''}`.trim()
174 const tools = row.tools ?? 0
175
176 return (
177 <Box key={row.id} flexDirection="column">
178 <Text wrap="truncate-end">
179 {' '.repeat(row.depth * 3)}
180 {row.depth > 0 && <Text dimColor>└ </Text>}
181 <Text>{logoOf(row.type, row.id)} </Text>
182 <Text color={tintOf(row.id)} bold dimColor={row.status === 'done'}>{label(row).padEnd(9)}</Text>
183 <Text color={look.color} bold>{look.icon} </Text>
184 <Text color={look.color}>{look.text.padEnd(8)}</Text>
185 {spent && <Text color={CYAN}>{`↑${fmt(used.input)} ↓${fmt(used.output)} `}</Text>}
186 <Text dimColor>{extra}</Text>
187 </Text>
188 {live && <Text wrap="truncate-end" dimColor>{pad}↳ {row.goal}</Text>}
189 {live && (
190 <Text wrap="truncate-end">
191 <Text color={ORANGE}>{pad}▸ {what}</Text>
192 <Text dimColor>{` · étape ${row.steps}${tools > 0 ? ` · ${tools} outil${tools === 1 ? '' : 's'}` : ''}`}</Text>
193 </Text>
194 )}
195 </Box>
196 )
197}
198
199export const register: Register = on => {
200 on('session.start', async ($, e, next) => {
201 // Une ancienne version epinglait une ligne de statut : un rechargement l'efface.
202 $.ui.status(undefined)
203 await serial(() => adopt($, true)).catch(() => undefined)
204
205 return next(e)
206 })
207
208 // Nouvelle demande : on repart d'un tableau propre, sans les taches du tour d'avant.
209 on('turn.start', async ($, e, next) => {
210 const main: Row = {
211 id: MAIN,
212 goal: short(e.text || 'Suite de la tache', 90),
213 type: 'principal',
214 model,
215 effort,
216 status: 'running',
217 action: 'reflexion',
218 steps: 0,
219 depth: 0,
220 }
221 await update($, rows, list => [
222 main,
223 ...list.filter(r => r.id !== MAIN && r.status === 'running'),
224 ])
225 await serial(() => refreshOthers($))
226
227 return next(e)
228 })
229
230 on('agent.spawn', async ($, e, next) => {
231 const started = await next(e)
232 if (started.deny !== undefined || started.agentId === undefined) return started
233
234 const row: Row = {
235 id: started.agentId,
236 goal: short(e.description || e.prompt, 90),
237 type: e.subagentType,
238 model: started.model,
239 effort: '',
240 status: 'running',
241 action: 'demarrage',
242 steps: 0,
243 depth: 1,
244 }
245 await update($, rows, list => {
246 const parent = list.find(r => r.id === e.parentAgentId)
247 return cap([...list, { ...row, depth: parent === undefined ? 1 : parent.depth + 1 }])
248 })
249
250 return started
251 })
252
253 // Chaque requete fixe le modele et l'effort reels ; un agent relance repasse en cours.
254 on('turn.step', async function* ($, e, next) {
255 const id = e.agentId ?? MAIN
256 if (id === MAIN) {
257 model = e.model
258 effort = e.effort === undefined ? '' : String(e.effort)
259 } else if (!(await read($, rows)).some(r => r.id === id)) {
260 // Agent relance apres le nettoyage d'un tour : on recree sa ligne depuis le moteur.
261 const info = (await $.agent.list()).find(a => a.id === id)
262 if (info !== undefined) {
263 await update($, rows, list =>
264 list.some(r => r.id === id)
265 ? list
266 : cap([
267 ...list,
268 {
269 id,
270 goal: short(info.description || info.type, 90),
271 type: info.type,
272 model: e.model,
273 effort: '',
274 status: 'running',
275 action: 'reflexion',
276 steps: 0,
277 depth: (list.find(r => r.id === info.parentId)?.depth ?? 0) + 1,
278 },
279 ]),
280 )
281 }
282 }
283 await update(
284 $,
285 rows,
286 patch(id, row => ({
287 model: e.model,
288 effort: e.effort === undefined ? '' : String(e.effort),
289 steps: row.steps + 1,
290 action: 'reflexion',
291 status: 'running',
292 durationMs: undefined,
293 })),
294 )
295
296 const result = yield* next(e)
297
298 // Source unique des tokens : chaque reponse du modele, y compris celle d'un tour interrompu ensuite.
299 const spent = fromUsage(result.usage)
300 if (spent.input + spent.output + spent.cache > 0) {
301 await spend($, spent, id)
302 }
303
304 return result
305 })
306
307 // Le resume de compaction est une requete a part, hors turn.step : sans ceci le compteur la rate.
308 on('session.compact', async ($, e, next) => {
309 const result = await next(e)
310 const spent = fromUsage('usage' in result ? result.usage : undefined)
311 if (spent.input + spent.output + spent.cache > 0) {
312 await spend($, spent)
313 }
314
315 return result
316 })
317
318 on('tool.call', async ($, e, next) => {
319 const id = e.agentId ?? MAIN
320 await update($, rows, patch(id, row => ({ action: e.tool, detail: describe(e), tools: (row.tools ?? 0) + 1 })))
321
322 return next(e)
323 })
324
325 on('turn.complete', async ($, e, next) => {
326 const id = e.agentId ?? MAIN
327 const status: Row['status'] =
328 e.reason === 'answer' ? 'done' : e.reason === 'aborted' ? 'aborted' : 'error'
329 await update($, rows, patch(id, () => ({ status, action: '', durationMs: e.durationMs })))
330 if (id === MAIN) await serial(() => refreshOthers($))
331
332 return next(e)
333 })
334
335 // Une ligne par tache, un mot, un etat, ses tokens ; un pied de page pour la session et le projet.
336 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
337 if (e.props.hasSurvey) return next(e)
338
339 const { Box, Text } = $.ui.resolve(e)
340 const list = await read($, rows)
341 const t = await read($, tally)
342 const count = (s: Row['status']) => list.filter(r => r.status === s).length
343 const running = count('running')
344 const done = count('done')
345 const failed = count('error') + count('aborted')
346
347 const main = list.filter(r => r.id === MAIN)
348 const others = list.filter(r => r.id !== MAIN)
349 const shown = [...main, ...others.slice(-(MAX_ROWS - main.length))]
350 const hidden = list.length - shown.length
351
352 const project = add(t.others, t.session)
353 // Le projet n'apparait que s'il apporte du neuf : sinon c'est le meme chiffre que la session.
354 const more = t.project !== '' && t.sessions > 0
355
356 return (
357 <Box flexDirection="column">
358 <Text wrap="truncate-end">
359 <Text color={BLUE} bold>◆ TÂCHES</Text>
360 {list.length === 0 && <Text dimColor> aucune pour l'instant</Text>}
361 {running > 0 && <Text color={LOOK.running.color} bold> ● {running} en cours</Text>}
362 {done > 0 && <Text color={LOOK.done.color}> ✔ {done} terminé{done > 1 ? 's' : ''}</Text>}
363 {failed > 0 && <Text color={LOOK.error.color}> ✖ {failed} échec{failed > 1 ? 's' : ''}</Text>}
364 </Text>
365 {shown.map(row => rowView({ Box, Text }, row))}
366 {hidden > 0 && <Text dimColor> +{hidden} autres</Text>}
367 <Text wrap="truncate-end">
368 <Text color={VIOLET} bold>◆ TOKENS</Text>
369 <Text dimColor> </Text>
370 <Text color={CYAN} bold>↑{fmt(t.session.input)}</Text>
371 <Text color={ORANGE} bold> ↓{fmt(t.session.output)}</Text>
372 <Text dimColor> cache {fmt(t.session.cache)}</Text>
373 {more && <Text dimColor> │ projet </Text>}
374 {more && <Text color={VIOLET}>{short(t.project, 20)} </Text>}
375 {more && <Text color={CYAN} bold>↑{fmt(project.input)}</Text>}
376 {more && <Text color={ORANGE} bold> ↓{fmt(project.output)}</Text>}
377 {more && <Text dimColor> · {t.sessions + 1} sessions</Text>}
378 </Text>
379 </Box>
380 )
381 })
382}
383hooks/activity.ts 52 lines1const one = (text: string, n: number) => {
2 const t = text.replace(/\s+/g, ' ').trim()
3 return t.length > n ? `${t.slice(0, n - 1)}…` : t
4}
5
6// "src/a/b.ts" -> "a/b.ts" : les deux derniers segments suffisent a reconnaitre le fichier.
7const tail = (path: string) => path.split('/').filter(Boolean).slice(-2).join('/')
8
9// Ce que l'outil appele vise, en quelques mots : un fichier, une commande, un motif, une adresse.
10// Les arguments de l'outil sont a plat sur l'evenement `tool.call`.
11export const describe = (args: Record<string, unknown>): string => {
12 const s = (key: string) => (typeof args[key] === 'string' ? (args[key] as string) : '')
13 const path = s('file_path') || s('notebook_path')
14 if (path !== '') return tail(path)
15 const command = s('command')
16 if (command !== '') return one(command, 70)
17 const pattern = s('pattern')
18 if (pattern !== '') return `"${one(pattern, 40)}"${s('path') !== '' ? ` dans ${tail(s('path'))}` : ''}`
19 const target = s('url') || s('query')
20 if (target !== '') return one(target, 70)
21 const text = s('description') || s('prompt')
22 if (text !== '') return one(text, 70)
23 return s('path') !== '' ? tail(s('path')) : ''
24}
25
26// Le logo d'un agent d'apres son type ; un type inconnu prend un logo stable tire de son identifiant.
27const LOGOS: Record<string, string> = {
28 principal: '🧠',
29 Explore: '🔭',
30 Plan: '🧭',
31 'general-purpose': '🤖',
32 chercheur: '🔎',
33 implementeur: '🔧',
34 relecteur: '🧐',
35 orchestrateur: '🎼',
36 'claude-code-guide': '📘',
37 'statusline-setup': '📟',
38}
39const SPARE = ['🐙', '🦊', '🐝', '🦉', '🐳', '🦄']
40
41const hash = (text: string) => {
42 let h = 0
43 for (let i = 0; i < text.length; i++) h = (Math.imul(h, 31) + text.charCodeAt(i)) >>> 0
44 return h
45}
46
47export const logoOf = (type: string, id: string): string => LOGOS[type] ?? SPARE[hash(id) % SPARE.length]!
48
49// Une couleur par agent : on reconnait d'un coup d'oeil qui fait quoi, dans la bande.
50const TINTS = ['#b08cff', '#4dd4d4', '#ff9f43', '#ff7ab2', '#8fd36a', '#f2d45c']
51export const tintOf = (id: string): string => (id === 'main' ? '#4da3ff' : TINTS[hash(id) % TINTS.length]!)
52hooks/tokens.ts 80 lines1export type Totals = { input: number; output: number; cache: number }
2
3export const ZERO: Totals = { input: 0, output: 0, cache: 0 }
4
5type ApiUsage = {
6 input_tokens?: number
7 output_tokens?: number
8 cache_read_input_tokens?: number
9 cache_creation_input_tokens?: number
10}
11
12// "input" = ce que le modele a lu de neuf (non cache + ecrit en cache) ; la lecture du cache
13// pese des millions et fausserait le chiffre phare, elle est comptee a part.
14export const fromUsage = (u: ApiUsage | null | undefined): Totals =>
15 u == null
16 ? ZERO
17 : {
18 input: (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0),
19 output: u.output_tokens ?? 0,
20 cache: u.cache_read_input_tokens ?? 0,
21 }
22
23export const add = (a: Totals, b: Totals): Totals => ({
24 input: a.input + b.input,
25 output: a.output + b.output,
26 cache: a.cache + b.cache,
27})
28
29// 842, 1.2k, 45k, 1.2M.
30export const fmt = (n: number): string => {
31 if (!Number.isFinite(n) || n < 0) return '0'
32 if (n < 1000) return String(Math.round(n))
33 if (n < 9_950) return `${(n / 1000).toFixed(1)}k`
34 if (n < 999_500) return `${Math.round(n / 1000)}k`
35 return `${(n / 1_000_000).toFixed(n < 10_000_000 ? 2 : 1)}M`
36}
37
38// 4s, 2min05.
39export const dur = (ms: number | undefined): string => {
40 if (ms === undefined || !Number.isFinite(ms) || ms < 0) return ''
41 const s = Math.round(ms / 1000)
42 if (s < 1) return '<1s'
43 if (s < 60) return `${s}s`
44 return `${Math.floor(s / 60)}min${String(s % 60).padStart(2, '0')}`
45}
46
47// Nom du dossier de stockage : le nom du projet, lisible, puis un empreinte de la racine entiere.
48// Deux racines differentes ne partagent jamais de dossier, meme si elles ne different que par
49// un caractere (`/b-c`, `/b/c`, `/b c`) : le nom seul ne distingue pas.
50export const projectKey = (root: string): string => {
51 let a = 5381
52 let b = 52711
53 for (let i = 0; i < root.length; i++) {
54 const c = root.charCodeAt(i)
55 a = (Math.imul(a, 33) ^ c) >>> 0
56 b = (Math.imul(b, 31) + c + i) >>> 0
57 }
58 const name = (root.split('/').filter(Boolean).pop() ?? 'racine').replace(/[^A-Za-z0-9._-]+/g, '-').slice(0, 40)
59 return `${name}-${a.toString(16).padStart(8, '0')}${b.toString(16).padStart(8, '0')}`
60}
61
62export const folderName = (root: string): string => root.split('/').filter(Boolean).pop() ?? root
63
64// L'app lance ses sessions sans dossier dans un espace temporaire : pas de projet.
65export const isScratch = (root: string): boolean => root.includes('/scratch-workspaces/')
66
67// Un fichier d'une autre session peut etre en cours d'ecriture : tout ce qui n'est pas valide est ignore.
68export const parseTotals = (text: string): Totals | null => {
69 try {
70 const v = JSON.parse(text) as Partial<Totals> | null
71 const num = (x: unknown) => (typeof x === 'number' && Number.isFinite(x) && x >= 0 ? x : null)
72 const input = num(v?.input)
73 const output = num(v?.output)
74 const cache = num(v?.cache)
75 return input === null || output === null || cache === null ? null : { input, output, cache }
76 } catch {
77 return null
78 }
79}
80types/index.d.ts 34 lines1export type Totals = { input: number; output: number; cache: number }
2
3export type Row = {
4 id: string
5 goal: string
6 type: string
7 model: string
8 effort: string
9 status: 'running' | 'done' | 'error' | 'aborted'
10 action: string
11 steps: number
12 depth: number
13 durationMs?: number
14 // Cible du dernier outil appele, et nombre d'outils lances (absents sur une ancienne ligne).
15 detail?: string
16 tools?: number
17 // Absent sur les lignes ecrites par une ancienne version.
18 usage?: Totals
19}
20
21// Compteur de la session et du projet ; `others` = les autres sessions du meme projet.
22export type Tally = {
23 session: Totals
24 others: Totals
25 sessions: number
26 project: string
27}
28
29declare module 'claude-code' {
30 interface PluginState {
31 'task-board': { rows: Row[]; tally: Tally }
32 }
33}
34