SLOPSHOPPER

diff-replie

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

rowsguardcommandtimer
v1.0.1Apache-2.0updated 2026-10-21Aliquanto3/claude-code-mods/plugins/diff-replie
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diff-replie
› 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 › /details ⎿ diff-replie: Diffs et sorties dépliés. ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-code-mods

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.

Mods

ModCe qu'il fait
diff-replieReplie 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

Installer

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.

diff-replie

Les éditions de fichiers et les commandes shell occupent beaucoup de place dans la conversation. Le mod les replie :

  • Édition (Edit, Write) : ⎿ Modifié epics.md (+42 −7) au lieu du diff ; Créé notes.md (40 lignes) pour un nouveau fichier.
  • Commande (Bash, PowerShell) : la ligne d'appel montre la description de la commande, et la sortie devient ⎿ Sortie repliée : 12 lignes de sortie.
  • Appels consécutifs du même type, sans texte entre eux : une seule ligne, par exemple ● 3 commandes shell : Lister le dossier · Compter les lignes · Nettoyer.
  • Erreurs : un appel en échec reste affiché en entier.
  • /details : déplie tout, puis replie tout au second appel.
  • Plein écran (/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.

Licence

Apache 2.0.

Développer un mod

claude plugin validate plugins/<mod>
claude plugin test plugins/<mod>
claude --plugin-dir plugins/<mod>
Source 2 files
hooks/register.tsx 291 lines
1import 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}
291
types/index.d.ts 9 lines
1/** 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