SLOPSHOPPER

menage

Ménage : ce que le travail de Claude laisse traîner sur ce PC, en lecture seule. Worktrees (branche, mergée ou non, propre ou modifié, âge, taille), dossiers…

panecommandprocesstimer
v0.1.0MITupdated 2026-10-22julian-passebecq/claude-mod/mods/menage
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · menage
│ ┃ Ménage ✕ › 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 │ │ › /menage │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ménage
README

menage (« Ménage »)

Now shown inside Registres (tab Ménage, /registres menage); /menage keeps working (this mod while loaded, else Registres' alias).

Read-only inventory of what Claude work leaves lying on this PC: worktrees (branch, merged or not, clean or modified, age, size), orphan worktree folders, local and remote branches already merged, mod copies, deploy backups, transcripts per project, temporary folders, archivable sessions. Each row shows the exact safe command to run; the mod never runs it and never deletes anything. Rows in red are « ne pas supprimer ».

Now also a tab of Registres: /registres menage (or /menage when this mod is not loaded) shows the same inventory in the Registres pane, drawn by a copy of hooks/view.mjs. This mod keeps working on its own while it is loaded.

Commands

  • /menage [refresh] | close: the pane. Buttons: filters (Tout, Récupérable, Ne pas supprimer), ↻, « Tout copier » and « Copier » per section or row (copies the commands, runs nothing).

How it runs

scanner/menage_scan.mjs scan (Node, read-only git commands, folder sizes through scanner/sizes.ps1 on Windows) runs in the background when ~/.claude/menage-mod/menage.json is older than refresh_min or on ↻. Settings: menage.config.json, deep-merged with ~/.claude/menage-mod/menage.config.json.

Code layout

  • lib/classify.mjs: levels, filters, commands (pure).
  • hooks/view.mjs: the pane's drawing, pure (element table in, tree out); also copied into mods/registres.
  • hooks/register.tsx: reads the files, runs the scanner, hands the facts to the view.

Tests

node --test mods/menage/tests/*.test.mjs
claude plugin validate mods/menage
claude plugin test mods/menage
Source 4 files
hooks/register.tsx 110 lines
1// menage mod ("Ménage"): read-only inventory of what Claude work left lying on this PC, with the exact safe command
2// for each row. /menage [refresh|close] opens the pane. Figures come from scanner/menage_scan.mjs (Node, read-only git
3// commands, folder sizes, Desktop session files), run in the background. The mod never runs a command it shows and
4// never deletes anything; the drawing is hooks/view.mjs (pure, also drawn by the registres mod's « Ménage » tab); the command shows a pane and returns no text, so nothing reaches the model; nothing here
5// blocks, rewrites or delays a tool call or a prompt.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import { deepMerge, fill } from '../lib/classify.mjs'
10import { drawMenage } from './view.mjs'
11
12const PANE = 'menage'
13const filterAtom = atom({ plugin: 'menage', key: 'filter' } as const, 'all')
14const copiedAtom = atom({ plugin: 'menage', key: 'copied' } as const, '')
15
16const S: { busy: boolean; error: string; model: any } = { busy: false, error: '', model: undefined }
17
18const home = async ($: EngineInterface) => {
19  let v: string | undefined
20  try {
21    v = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
22  } catch {}
23  return (v ?? '').replace(/\\/g, '/')
24}
25const readJson = async ($: EngineInterface, path: string): Promise<any> => {
26  try {
27    return JSON.parse((await $.fs.read(path, { as: 'text' })) as string)
28  } catch {
29    return undefined
30  }
31}
32
33async function context($: EngineInterface) {
34  const root = $.plugin.root.replace(/\\/g, '/')
35  const h = await home($)
36  const base = (await readJson($, `${root}/menage.config.json`)) || {}
37  const dataDir = String(base.data_dir || '~/.claude/menage-mod').replace(/^~(?=[\\/]|$)/, h).replace(/\\/g, '/')
38  const cfg = deepMerge(base, (await readJson($, `${dataDir}/menage.config.json`)) || {})
39  return { root, dataDir, cfg }
40}
41
42/** Runs the scanner in the background and redraws; never awaited by a hook the person waits on. */
43async function rescan($: EngineInterface, force = false) {
44  if (S.busy) return
45  S.busy = true
46  $.ui.invalidate('ui.render')
47  try {
48    const { root, cfg } = await context($)
49    const argv = [cfg.node || 'node', `${root}/scanner/menage_scan.mjs`, 'scan', ...(force ? ['--force'] : [])]
50    const r = await $.process.run(argv, { timeoutMs: Math.min((cfg.scan_timeout_s ?? 600) * 1000, 600000) })
51    if (r.exitCode !== 0) throw new Error((r.stderr || '').trim().split('\n').pop() || `scanner exit ${r.exitCode}`)
52    S.error = ''
53    S.model = undefined
54  } catch (err) {
55    S.error = String((err as Error)?.message ?? err).slice(0, 300)
56  } finally {
57    S.busy = false
58  }
59  $.ui.invalidate('ui.render')
60}
61
62async function load($: EngineInterface) {
63  const { cfg, dataDir } = await context($)
64  const model = S.model ?? (S.model = await readJson($, `${dataDir}/menage.json`))
65  const now = await $.clock.now()
66  const stale = !model || now - (model.generated || 0) > (cfg.refresh_min ?? 30) * 60000
67  return { cfg, model, stale, now }
68}
69
70export const register: Register = on => {
71  on('session.start', async ($, e, next) => {
72    await $.command.register({
73      name: 'menage',
74      description: 'Ménage : worktrees, branches mergées, dossiers et sessions qui traînent, avec la commande sûre pour chacun (lecture seule, rien n’est supprimé)',
75      argumentHint: '[refresh] | close',
76    })
77    return next(e)
78  })
79
80  on('command.run', { command: 'menage' }, async ($, e) => {
81    const args = String(e.args ?? '').trim().toLowerCase()
82    if (args === 'close') {
83      await $.ui.close({ id: PANE })
84      return {}
85    }
86    const { cfg, stale } = await load($)
87    const refresh = args.split(/\s+/).includes('refresh')
88    if (stale || refresh) $.clock.after(0, () => rescan($, refresh))
89    await $.ui.open({ id: PANE, title: cfg.labels?.pane_title ?? 'Ménage' })
90    return {} // a pane, no transcript text: nothing for the model to read
91  })
92
93  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
94    const { cfg, model, now } = await load($)
95    const L = cfg.labels ?? {}
96    const filter = (await read($, filterAtom)) as string
97    const copied = (await read($, copiedAtom)) as string
98    const copy = async (press: any, lines: string[]) => {
99      if (!lines.length) return
100      const r = await $.ui.copy({ text: lines.join('\n'), surface: press.surface })
101      await update($, copiedAtom, () => (r.isCopied ? fill(L.copied, { n: lines.length }) : L.copy_failed))
102    }
103    return drawMenage($.ui.resolve(e), { cfg, model, now, filter, copied, busy: S.busy, error: S.error }, {
104      setFilter: (id: string) => update($, filterAtom, () => id),
105      rescan: () => { $.clock.after(0, () => rescan($, true)) },
106      copy,
107    }) as any
108  })
109}
110
lib/classify.mjs 230 lines
1// menage mod: pure classification of what lies around (worktrees, branches, folders, sessions), the exact command
2// to show for each row and the totals. No I/O: node --test covers it directly. The mod never runs a command.
3
4/** Forward slashes, no trailing slash. */
5export const norm = p => String(p || '').replace(/\\/g, '/').replace(/\/+$/, '')
6/** Key to compare paths (case-insensitive on Windows-style drive paths). */
7export const pathKey = p => {
8  const n = norm(p)
9  return /^[A-Za-z]:\//.test(n) ? n.toLowerCase() : n
10}
11/** A path quoted for the shell (double quotes, forward slashes). */
12export const q = p => `"${norm(p)}"`
13
14/** `{name}` placeholders from `vars`. */
15export const fill = (s, vars) => String(s || '').replace(/\{(\w+)\}/g, (m, k) => (vars[k] === undefined ? m : String(vars[k])))
16
17/** Deep merge: objects merge, arrays and scalars from `b` win. */
18export function deepMerge(a, b) {
19  if (Array.isArray(b)) return b.slice()
20  if (!b || typeof b !== 'object') return b === undefined ? a : b
21  const out = { ...(a && typeof a === 'object' && !Array.isArray(a) ? a : {}) }
22  for (const [k, v] of Object.entries(b)) out[k] = deepMerge(out[k], v)
23  return out
24}
25
26/** `~` and `%VAR%` / `$VAR` expanded, forward slashes. */
27export function expand(p, env = {}) {
28  let s = String(p || '')
29  s = s.replace(/^~(?=[\\/]|$)/, env.HOME || '')
30  s = s.replace(/%([A-Za-z_][A-Za-z0-9_]*)%/g, (m, k) => (env[k] ?? m))
31  s = s.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (m, k) => (env[k] ?? m))
32  return norm(s)
33}
34
35/** A value still in <angle brackets> counts as unset. */
36export const unset = p => !p || /<[^>]+>/.test(String(p))
37
38/** `git worktree list --porcelain` → [{ path, head, branch, detached, bare, locked, prunable }]. */
39export function parseWorktrees(text) {
40  const out = []
41  let cur
42  for (const line of String(text || '').split(/\r?\n/)) {
43    if (line.startsWith('worktree ')) {
44      cur = { path: norm(line.slice(9)), head: '', branch: '', detached: false, bare: false, locked: false, prunable: false }
45      out.push(cur)
46    } else if (!cur) continue
47    else if (line.startsWith('HEAD ')) cur.head = line.slice(5)
48    else if (line.startsWith('branch ')) cur.branch = line.slice(7).replace(/^refs\/heads\//, '')
49    else if (line === 'detached') cur.detached = true
50    else if (line === 'bare') cur.bare = true
51    else if (line.startsWith('locked')) cur.locked = true
52    else if (line.startsWith('prunable')) cur.prunable = true
53  }
54  return out
55}
56
57/** `git status --porcelain` text → counts. */
58export function parseStatus(text) {
59  const lines = String(text || '').split(/\r?\n/).filter(Boolean)
60  return { changed: lines.filter(l => !l.startsWith('??')).length, untracked: lines.filter(l => l.startsWith('??')).length }
61}
62
63/** owner/name from a git remote URL (https or ssh), or ''. */
64export function slugOf(url) {
65  const m = String(url || '').trim().match(/[:/]([^/:]+(?:\/[^/:]+)*?\/[^/]+?)(?:\.git)?\/?$/)
66  if (!m) return ''
67  const parts = m[1].split('/')
68  return parts.slice(-2).join('/').toLowerCase()
69}
70
71/** Branch name → merged PR info, from the Desktop session files' `prs` lists. Key: `<slug>#<branch>` and `#<branch>`. */
72export function prIndex(sessions) {
73  const idx = {}
74  for (const s of sessions || []) {
75    for (const p of s.prs || []) {
76      if (!p || !p.branch) continue
77      const rec = { state: String(p.state || '').toUpperCase(), number: p.number, url: p.url }
78      for (const k of [`${String(p.repo || '').toLowerCase()}#${p.branch}`, `#${p.branch}`]) {
79        const old = idx[k]
80        if (!old || rec.state === 'MERGED' || (old.state !== 'MERGED' && rec.state === 'OPEN')) idx[k] = rec
81      }
82    }
83  }
84  return idx
85}
86export const prFor = (idx, slug, branch) => (branch ? idx[`${slug}#${branch}`] || idx[`#${branch}`] : undefined)
87
88/**
89 * How a branch (or detached HEAD) relates to the base branch.
90 * facts: { ancestor, empty, squash, pr: {state}, gone }. Returns { merged, how, label }.
91 *   ancestor: tip reachable from base (git branch --merged); squash: the files the branch changed are identical in base;
92 *   pr: a session file says its PR is MERGED. gone alone (remote branch deleted) is a hint, not proof.
93 */
94export function mergeState(f, labels = {}) {
95  const L = { ancestor: 'mergée', empty: 'aucun commit en plus de main', squash: 'mergée (squash : contenu identique dans main)', pr: 'PR #{n} mergée', gone: 'branche distante supprimée, non prouvée mergée', no: 'non mergée', ...labels }
96  if (f.ancestor && f.empty) return { merged: true, how: 'empty', label: L.empty }
97  if (f.ancestor) return { merged: true, how: 'ancestor', label: L.ancestor }
98  if (f.squash) return { merged: true, how: 'squash', label: L.squash }
99  if (f.pr && f.pr.state === 'MERGED') return { merged: true, how: 'pr', label: fill(L.pr, { n: f.pr.number ?? '?' }) }
100  if (f.gone) return { merged: false, how: 'gone', label: L.gone }
101  return { merged: false, how: 'no', label: L.no }
102}
103
104const DEFAULT_CMDS = {
105  worktree_remove: 'git -C {repo} worktree remove {path}',
106  worktree_prune: 'git -C {repo} worktree prune',
107  branch_delete: 'git -C {repo} branch -d {branch}',
108  branch_delete_squash: 'git -C {repo} branch -D {branch}',
109  remote_delete: 'git -C {repo} push {remote} --delete {branch}',
110  folder_delete: 'rm -rf {path}',
111}
112/** The exact command for a row, from the config's `commands` templates; paths quoted. */
113export function command(kind, vars, cmds = {}) {
114  const t = { ...DEFAULT_CMDS, ...cmds }[kind]
115  if (!t) return ''
116  return fill(t, { ...vars, repo: vars.repo ? q(vars.repo) : vars.repo, path: vars.path ? q(vars.path) : vars.path })
117}
118
119/**
120 * One worktree row: level safe (green, removable as is), check (orange), danger (red, "ne pas supprimer").
121 * w: parsed worktree plus { status: {changed, untracked}, merge: mergeState(), isMain }.
122 */
123export function worktreeRow(w, repo, cfg = {}) {
124  const L = cfg.labels || {}
125  const reasons = []
126  if (w.prunable) return { level: 'safe', reasons: [L.prunable || 'dossier disparu : git le liste encore'], command: command('worktree_prune', { repo }, cfg.commands), reclaim: 0 }
127  const dirty = w.status && w.status.changed + w.status.untracked > 0
128  if (dirty) reasons.push(fill(L.dirty || 'modifié : {changed} fichiers modifiés, {untracked} non suivis', w.status))
129  if (!w.merge?.merged) reasons.push(w.merge?.label || L.unmerged || 'non mergée')
130  if (w.locked) reasons.push(L.locked || 'verrouillé (git worktree lock)')
131  if (w.unknown) reasons.push(L.unknown || 'état illisible')
132  if (reasons.length) return { level: 'danger', reasons, command: '', reclaim: 0 }
133  const cmd = command('worktree_remove', { repo, path: w.path }, cfg.commands)
134  return { level: 'safe', reasons: [w.merge.label, L.clean || 'propre'], command: cmd, reclaim: w.size?.bytes || 0 }
135}
136
137/** A merged branch row (local or remote); squash/pr-merged local branches need -D since -d refuses them. */
138export function branchRow(b, repo, cfg = {}) {
139  const L = cfg.labels || {}
140  if (b.remote) {
141    return { level: 'safe', reasons: [b.merge.label, L.remote_note || 'supprime la branche sur le serveur'], command: command('remote_delete', { repo, remote: b.remoteName || 'origin', branch: b.name }, cfg.commands) }
142  }
143  if (b.merge.how === 'ancestor' || b.merge.how === 'empty') {
144    return { level: 'safe', reasons: [b.merge.label], command: command('branch_delete', { repo, branch: b.name }, cfg.commands) }
145  }
146  return { level: 'check', reasons: [b.merge.label, L.squash_note || '-d refuse une branche mergée par squash : -D, après vérification'], command: command('branch_delete_squash', { repo, branch: b.name }, cfg.commands) }
147}
148
149/** Keep the newest `keep` entries (by time), the rest are old. */
150export function splitOld(items, keep) {
151  const sorted = [...items].sort((a, b) => (b.time || 0) - (a.time || 0))
152  return { kept: sorted.slice(0, Math.max(0, keep)), old: sorted.slice(Math.max(0, keep)) }
153}
154
155/**
156 * A Desktop session that can be archived: not archived, idle at least `idleH` hours, every PR merged or closed and at
157 * least one merged. s: { archived, lastActivityAt, prs: [{state}] }.
158 */
159export function archivable(s, now, idleH = 12) {
160  if (s.archived || !s.prs || !s.prs.length) return false
161  if (now - (s.lastActivityAt || 0) < idleH * 3600000) return false
162  const st = s.prs.map(p => String(p.state || '').toUpperCase())
163  return st.every(x => x === 'MERGED' || x === 'CLOSED') && st.includes('MERGED')
164}
165
166/** Is a mods folder loaded by CLAUDE_CODE_PLUGIN_DIRS (paths separated by ; or , ; : only between POSIX paths)? */
167export function pluginDirs(value, env = {}) {
168  const v = String(value || '')
169  const parts = v.includes(';') || /^[A-Za-z]:/.test(v) ? v.split(/[;,]/) : v.split(/[:,]/)
170  return parts.map(s => s.trim()).filter(Boolean).map(s => pathKey(expand(s, env)))
171}
172
173/** Totals: sure = safe rows' bytes; check = check rows' bytes; counts per section and level. */
174export function totals(sections) {
175  const t = { sure: 0, check: 0, rows: 0, danger: 0, commands: 0 }
176  for (const s of sections || []) {
177    for (const r of s.rows || []) {
178      t.rows++
179      if (r.level === 'safe') t.sure += r.reclaim || 0
180      else if (r.level === 'check') t.check += r.reclaim || 0
181      else if (r.level === 'danger') t.danger++
182      if (r.command) t.commands++
183    }
184  }
185  return t
186}
187
188/** Bytes as "1,2 Go" (French units). */
189export function fmtBytes(n) {
190  if (n === null || n === undefined || !Number.isFinite(n)) return '—'
191  const u = ['o', 'Ko', 'Mo', 'Go', 'To']
192  let i = 0
193  let v = n
194  while (Math.abs(v) >= 1024 && i < u.length - 1) {
195    v /= 1024
196    i++
197  }
198  return `${(i === 0 ? String(Math.round(v)) : v.toFixed(v >= 100 ? 0 : 1)).replace('.', ',')} ${u[i]}`
199}
200
201/** Age from ms timestamps: "3 h", "2 j", "5 min". */
202export function fmtAge(then, now) {
203  if (!then) return '—'
204  const m = Math.max(0, (now - then) / 60000)
205  if (m < 60) return `${Math.round(m)} min`
206  if (m < 48 * 60) return `${Math.round(m / 60)} h`
207  return `${Math.round(m / 1440)} j`
208}
209
210/** Path shown relative to one of `roots` when inside it. */
211export function short(p, roots = []) {
212  const n = norm(p)
213  for (const r of roots) {
214    const rn = norm(r)
215    if (rn && pathKey(n).startsWith(pathKey(rn) + '/')) return n.slice(rn.length + 1)
216  }
217  return n
218}
219
220/** Rows filtered by the pane's filter: all | safe (récupérable) | danger (ne pas supprimer). */
221export function filterRows(rows, filter) {
222  if (filter === 'safe') return rows.filter(r => r.level === 'safe' || r.level === 'check')
223  if (filter === 'danger') return rows.filter(r => r.level === 'danger')
224  return rows
225}
226
227/** Every command of the rows at the given levels (safe by default), in order. */
228export const commandsOf = (sections, levels = ['safe']) =>
229  (sections || []).flatMap(s => (s.rows || []).filter(r => r.command && levels.includes(r.level)).map(r => r.command))
230
hooks/view.mjs 76 lines
1// menage mod - the pane's drawing, pure: no `$`. `el` is the element table from $.ui.resolve(e) (Box, Text, Button),
2// `M` the facts the hook read, `act` the callbacks it hands in. register.tsx draws the « Ménage » pane with it, and the
3// registres mod draws its « Ménage » tab with a byte-for-byte copy (lib/core/targets.json): change it here only.
4import { commandsOf, fill, filterRows, fmtAge, fmtBytes } from '../lib/classify.mjs'
5
6/** h-like factory over the element table: mk(el)('Text', { dimColor: true }, 'x'). */
7export const mk = (el) => (type, props, ...kids) =>
8  el[type]({ ...(props || {}), children: kids.flat(Infinity).filter((k) => k !== null && k !== undefined && k !== false) })
9
10/**
11 * M = { cfg, model, now, filter, copied, busy, error }; act = { setFilter(id), rescan(), copy(press, lines) }.
12 * Returns the pane's tree.
13 */
14export function drawMenage(el, M, act) {
15  const h = mk(el)
16  const { cfg, model, now, filter, copied } = M
17  const L = cfg.labels ?? {}
18  const C = cfg.colors ?? {}
19  const status = M.busy ? 'inventaire en cours…' : ''
20  if (!model) {
21    return h('Box', { flexDirection: 'column', gap: 1 },
22      h('Text', {}, L.first_scan),
23      M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
24      status ? h('Text', { dimColor: true }, status) : null)
25  }
26  const t = model.totals ?? {}
27  const max = cfg.max_rows ?? 40
28  const color = (level) => (level === 'safe' ? C.safe : level === 'check' ? C.check : level === 'danger' ? C.danger : undefined)
29  const filters = [['all', L.filter_all], ['safe', L.filter_safe], ['danger', L.filter_danger]]
30  const tag = (r) => (r.level === 'danger' ? `${L.danger_tag} · ` : r.level === 'check' ? `${L.check_tag} · ` : '')
31
32  return h('Box', { flexDirection: 'column', gap: 1 },
33    h('Box', { flexDirection: 'column' },
34      h('Text', { dimColor: true }, fill(L.subtitle, { repos: model.repos ?? 0, age: fmtAge(model.generated, now) }) + (status ? ` · ${status}` : '')),
35      M.error ? h('Text', { color: 'error' }, `scan : ${M.error}`) : null,
36      (model.errors ?? []).slice(0, 5).map((x) => h('Text', { color: 'warning' }, x))),
37
38    h('Box', { flexDirection: 'column' },
39      h('Text', { bold: true, color: C.safe }, fill(L.total_sure, { bytes: fmtBytes(t.sure ?? 0) })),
40      h('Text', { color: C.check }, fill(L.total_check, { bytes: fmtBytes(t.check ?? 0) })),
41      t.danger ? h('Text', { color: C.danger }, fill(L.total_danger, { n: t.danger })) : null),
42
43    h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
44      filters.map(([id, label]) => h('Button', { key: `f-${id}`, ...(id !== filter ? { plain: true } : {}), onPress: () => act.setFilter(id) }, label)),
45      h('Button', { key: 'refresh', plain: true, onPress: () => act.rescan() }, L.refresh ?? '↻'),
46      h('Button', { key: 'copy-all', plain: true, onPress: (press) => act.copy(press, commandsOf(model.sections ?? [])) }, L.copy_all)),
47    copied ? h('Text', { dimColor: true }, copied) : null,
48
49    (model.sections ?? []).map((s) => {
50      const rows = filterRows(s.rows ?? [], filter)
51      if (!rows.length && filter !== 'all') return null
52      const sure = (s.rows ?? []).filter((r) => r.level === 'safe').reduce((a, r) => a + (r.reclaim || 0), 0)
53      const safeCmds = commandsOf([s])
54      return h('Box', { key: `s-${s.id}`, flexDirection: 'column' },
55        h('Box', { flexDirection: 'row', gap: 1, flexWrap: 'wrap' },
56          h('Text', { bold: true }, `${s.title} (${(s.rows ?? []).length})${sure ? ` · ${fmtBytes(sure)}` : ''}`),
57          safeCmds.length ? h('Button', { key: `copy-${s.id}`, plain: true, onPress: (press) => act.copy(press, safeCmds) }, `${L.copy} (${safeCmds.length})`) : null),
58        s.summary ? h('Text', { dimColor: true }, s.summary) : null,
59        !rows.length ? h('Text', { dimColor: true }, L.empty) : null,
60        rows.slice(0, max).map((r, i) => h('Box', { key: `r-${s.id}-${i}`, flexDirection: 'column', paddingLeft: 1 },
61          h('Text', { ...(color(r.level) ? { color: color(r.level) } : {}), ...(r.level === 'danger' ? { bold: true } : {}) }, `● ${r.title}${r.meta ? ` · ${r.meta}` : ''}`),
62          r.reasons?.length || r.level === 'danger' || r.level === 'check'
63            ? h('Text', r.level === 'danger' ? { color: C.danger } : { dimColor: true }, `  ${tag(r)}${(r.reasons ?? []).join(' · ')}`)
64            : null,
65          r.command
66            ? h('Box', { flexDirection: 'row', gap: 1 },
67              h('Button', { key: `c-${s.id}-${i}`, plain: true, onPress: (press) => act.copy(press, [r.command]) }, L.copy),
68              h('Text', { dimColor: true, wrap: 'wrap' }, r.command))
69            : null)),
70        rows.length > max ? h('Text', { dimColor: true }, fill(L.more, { n: rows.length - max })) : null)
71    }),
72
73    h('Box', { flexDirection: 'column' },
74      (L.notes ?? []).map((x) => h('Text', { dimColor: true }, `· ${fill(x, { base: 'origin/main' })}`))))
75}
76
types/index.d.ts 16 lines
1// Types of the menage mod: the `$.state` values the pane reads.
2
3/** Row levels: safe (green), check (orange, à vérifier), danger (red, ne pas supprimer), info. */
4export type Level = 'safe' | 'check' | 'danger' | 'info'
5
6declare module 'claude-code' {
7  interface PluginState {
8    menage: {
9      /** rows shown: all | safe (récupérable, sûr ou à vérifier) | danger (ne pas supprimer) */
10      filter: string
11      /** last copy result shown under the buttons */
12      copied: string
13    }
14  }
15}
16