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…

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.
/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).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.
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.node --test mods/menage/tests/*.test.mjs
claude plugin validate mods/menage
claude plugin test mods/menagehooks/register.tsx 110 lines1// 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}
110lib/classify.mjs 230 lines1// 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))
230hooks/view.mjs 76 lines1// 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}
76types/index.d.ts 16 lines1// 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