SLOPSHOPPER

cockpit

Panneau live des statistiques de la session : tours, outils, tokens, durées et fichiers modifiés. Ouvert par /cockpit.

newpaneguardcommand
v0.1.0MITupdated 2026-10-02Para-FR/claude-code-mods-fr/cockpit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cockpit
│ ┃ Cockpit ✕ › fix the failing auth test and add an audit log call │ ┃ Modèle Opus 5.5 · Coût $0.420 │ ┃ Contexte ████████████░░░░░░░░░░░░ 49 % ⏺ Read(src/auth.ts) │ ┃ 97.4k / 200.0k tokens ⎿ Read 6 lines │ ┃ ⏺ Update(src/auth.ts) │ ┃ Tours 1 · Outils 9 · Tokens 98.9k ⎿ Added 2 lines, removed 1 line │ ┃ entrée 97.4k · sortie 1.5k ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ Outils les plus utilisés │ ┃ Bash ████████████████████████ 4 ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ Write ████████████ 2 │ ┃ Read ██████ 1 ✻ Worked for 42s · done 4:20 PM │ ┃ Grep ██████ 1 │ ┃ Edit ██████ 1 › /cockpit │ ┃ ⎿ cockpit: Cockpit ouvert. │ ┃ Durée des tours │ ┃ #1 ████████████████████████ 42s │ ┃ │ ┃ Derniers fichiers modifiés │ ┃ src/cache.ts │ ┃ src/audit.ts │ ┃ src/auth.ts │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Cockpit
Modèle Opus 5.5 · Coût $0.420 Contexte ████████████░░░░░░░░░░░░ 49 % 97.4k / 200.0k tokens Tours 1 · Outils 9 · Tokens 98.9k entrée 97.4k · sortie 1.5k Outils les plus utilisés Bash ████████████████████████ 4 Write ████████████ 2 Read ██████ 1 Grep ██████ 1 Edit ██████ 1 Durée des tours #1 ████████████████████████ 42s Derniers fichiers modifiés src/cache.ts src/audit.ts src/auth.ts
README

Claude Code Mods 🇫🇷

Deux Claude Mods construits en live dans la vidéo YouTube de Claude Code France : Claude Code les a écrits lui-même, à partir d'une seule phrase chacun.

ModCe qu'il fait
🛡️ garde-du-corpsBloque la lecture et la modification des fichiers .env (les modèles .env.example, .env.sample… restent accessibles) et les commandes Bash destructrices : rm -rf, git push --force (et --force-with-lease), git reset --hard, DROP TABLE. Chaque blocage affiche un toast, et la status line compte les actions bloquées.
🚀 cockpitUn panneau live à côté de la conversation : modèle, coût, remplissage du contexte, nombre de tours, d'appels d'outils et de tokens, outils les plus utilisés, durée de chaque tour et derniers fichiers modifiés. S'ouvre avec /cockpit.

Prérequis : Claude Code 2.1.287 ou plus récent (les mods sont activés par défaut). Pour que le cockpit s'affiche en barre latérale, mets "tui": "fullscreen" dans ton settings.json.

Installation

Via la marketplace (le plus simple)

Dans Claude Code :

/plugin marketplace add Para-FR/claude-code-mods-fr
/plugin install garde-du-corps@claude-code-mods-fr
/plugin install cockpit@claude-code-mods-fr

En clonant le repo

git clone https://github.com/Para-FR/claude-code-mods-fr.git ~/claude-code-mods-fr
claude --plugin-dir ~/claude-code-mods-fr/garde-du-corps --plugin-dir ~/claude-code-mods-fr/cockpit

Vérifier et tester

claude plugin validate garde-du-corps && claude plugin test garde-du-corps
claude plugin validate cockpit && claude plugin test cockpit

Les prompts qui ont créé ces mods

garde-du-corps

Crée-moi un mod Claude Code "garde-du-corps" : il bloque toute lecture ou modification des fichiers .env, ainsi que les commandes Bash dangereuses (rm -rf, git push --force, git reset --hard, DROP TABLE). Quand il bloque quelque chose, affiche un toast, et garde dans la status line un compteur des actions bloquées.

cockpit

