Replie les diffs d'Edit et Write et la sortie des commandes shell en une ligne, regroupe les appels consécutifs du même type, et /details déplie tout

Mods pour Claude Code : des extensions de son interface, installables en une ligne.
Mods for Claude Code: interface extensions you install with one line. The mods' interface text is in French.
| Mod | Ce qu'il fait |
|---|---|
diff-replie | Replie les diffs des éditions de fichiers et la sortie des commandes shell en une ligne, regroupe les appels consécutifs du même type, et /details déplie tout |
Dans une session Claude Code, à l'invite du terminal :
/plugin install diff-replie --marketplace Aliquanto3/claude-code-mods
Répondre y pour ajouter le marketplace, puis choisir la portée (utilisateur par défaut). Le mod est actif aussitôt, sans redémarrage, si les mods de code sont activés.
Prérequis : les mods de code de Claude Code sont en accès anticipé. Sans la variable CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, le mod s'installe mais ne se charge pas, sans message. Ajoutez-la au bloc env de ~/.claude/settings.json ("env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" }), puis redémarrez Claude Code. Testé avec Claude Code 2.1.280 et 2.1.306.
Depuis un shell :
claude plugin install diff-replie --marketplace Aliquanto3/claude-code-mods
Dans l'application de bureau, où /plugin ne fonctionne pas : cliquer sur + à côté de la zone de saisie, puis Plugins, Add marketplace avec Aliquanto3/claude-code-mods, puis Add plugin.
Les éditions de fichiers et les commandes shell occupent beaucoup de place dans la conversation. Le mod les replie :
⎿ Modifié epics.md (+42 −7) au lieu du diff ; Créé notes.md (40 lignes) pour un nouveau fichier.⎿ Sortie repliée : 12 lignes de sortie.● 3 commandes shell : Lister le dossier · Compter les lignes · Nettoyer./details : déplie tout, puis replie tout au second appel./tui fullscreen) : les boutons « Voir le diff », « Voir la sortie » et « Voir le détail » déplient un appel ou un groupe d'un clic. En affichage normal, le terminal ne transmet pas les clics : la ligne dit /details pour déplier.Le mod ne change que l'affichage : Claude reçoit toujours le résultat complet de chaque appel. Les appels que Claude Code range déjà dans ses propres lignes repliées (lectures, recherches) ne sont pas touchés.
claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
claude --plugin-dir plugins/<mod>hooks/register.tsx 291 lines1import type { Register } from 'claude-code'
2
3import type { Expanded, LeaderOf, Runs, Summaries } from '../types'
4
5// État du mod, en mémoire du process et remis à zéro par `resetState` à chaque début et fin de
6// session (/clear, /resume), comme l'affichage replié/déplié lui-même. `claude-code` n'exporte plus
7// d'atome réactif : un changement qui doit se voir hors du rendu qui l'a causé (bouton, /details,
8// fin d'un appel) passe par `$.ui.invalidate('ui.render')`, qui redessine les rendus du mod.
9let expanded: Expanded = []
10let runs: Runs = {}
11let leaderOf: LeaderOf = {}
12let summaries: Summaries = {}
13// Tout déplier d'un coup (commande /details) : le seul geste possible hors du plein écran,
14// où le terminal ne transmet pas les clics.
15let showAll = false
16
17function resetState() {
18 expanded = []
19 runs = {}
20 leaderOf = {}
21 summaries = {}
22 showAll = false
23}
24
25const COMMAND = 'details'
26const HINT = ' · /details pour déplier'
27// Les boutons ne répondent qu'où la surface transmet les clics : terminal plein écran, desktop, IDE.
28const canPress = (e: { surface: string; viewport?: { isFullscreen?: boolean } }) =>
29 e.surface !== 'terminal' || e.viewport?.isFullscreen === true
30
31type Kind = 'edit' | 'shell'
32const kindOf = (tool: string): Kind | null =>
33 tool === 'Edit' || tool === 'Write' ? 'edit' : tool === 'Bash' || tool === 'PowerShell' ? 'shell' : null
34
35type Patch = { lines: string[] }[]
36type EditOutput = { filePath?: string; structuredPatch?: Patch; type?: 'create' | 'update'; content?: string; staged?: boolean }
37type ShellOutput = { stdout?: string; stderr?: string; interrupted?: boolean }
38type ShellInput = { command?: string; description?: string }
39
40const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
41const lineCount = (text = '') => (text.trim() === '' ? 0 : text.trimEnd().split('\n').length)
42const plural = (n: number, word: string) => `${n} ${word}${n > 1 ? 's' : ''}`
43
44const isCreate = (tool: string, output: EditOutput) => tool === 'Write' && output.type === 'create'
45
46export const editStats = (tool: string, output: EditOutput) => {
47 const name = baseName(output.filePath ?? 'fichier')
48 if (isCreate(tool, output)) return `${name} (créé, ${plural(lineCount(output.content), 'ligne')})`
49 let added = 0
50 let removed = 0
51 for (const hunk of output.structuredPatch ?? []) {
52 for (const line of hunk.lines) {
53 if (line.startsWith('+')) added += 1
54 else if (line.startsWith('-')) removed += 1
55 }
56 }
57 return `${name} (+${added} −${removed})`
58}
59
60export const summarizeEdit = (tool: string, output: EditOutput) =>
61 isCreate(tool, output)
62 ? `Créé ${baseName(output.filePath ?? 'fichier')} (${plural(lineCount(output.content), 'ligne')})`
63 : `Modifié ${editStats(tool, output)}`
64
65export const summarizeShell = (output: ShellOutput) => {
66 const out = lineCount(output.stdout)
67 const err = lineCount(output.stderr)
68 const parts = [out === 0 ? 'aucune sortie' : `${plural(out, 'ligne')} de sortie`]
69 if (err > 0) parts.push(`${plural(err, 'ligne')} d'erreur`)
70 if (output.interrupted) parts.push('interrompue')
71 return `Sortie repliée : ${parts.join(', ')}`
72}
73
74export const groupTitle = (kind: Kind, count: number) =>
75 kind === 'shell' ? `${count} commandes shell` : `${count} modifications de fichiers`
76
77const groupOf = (id: string) => {
78 const leader = leaderOf[id]
79 const ids = leader ? (runs[leader] ?? []) : []
80 return { leader, ids, isGrouped: ids.length > 1 }
81}
82
83// Retire des groupes du mod les appels que Claude Code a rangés dans ses propres lignes ;
84// rend, pour chaque groupe touché, le nouvel appel de tête (null si le groupe est vide).
85// Coût borné par la taille des groupes touchés, pas par l'historique de la session.
86function detach(gone: string[]): Record<string, string | null> {
87 const moved: Record<string, string | null> = {}
88 const goneIds = new Set(gone)
89 for (const leader of new Set(gone.map(id => leaderOf[id]).filter(Boolean))) {
90 const members = (runs[leader] ?? []).filter(x => !goneIds.has(x))
91 delete runs[leader]
92 const newLeader = members[0] ?? null
93 moved[leader] = newLeader
94 if (newLeader) {
95 runs[newLeader] = members
96 for (const id of members) leaderOf[id] = newLeader
97 }
98 }
99 for (const id of gone) delete leaderOf[id]
100 return moved
101}
102
103export const register: Register = on => {
104 // Suivi des appels consécutifs : même type, sans texte du modèle entre eux.
105 let current: { leader: string; kind: Kind } | null = null
106 let textSinceLastCall = false
107 // Appels déjà rangés par Claude Code dans ses propres lignes (voir ui.render ToolGroup).
108 const engineFolded = new Set<string>()
109 const resetSession = () => {
110 resetState()
111 current = null
112 textSinceLastCall = false
113 engineFolded.clear()
114 }
115
116 on('session.start', async ($, e, next) => {
117 resetSession()
118 await $.command.register({ name: COMMAND, description: 'Déplie ou replie les diffs et les sorties de commandes' })
119 return next(e)
120 })
121
122 on('session.end', ($, e, next) => {
123 resetSession()
124 return next(e)
125 })
126
127 on('command.run', { command: COMMAND }, $ => {
128 const isShown = !showAll
129 showAll = isShown
130 $.ui.invalidate('ui.render')
131 return { text: isShown ? 'Diffs et sorties dépliés.' : 'Diffs et sorties repliés.' }
132 })
133
134 on('turn.start', ($, e, next) => {
135 current = null
136 return next(e)
137 })
138
139 on('turn.step', async function* ($, e, next) {
140 const result = yield* next(e)
141 if (result.answer.trim() !== '') textSinceLastCall = true
142 return result
143 })
144
145 on('tool.call', async ($, e, next) => {
146 const id = e.tool_use_id
147 const kind = kindOf(e.tool)
148 const isSubagent = 'agentId' in e && Boolean(e.agentId)
149 if (!id || isSubagent) return next(e)
150 if (!kind) {
151 current = null
152 return next(e)
153 }
154
155 if (!current || current.kind !== kind || textSinceLastCall) current = { leader: id, kind }
156 textSinceLastCall = false
157 const leader = current.leader
158 // Les arguments de l'appel sont à plat sur l'événement (e.command, e.file_path).
159 const input = e as unknown as ShellInput & { file_path?: string }
160 const pending = kind === 'shell' ? (input.description ?? input.command ?? 'commande') : baseName(input.file_path ?? 'fichier')
161
162 leaderOf[id] = leader
163 ;(runs[leader] ??= []).push(id)
164 summaries[id] = { label: `${pending} (en cours)`, isError: false }
165 // La ligne de tête du groupe affiche ce nouvel appel : la redessiner.
166 $.ui.invalidate('ui.render')
167
168 const answer = await next(e)
169 const isError = Boolean((answer as { isError?: boolean }).isError)
170 const output = (answer as { result?: unknown }).result as EditOutput | undefined
171 const label = kind === 'edit' && output && !isError ? editStats(e.tool, output) : pending
172 summaries[id] = { label: isError ? `${label} (échec)` : label, isError }
173 $.ui.invalidate('ui.render')
174 return answer
175 })
176
177 // Claude Code range déjà certains appels dans ses propres lignes repliées (lectures, `wc`,
178 // éditions du dossier temporaire) : on les retire des groupes du mod, sinon un groupe
179 // pourrait dépendre d'une ligne invisible. L'écriture part hors du dessin (refusée pendant).
180
181 on('ui.render', { component: 'ToolGroup' }, ($, e, next) => {
182 const fresh = e.props.calls.map(c => c.tool_use_id).filter((id): id is string => Boolean(id) && !engineFolded.has(id!))
183 if (fresh.length > 0) {
184 fresh.forEach(id => engineFolded.add(id))
185 $.clock.after(0, () => {
186 const moved = detach(fresh)
187 if (current && current.leader in moved) {
188 const newLeader = moved[current.leader]
189 current = newLeader ? { ...current, leader: newLeader } : null
190 }
191 $.ui.invalidate('ui.render')
192 })
193 }
194 return next(e)
195 })
196
197 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
198 const kind = kindOf(e.props.tool)
199 if (!kind || showAll) return next(e)
200 const id = e.props.tool_use_id
201 const { leader, ids, isGrouped } = groupOf(id)
202 const { Box, Button, Text } = $.ui.resolve(e)
203
204 if (isGrouped && leader) {
205 const isOpen = expanded.includes(leader)
206 if (id !== leader) return isOpen ? next(e) : <Box />
207 const toggle = () => {
208 expanded = isOpen ? expanded.filter(x => x !== leader) : [...expanded, leader]
209 $.ui.invalidate('ui.render')
210 }
211 if (isOpen) {
212 const engineTree = await next(e)
213 return (
214 <Box flexDirection="column">
215 <Button key="replier-groupe" label={`Replier les ${groupTitle(kind, ids.length)}`} onPress={toggle} />
216 {engineTree ?? null}
217 </Box>
218 )
219 }
220 const labels = ids.map(x => summaries[x]?.label ?? '…')
221 const errors = ids.filter(x => summaries[x]?.isError).length
222 return (
223 <Box marginTop={1}>
224 <Text>
225 {'● '}
226 {groupTitle(kind, ids.length)}
227 {errors > 0 ? ` dont ${errors} en échec` : ''}
228 <Text dimColor> : {labels.join(' · ')}{canPress(e) ? ' ' : HINT}</Text>
229 </Text>
230 {canPress(e) ? <Button key="voir-groupe" label="Voir le détail" onPress={toggle} /> : null}
231 </Box>
232 )
233 }
234
235 // Appel seul : la description de la commande à la place de son texte.
236 const input = e.props.input as ShellInput | undefined
237 if (kind !== 'shell' || !input?.description || !input.command || expanded.includes(id)) return next(e)
238 return next({ ...e, props: { ...e.props, input: { ...input, command: input.description } } })
239 })
240
241 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
242 const { tool, tool_use_id: id, isErrored } = e.props
243 const kind = kindOf(tool)
244 const output = e.props.output as (EditOutput & ShellOutput) | undefined
245 if (!kind || isErrored || !output || output.staged || showAll) return next(e)
246
247 const { Box, Button, Text } = $.ui.resolve(e)
248 const { leader, isGrouped } = groupOf(id)
249 if (isGrouped && leader) return expanded.includes(leader) ? next(e) : <Box />
250
251 const noun = kind === 'edit' ? 'le diff' : 'la sortie'
252 if (expanded.includes(id)) {
253 const engineTree = await next(e)
254 return (
255 <Box flexDirection="column">
256 {engineTree ?? null}
257 <Button
258 key="masquer"
259 label={`Masquer ${noun}`}
260 onPress={() => {
261 expanded = expanded.filter(x => x !== id)
262 $.ui.invalidate('ui.render')
263 }}
264 />
265 </Box>
266 )
267 }
268
269 const summary = kind === 'edit' ? summarizeEdit(tool, output) : summarizeShell(output)
270 return (
271 <Box>
272 <Text dimColor>
273 {' ⎿ '}
274 {summary}
275 {canPress(e) ? ' ' : HINT}
276 </Text>
277 {canPress(e) ? (
278 <Button
279 key="voir"
280 label={`Voir ${noun}`}
281 onPress={() => {
282 expanded = [...expanded, id]
283 $.ui.invalidate('ui.render')
284 }}
285 />
286 ) : null}
287 </Box>
288 )
289 })
290}
291types/index.d.ts 9 lines1/** tool_use_id des résultats ou des groupes dépliés. */
2export type Expanded = string[]
3/** Groupes d'appels consécutifs du même type : id du premier appel → ids du groupe. */
4export type Runs = Record<string, string[]>
5/** Appel → id du premier appel de son groupe. */
6export type LeaderOf = Record<string, string>
7/** Appel → sa ligne de synthèse (« epics.md (+2 −1) », description de la commande). */
8export type Summaries = Record<string, { label: string; isError: boolean }>
9