SLOPSHOPPER

task-board

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.

newbandguardstatusagents
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · task-board
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ◆ TÂCHES ✔ 1 terminé 🧠 Réponse ✔ terminé 42s ◆ TOKENS ↑0 ↓0 cache 0 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
◆ TÂCHES ✔ 1 terminé 🧠 Réponse ✔ terminé 42s ◆ TOKENS ↑0 ↓0 cache 0
README

task-board

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

Installation

/plugin install task-board --marketplace LouiSvon/claude-code-mods

Lecture de la barre

MarqueCouleurSens
●bleuen cours
✔vertterminé
✖rougeerreur
■grisarrêté
  • Mot : premier mot utile de la description de l'agent, en 12 caractères au plus. « Réponse » désigne la demande principale.
  • Décalage : └ marque un sous-agent, décalé de trois colonnes par niveau.
  • Tâche en cours : trois lignes. L'état, puis ↳ 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.
  • Tokens d'un sous-agent : ↑ 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.
  • Capacité : quatre tâches au plus, +N autres au-delà. Une ligne trop longue pour le terminal est coupée au bord (…), jamais cassée sur la suivante.

Logos

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.

Compteur de tokens

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.

  • Session : repart de son fichier à la reprise (--resume), de zéro après /clear.
  • Projet : somme de toutes les sessions du même dossier. Le dossier de stockage porte le nom du projet suivi d'une empreinte de la racine entière : deux dépôts du même nom, ou à un caractère près, ne se mélangent jamais. Les autres sessions sont relues à chaque fin de tour.
  • Changement de dossier (/cd) : le compteur repart de zéro pour le nouveau projet ; ce qui a été dépensé avant reste dans l'ancien.
  • Hors dossier de projet (espace temporaire de l'app) : la session seule, rien n'est écrit. Sa reprise repart donc de zéro.
  • Limite : une réponse coupée en plein flux (arrêt pendant que le modèle écrit) peut ne pas être comptée ; le moteur ne rapporte alors pas toujours ses tokens.

Données

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.

Tests

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.

Source 4 files
hooks/register.tsx 383 lines
1import { 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}
383
hooks/activity.ts 52 lines
1const 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]!)
52
hooks/tokens.ts 80 lines
1export 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}
80
types/index.d.ts 34 lines
1export 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