Crée un mod "cockpit" : un panneau live à côté de la conversation qui affiche, pour cette session, le nombre de tours, d'appels d'outils et de tokens, un histogramme des outils les plus utilisés, la durée de chaque tour en barres, et la liste des derniers fichiers modifiés. Ajoute une commande /cockpit pour l'ouvrir.

⚠️ À savoir

  • Lis le code d'un mod avant de l'installer : un mod tourne dans Claude Code et voit tes prompts, tes outils et tes fichiers. Ceux-ci sont courts : lis-les 🙂
  • Early access : l'API des mods peut changer d'une version de Claude Code à l'autre.
  • Garde-fou, pas coffre-fort : le garde du corps bloque les cas évidents, pas toutes les variantes possibles. Ne compte pas uniquement sur lui pour protéger de vrais secrets de production.

Liens

Licence MIT.

Source 3 files
hooks/register.tsx 212 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CockpitLive } from '../types'
5
6import {
7  EDIT_TOOLS,
8  bar,
9  emptyStats,
10  formatDuration,
11  formatModel,
12  formatTokens,
13  formatUsd,
14  shortPath,
15  topTools,
16  withFile,
17  withToolCall,
18  withTokens,
19  withTurn,
20} from './stats'
21
22const PANE = 'cockpit'
23const stats = atom({ plugin: 'cockpit', key: 'stats' } as const, emptyStats())
24const cwd = atom({ plugin: 'cockpit', key: 'cwd' } as const, '')
25const live = atom({ plugin: 'cockpit', key: 'live' } as const, {
26  model: '',
27  contextTokens: null,
28  contextWindow: 0,
29  contextPercent: null,
30  costUsd: null,
31} as CockpitLive)
32
33// Modèle, remplissage du contexte et coût, tels que la status line les voit.
34// Ne lève jamais : un chiffre manquant ne doit pas faire échouer l'appel d'outil en cours.
35const refreshLive = async ($: EngineInterface) => {
36  try {
37    const [model, usage] = await Promise.all([$.session.model(), $.session.usage()])
38    const snapshot: CockpitLive = {
39      model,
40      contextTokens: usage.context.tokens ?? null,
41      contextWindow: usage.context.window,
42      contextPercent: usage.context.percent ?? null,
43      costUsd: usage.cost?.usd ?? null,
44    }
45    await update($, live, () => snapshot)
46  } catch {
47    // Les chiffres précédents restent affichés.
48  }
49}
50
51const SHOWN_TOOLS = 8
52const SHOWN_TURNS = 10
53const SHOWN_FILES = 8
54
55export const register: Register = on => {
56  on('session.start', async ($, e, next) => {
57    await update($, cwd, () => e.cwd)
58    await $.command.register({
59      name: 'cockpit',
60      description: 'Ouvre le cockpit : statistiques live de la session',
61    })
62    const started = await next(e)
63    await refreshLive($)
64
65    return started
66  })
67
68  on('classic.PostModelSwitch', async ($, e, next) => {
69    const switched = await next(e)
70    await refreshLive($)
71
72    return switched
73  })
74
75  on('command.run', { command: 'cockpit' }, async $ => {
76    await refreshLive($)
77    await $.ui.open({ id: PANE, title: 'Cockpit', columns: 52 })
78
79    return { text: 'Cockpit ouvert.' }
80  })
81
82  on('tool.call', async ($, e, next) => {
83    const ran = await next(e)
84
85    if (ran.deny !== undefined) {
86      return ran
87    }
88
89    await update($, stats, s => withToolCall(s, e.tool))
90    await refreshLive($)
91
92    if (ran.isError !== true && EDIT_TOOLS.has(e.tool)) {
93      const path = e.tool === 'NotebookEdit' ? e.notebook_path : 'file_path' in e ? e.file_path : undefined
94
95      if (typeof path === 'string') {
96        await update($, stats, s => withFile(s, path))
97      }
98    }
99
100    return ran
101  })
102
103  on('turn.complete', async ($, e, next) => {
104    const { usage } = e
105
106    // Les tokens comptent aussi ceux des sous-agents ; les tours, la boucle principale seule.
107    if (usage !== undefined) {
108      const input = usage.input_tokens + usage.cache_read_input_tokens + usage.cache_creation_input_tokens
109      await update($, stats, s => withTokens(s, input, usage.output_tokens))
110    }
111
112    if (e.agentId === undefined) {
113      await update($, stats, s => withTurn(s, e.durationMs))
114    }
115
116    const done = await next(e)
117    await refreshLive($)
118
119    return done
120  })
121
122  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
123    const { Box, Text } = $.ui.resolve(e)
124    const s = await read($, stats)
125    const root = await read($, cwd)
126    const now = await read($, live)
127    const percent = now.contextPercent ?? 0
128    const gaugeColor = percent >= 80 ? 'red' : percent >= 50 ? 'yellow' : 'green'
129    const barWidth = Math.max(6, Math.min(24, (e.viewport?.columns ?? 52) - 26))
130
131    const tools = topTools(s, SHOWN_TOOLS)
132    const toolMax = tools[0]?.[1] ?? 0
133    const labelWidth = Math.max(4, ...tools.map(([name]) => Math.min(name.length, 14)))
134
135    const firstTurn = Math.max(0, s.durations.length - SHOWN_TURNS)
136    const turns = s.durations.slice(firstTurn)
137    const turnMax = Math.max(0, ...turns)
138    const turnOffset = s.turns - s.durations.length + firstTurn
139
140    return (
141      <Box flexDirection="column" gap={1}>
142        <Box flexDirection="column">
143          <Text>
144            <Text bold>Modèle </Text>
145            <Text color="cyan">{formatModel(now.model)}</Text>
146            <Text dimColor> · </Text>
147            <Text bold>Coût </Text>
148            <Text color="yellow">{now.costUsd === null ? '—' : formatUsd(now.costUsd)}</Text>
149          </Text>
150          <Text wrap="truncate">
151            <Text bold>Contexte </Text>
152            <Text color={gaugeColor}>{bar(percent, 100, barWidth).padEnd(barWidth, '░')}</Text>{' '}
153            {now.contextPercent === null ? '—' : `${now.contextPercent} %`}
154          </Text>
155          <Text dimColor>
156            {now.contextTokens === null
157              ? `fenêtre ${formatTokens(now.contextWindow)}`
158              : `${formatTokens(now.contextTokens)} / ${formatTokens(now.contextWindow)} tokens`}
159          </Text>
160        </Box>
161
162        <Box flexDirection="column">
163          <Text>
164            <Text bold>Tours </Text>
165            {s.turns}
166            <Text dimColor> · </Text>
167            <Text bold>Outils </Text>
168            {s.toolCalls}
169            <Text dimColor> · </Text>
170            <Text bold>Tokens </Text>
171            {formatTokens(s.tokensIn + s.tokensOut)}
172          </Text>
173          <Text dimColor>
174            entrée {formatTokens(s.tokensIn)} · sortie {formatTokens(s.tokensOut)}
175          </Text>
176        </Box>
177
178        <Box flexDirection="column">
179          <Text bold>Outils les plus utilisés</Text>
180          {tools.length === 0 && <Text dimColor>Aucun appel pour l'instant.</Text>}
181          {tools.map(([name, count]) => (
182            <Text key={`tool-${name}`} wrap="truncate">
183              {name.slice(0, 14).padEnd(labelWidth)} <Text color="cyan">{bar(count, toolMax, barWidth)}</Text> {count}
184            </Text>
185          ))}
186        </Box>
187
188        <Box flexDirection="column">
189          <Text bold>Durée des tours</Text>
190          {turns.length === 0 && <Text dimColor>Aucun tour terminé.</Text>}
191          {turns.map((ms, i) => (
192            <Text key={`turn-${turnOffset + i}`} wrap="truncate">
193              {`#${turnOffset + i + 1}`.padEnd(5)} <Text color="magenta">{bar(ms, turnMax, barWidth)}</Text>{' '}
194              {formatDuration(ms)}
195            </Text>
196          ))}
197        </Box>
198
199        <Box flexDirection="column">
200          <Text bold>Derniers fichiers modifiés</Text>
201          {s.files.length === 0 && <Text dimColor>Aucun fichier modifié.</Text>}
202          {s.files.slice(0, SHOWN_FILES).map(file => (
203            <Text key={`file-${file}`} color="green" wrap="truncate-start">
204              {shortPath(file, root)}
205            </Text>
206          ))}
207        </Box>
208      </Box>
209    )
210  })
211}
212
hooks/stats.ts 78 lines
1import type { CockpitStats } from '../types'
2
3const MAX_DURATIONS = 50
4const MAX_FILES = 20
5
6export const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
7
8export const emptyStats = (): CockpitStats => ({
9  turns: 0,
10  toolCalls: 0,
11  tokensIn: 0,
12  tokensOut: 0,
13  tools: {},
14  durations: [],
15  files: [],
16})
17
18export const withToolCall = (stats: CockpitStats, tool: string): CockpitStats => ({
19  ...stats,
20  toolCalls: stats.toolCalls + 1,
21  tools: { ...stats.tools, [tool]: (stats.tools[tool] ?? 0) + 1 },
22})
23
24// Le plus récent en tête, sans doublon.
25export const withFile = (stats: CockpitStats, path: string): CockpitStats => ({
26  ...stats,
27  files: [path, ...stats.files.filter(f => f !== path)].slice(0, MAX_FILES),
28})
29
30export const withTokens = (stats: CockpitStats, input: number, output: number): CockpitStats => ({
31  ...stats,
32  tokensIn: stats.tokensIn + input,
33  tokensOut: stats.tokensOut + output,
34})
35
36export const withTurn = (stats: CockpitStats, durationMs: number): CockpitStats => ({
37  ...stats,
38  turns: stats.turns + 1,
39  durations: [...stats.durations, durationMs].slice(-MAX_DURATIONS),
40})
41
42export const topTools = (stats: CockpitStats, count: number): [string, number][] =>
43  Object.entries(stats.tools)
44    .sort((a, b) => b[1] - a[1])
45    .slice(0, count)
46
47export const formatTokens = (n: number): string =>
48  n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : n >= 1_000 ? `${(n / 1_000).toFixed(1)}k` : `${n}`
49
50export const formatDuration = (ms: number): string => {
51  const seconds = Math.round(ms / 1000)
52
53  return seconds >= 60 ? `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, '0')}s` : `${seconds}s`
54}
55
56// Une barre d'au moins un bloc pour toute valeur non nulle.
57export const bar = (value: number, max: number, width: number): string =>
58  max <= 0 || value <= 0 ? '' : '█'.repeat(Math.max(1, Math.round((value / max) * width)))
59
60export const shortPath = (path: string, cwd: string): string =>
61  cwd !== '' && path.startsWith(`${cwd}/`) ? path.slice(cwd.length + 1) : path
62
63// claude-opus-5-5 → Opus 5.5, claude-sonnet-5-5[1m] → Sonnet 5.5 [1m]
64export const formatModel = (id: string): string => {
65  const match = /^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?!\d)(?:-\d{8})?(\[.+\])?$/.exec(id)
66
67  if (match === null) {
68    return id === '' ? '—' : id
69  }
70
71  const [, family = '', major, minor, suffix] = match
72  const name = family.charAt(0).toUpperCase() + family.slice(1)
73
74  return `${name} ${major}${minor !== undefined ? `.${minor}` : ''}${suffix !== undefined ? ` ${suffix}` : ''}`
75}
76
77export const formatUsd = (usd: number): string => (usd < 10 ? `$${usd.toFixed(3)}` : `$${usd.toFixed(2)}`)
78
types/index.d.ts 24 lines
1export type CockpitStats = {
2  turns: number
3  toolCalls: number
4  tokensIn: number
5  tokensOut: number
6  tools: Record<string, number>
7  durations: number[]
8  files: string[]
9}
10
11export type CockpitLive = {
12  model: string
13  contextTokens: number | null
14  contextWindow: number
15  contextPercent: number | null
16  costUsd: number | null
17}
18
19declare module 'claude-code' {
20  interface PluginState {
21    cockpit: { stats: CockpitStats; cwd: string; live: CockpitLive }
22  }
23}
24