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

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.
| Mod | Ce qu'il fait |
|---|---|
| 🛡️ garde-du-corps | Bloque 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. |
| 🚀 cockpit | Un 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 tonsettings.json.
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
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
claude plugin validate garde-du-corps && claude plugin test garde-du-corps
claude plugin validate cockpit && claude plugin test cockpit
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.
Licence MIT.
hooks/register.tsx 212 lines1import { 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}
212hooks/stats.ts 78 lines1import 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)}`)
78types/index.d.ts 24 lines1export 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