SLOPSHOPPER

image-library

Панель «Библиотека»: галерея изображений проекта, сессий Claude Code и Codex с миниатюрами, фильтром и сортировкой по дате

newpanespinnercommandtoastprocess
★ 1v0.1.0no licenseupdated 2026-10-11nerevar48/nerevar-mods/plugins/image-library
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · image-library
│ ┃ Библиотека ✕ › fix the failing auth test and add an audit log call │ ┃ Все (0) ▾ [ ↓ Сначала новые ] [ Обновить ] 0 │ ┃ ⏺ 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 │ │ › /library │ ⎿ image-library: Библиотека открыта. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts 🖼 Библиотека ⟨Claude Code's own drawing⟩

Draws

Pane · Библиотека
Все (0) ▾ [ ↓ Сначала новые ] [ Обновить ] 0 изобр. Изображений не найдено.
README

nerevar-mods

Marketplace модов Claude Code.

МодЧто делает
turn-statsПолоса над полем ввода: время работы сессии, токены (включая субагентов), квота
pr-mergeПолоса с PR текущей ветки и кнопкой Merge через gh (настройки: mergeMethod, deleteBranch)
session-progressПрогресс-бар задачи сессии по todo-списку и плану модели (настройка: language)
copy-session-linkКнопка «🔗 Ссылка» в футере поля ввода Claude Code Desktop: копирует ссылку claude:// на текущую сессию
visual-answer-improveИтоговые секции ответа в цветных рамках с вкладкой-меткой: «Требуется решение» (оранжевая), «Сделано» (зелёная), «Не сделано» (мягкая красная). Правило про секции прикладывается к промптам пользователя (настройки: decisionColor, doneColor, notDoneColor)
image-libraryПанель «Библиотека» — галерея изображений (png/jpg/webp/gif/svg) из папки проекта (без папок сборки и зависимостей: node_modules, .git, Library, bin, dist, build и т. п.), папок сессий Claude Code этого проекта и Codex (~/.codex/generated_images): миниатюры, фильтр по источнику и сессии, сортировка по дате, постраничная навигация; «🔍» или имя — просмотр на всю панель с листанием, «📋 путь» — полный путь к файлу в буфер обмена, «↗» — открыть в системном приложении. Открывается кнопкой «🖼 Библиотека» в футере поля ввода или командой /library. Только Windows: сканирование и миниатюры через PowerShell и System.Drawing, кэш в %LOCALAPPDATA%\claude-image-library (настройки: includeCodex, scanLimit)

Установка

/plugin marketplace add nerevar48/nerevar-mods
/plugin install turn-stats@nerevar-mods
/plugin install pr-merge@nerevar-mods
/plugin install session-progress@nerevar-mods
/plugin install copy-session-link@nerevar-mods
/plugin install visual-answer-improve@nerevar-mods
/plugin install image-library@nerevar-mods

Или в ~/.claude/settings.json:

"extraKnownMarketplaces": {
  "nerevar-mods": { "source": { "source": "github", "repo": "nerevar48/nerevar-mods" } }
},
"enabledPlugins": {
  "turn-stats@nerevar-mods": true,
  "pr-merge@nerevar-mods": true,
  "session-progress@nerevar-mods": true,
  "copy-session-link@nerevar-mods": true,
  "visual-answer-improve@nerevar-mods": true,
  "image-library@nerevar-mods": true
}
Source 2 files
hooks/register.tsx 650 lines
1import { atom, read, update } from 'claude-code'
2import type { Elements, EngineInterface, Register, RenderElement } from 'claude-code'
3
4import type { Filter, LibraryImage, Preview, Source } from '../types'
5
6const PANE = 'image-library'
7const TITLE = 'Библиотека'
8const COMMAND = 'library'
9const PAGE = 24
10const THUMB_PX = 192
11// Исходник не больше этого (байт) вкладывается в Svg как есть, когда миниатюры нет:
12// его base64 (×4/3) с обёрткой укладывается в 131072 символа source у Svg.
13const EMBED_LIMIT = 90_000
14const SCRIPT_TIMEOUT_MS = 120_000
15const PREVIEW_WIDTH = 1600
16// Байт на полосу просмотра: её base64 с обёрткой укладывается в source одной Svg.
17const STRIP_BYTES = 95_000
18// Миниатюры делает System.Drawing: он читает эти форматы, а webp и svg нет.
19const THUMBABLE = ['png', 'jpg', 'jpeg', 'gif']
20const MIME: Record<string, string> = {
21  png: 'image/png',
22  jpg: 'image/jpeg',
23  jpeg: 'image/jpeg',
24  gif: 'image/gif',
25  webp: 'image/webp',
26  svg: 'image/svg+xml',
27}
28const SOURCE_LABEL: Record<Source, string> = { project: 'проект', session: 'сессия', codex: 'Codex' }
29
30const images = atom({ plugin: 'image-library', key: 'images' } as const, [])
31const status = atom({ plugin: 'image-library', key: 'status' } as const, 'idle')
32const error = atom({ plugin: 'image-library', key: 'error' } as const, '')
33const filter = atom({ plugin: 'image-library', key: 'filter' } as const, 'all')
34const isOldestFirst = atom({ plugin: 'image-library', key: 'isOldestFirst' } as const, false)
35const page = atom({ plugin: 'image-library', key: 'page' } as const, 0)
36const thumbs = atom({ plugin: 'image-library', key: 'thumbs' } as const, {})
37const viewing = atom({ plugin: 'image-library', key: 'viewing' } as const, null)
38const previews = atom({ plugin: 'image-library', key: 'previews' } as const, {})
39
40type Root = { source: Source; session: string | null; path: string; skipBuildDirs: boolean }
41type Settings = { includeCodex: boolean; scanLimit: number }
42
43function extensionOf(path: string): string {
44  return path.slice(path.lastIndexOf('.') + 1).toLowerCase()
45}
46
47function join(...parts: string[]): string {
48  return parts.map((part, i) => (i === 0 ? part.replace(/[\\/]+$/, '') : part.replace(/^[\\/]+|[\\/]+$/g, ''))).join('\\')
49}
50
51// Папка сессий проекта в temp: путь проекта, где всё, кроме букв и цифр, заменено на «-».
52function projectSlug(cwd: string): string {
53  return cwd.replace(/[^A-Za-z0-9]/g, '-')
54}
55
56function parseJson<T>(text: string, fallback: T): T {
57  const body = text.replace(/^/, '').trim()
58  return body ? (JSON.parse(body) as T) : fallback
59}
60
61async function runScript($: EngineInterface, args: string[], input: unknown): Promise<string> {
62  const script = join($.plugin.root, 'scripts', 'library.ps1')
63  const argv = ['powershell.exe', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, ...args]
64  const ran = await $.process.run(argv, { stdin: JSON.stringify(input), timeoutMs: SCRIPT_TIMEOUT_MS })
65
66  if (ran.exitCode !== 0) {
67    throw new Error(ran.stderr.trim().slice(0, 400) || `library.ps1: код ${ran.exitCode}`)
68  }
69
70  return ran.stdout
71}
72
73async function rootsOf($: EngineInterface, settings: Settings): Promise<Root[]> {
74  const cwd = await $.session.cwd()
75  const roots: Root[] = [{ source: 'project', session: null, path: cwd, skipBuildDirs: true }]
76  const temp = (await $.env.get('TEMP')) ?? (await $.env.get('TMP'))
77
78  if (temp) {
79    const sessionsDir = join(temp, 'claude', projectSlug(cwd))
80    const entries = await $.fs.list(sessionsDir).catch(() => [])
81
82    for (const entry of entries) {
83      if (entry.kind === 'dir') {
84        roots.push({ source: 'session', session: entry.name, path: join(sessionsDir, entry.name), skipBuildDirs: false })
85      }
86    }
87  }
88
89  const home = await $.env.get('USERPROFILE')
90
91  if (settings.includeCodex && home) {
92    roots.push({ source: 'codex', session: null, path: join(home, '.codex', 'generated_images'), skipBuildDirs: false })
93  }
94
95  return roots
96}
97
98function matches(image: LibraryImage, current: Filter): boolean {
99  if (current === 'all') return true
100  if (current === 'sessions') return image.source === 'session'
101  if (current.startsWith('session:')) return image.session === current.slice('session:'.length)
102  return image.source === current
103}
104
105// Ключ миниатюры с mtime: перезаписанный файл получает новую.
106function thumbKey(image: LibraryImage): string {
107  return `${image.path}|${image.mtimeMs}`
108}
109
110function pageCount(total: number): number {
111  return Math.max(1, Math.ceil(total / PAGE))
112}
113
114// Изображения страницы; номер за последней страницей (список сократился) — последняя.
115function pageOf(list: readonly LibraryImage[], index: number): { images: LibraryImage[]; index: number } {
116  const clamped = Math.min(Math.max(0, index), pageCount(list.length) - 1)
117  return { images: list.slice(clamped * PAGE, clamped * PAGE + PAGE), index: clamped }
118}
119
120// Номера страниц в переключателе: первая, последняя и соседи текущей; null — пропуск «…».
121// Пропуск ровно одной страницы показывает её саму.
122function pageNumbers(current: number, total: number): (number | null)[] {
123  const isNear = (index: number) => index === 0 || index === total - 1 || Math.abs(index - current) <= 2
124  const numbers: (number | null)[] = []
125  for (let index = 0; index < total; index++) {
126    if (isNear(index) || (isNear(index - 1) && isNear(index + 1))) {
127      numbers.push(index)
128    } else if (numbers.at(-1) !== null) {
129      numbers.push(null)
130    }
131  }
132  return numbers
133}
134
135function sortedFiltered(list: readonly LibraryImage[], current: Filter, oldestFirst: boolean): LibraryImage[] {
136  const chosen = list.filter(image => matches(image, current))
137  return oldestFirst ? chosen.reverse() : chosen
138}
139
140// Миниатюры видимой страницы: одна команда на все недостающие. Запрос во время
141// работы ставит флаг, и цикл проходит ещё раз; проверка флага и сброс идут без await между ними.
142let thumbsRun: Promise<void> | undefined
143let isThumbsAgain = false
144
145function ensureThumbs($: EngineInterface, cacheDir: string): Promise<void> {
146  isThumbsAgain = true
147  if (thumbsRun) return thumbsRun
148
149  thumbsRun = (async () => {
150    try {
151      while (isThumbsAgain) {
152        isThumbsAgain = false
153        const known = await read($, thumbs)
154        const shown = sortedFiltered(await read($, images), await read($, filter), await read($, isOldestFirst))
155        // Миниатюра, файл которой исчез из кэша на диске, делается заново.
156        const visible = pageOf(shown, await read($, page)).images.filter(image => THUMBABLE.includes(extensionOf(image.path)))
157        const isMade = await Promise.all(
158          visible.map(async image => {
159            const thumb = known[thumbKey(image)]
160            return thumb === undefined ? false : thumb === null || (await $.fs.exists(thumb).catch(() => false))
161          }),
162        )
163        const missing = visible.filter((_, index) => !isMade[index])
164
165        if (missing.length > 0) {
166          const args = ['-Mode', 'thumbs', '-CacheDir', cacheDir, '-Size', String(THUMB_PX)]
167          const made = parseJson<Record<string, string | null>>(await runScript($, args, missing.map(image => image.path)), {})
168          const byKey = Object.fromEntries(missing.map(image => [thumbKey(image), made[image.path] ?? null]))
169          await update($, thumbs, current => ({ ...current, ...byKey }))
170        }
171      }
172    } finally {
173      thumbsRun = undefined
174    }
175  })()
176
177  return thumbsRun
178}
179
180// Номер последнего запущенного скана: результат более старого отбрасывается.
181let scanGeneration = 0
182
183async function scan($: EngineInterface, settings: Settings, cacheDir: string): Promise<void> {
184  const generation = ++scanGeneration
185  await update($, status, () => 'scanning')
186
187  try {
188    const roots = await rootsOf($, settings)
189    const found = parseJson<LibraryImage[]>(
190      await runScript($, ['-Mode', 'scan', '-Limit', String(settings.scanLimit)], roots),
191      [],
192    )
193    if (generation !== scanGeneration) return
194    const present = new Set(found.map(thumbKey))
195    await update($, images, () => found)
196    // Открытый в просмотре файл исчез: вернуться к галерее.
197    const paths = new Set(found.map(image => image.path))
198    await update($, viewing, current => (current !== null && paths.has(current) ? current : null))
199    // Список мог сократиться: номер страницы остаётся в его пределах.
200    await update($, page, current => Math.min(current, pageCount(found.length) - 1))
201    const isPresent = ([key]: [string, unknown]) => present.has(key)
202    await update($, thumbs, current => Object.fromEntries(Object.entries(current).filter(isPresent)))
203    await update($, previews, current => Object.fromEntries(Object.entries(current).filter(isPresent)))
204    await update($, error, () => '')
205    await update($, status, () => 'idle')
206  } catch (failure) {
207    if (generation !== scanGeneration) return
208    await update($, error, () => (failure instanceof Error ? failure.message : String(failure)))
209    await update($, status, () => 'error')
210    return
211  }
212
213  await ensureThumbs($, cacheDir).catch(failure => $.ui.toast(`Миниатюры: ${String(failure)}`))
214}
215
216async function openFile($: EngineInterface, path: string): Promise<void> {
217  if (!(await $.fs.exists(path))) {
218    $.ui.toast(`Файла уже нет: ${path}`)
219    return
220  }
221  // explorer.exe открывает файл приложением по умолчанию; код выхода у него всегда 1.
222  await $.process.run(['explorer.exe', path], { timeoutMs: 10_000 }).catch(() => $.ui.toast(`Не удалось открыть ${path}`))
223}
224
225// Путь кладёт в буфер Windows скрипт: $.ui.copy на desktop в системный буфер не пишет.
226async function copyPath($: EngineInterface, path: string): Promise<void> {
227  const isCopied = await runScript($, ['-Mode', 'copy'], path).then(() => true, () => false)
228  $.ui.toast(isCopied ? `Путь скопирован: ${path}` : `Не удалось скопировать: ${path}`)
229}
230
231// Подготовки просмотра, идущие сейчас: повторный запрос той же картинки ждёт ту же.
232const previewRuns = new Map<string, Promise<void>>()
233
234async function hasStrips($: EngineInterface, preview: Preview): Promise<boolean> {
235  const present = await Promise.all(preview.strips.map(strip => $.fs.exists(strip.path).catch(() => false)))
236  return present.every(Boolean)
237}
238
239function ensurePreview($: EngineInterface, cacheDir: string, image: LibraryImage): Promise<void> {
240  const key = thumbKey(image)
241  const running = previewRuns.get(key)
242  if (running) return running
243
244  const run = (async () => {
245    if (!THUMBABLE.includes(extensionOf(image.path))) return
246    const known = (await read($, previews))[key]
247    // null — формат не читается; готовый просмотр без полос на диске и сбой делаются заново.
248    if (known === null || (known && 'strips' in known && (await hasStrips($, known)))) return
249
250    const args = ['-Mode', 'preview', '-CacheDir', cacheDir, '-Size', String(PREVIEW_WIDTH), '-StripBytes', String(STRIP_BYTES)]
251    try {
252      const made = parseJson<Preview | null>(await runScript($, args, image.path), null)
253      await update($, previews, current => ({ ...current, [key]: made }))
254    } catch (failure) {
255      // Сбой не помечает картинку непросматриваемой: просмотр показывает его с кнопкой «Повторить».
256      const message = failure instanceof Error ? failure.message : String(failure)
257      await update($, previews, current => ({ ...current, [key]: { error: message } }))
258    }
259  })().finally(() => previewRuns.delete(key))
260
261  previewRuns.set(key, run)
262  return run
263}
264
265async function openViewer($: EngineInterface, cacheDir: string, image: LibraryImage): Promise<void> {
266  await update($, viewing, () => image.path)
267  await $.ui.scroll({ to: 'start', in: PANE }).catch(() => undefined)
268  await ensurePreview($, cacheDir, image)
269}
270
271// Шаг по списку от картинки, открытой сейчас (а не на момент рисования).
272async function stepViewer($: EngineInterface, cacheDir: string, list: readonly LibraryImage[], delta: number): Promise<void> {
273  let target: LibraryImage | undefined
274  await update($, viewing, current => {
275    const index = list.findIndex(image => image.path === current)
276    target = list[Math.min(Math.max(0, index + delta), list.length - 1)]
277    return target?.path ?? current
278  })
279  if (target) await ensurePreview($, cacheDir, target)
280}
281
282async function closeViewer($: EngineInterface): Promise<void> {
283  await update($, viewing, () => null)
284}
285
286function sizeText(bytes: number): string {
287  if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} КБ`
288  return `${(bytes / 1024 / 1024).toFixed(1)} МБ`
289}
290
291function pad(value: number): string {
292  return String(value).padStart(2, '0')
293}
294
295function dateText(mtimeMs: number): string {
296  const date = new Date(mtimeMs)
297  return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()} ${pad(date.getHours())}:${pad(date.getMinutes())}`
298}
299
300function shortName(name: string): string {
301  return name.length > 24 ? `${name.slice(0, 11)}…${name.slice(-12)}` : name
302}
303
304function sourceText(image: LibraryImage, currentSession: string): string {
305  if (image.source !== 'session') return SOURCE_LABEL[image.source]
306  return image.session === currentSession ? 'эта сессия' : `сессия ${image.session?.slice(0, 8) ?? ''}`
307}
308
309function svgOf(mime: string, base64: string, width: number, height: number): string {
310  const href = `data:${mime};base64,${base64}`
311  return (
312    `<svg xmlns="http://www.w3.org/2000/svg" ` +
313    `width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">` +
314    `<image href="${href}" width="${width}" height="${height}" preserveAspectRatio="xMidYMid meet"/></svg>`
315  )
316}
317
318// base64 прочитанных файлов (миниатюра ~10 КБ); самые давние вытесняются первыми.
319const bytesCache = new Map<string, string>()
320const BYTES_CACHE_LIMIT = 600
321
322async function base64Of($: EngineInterface, path: string, cacheKey = path): Promise<string | null> {
323  const cached = bytesCache.get(cacheKey)
324  if (cached !== undefined) return cached
325
326  const bytes = await $.fs.read(path, { as: 'bytes' }).then(
327    read => read.base64,
328    () => null,
329  )
330  if (bytes === null) return null
331  const oldest = bytesCache.keys().next()
332  if (bytesCache.size >= BYTES_CACHE_LIMIT && !oldest.done) bytesCache.delete(oldest.value)
333  bytesCache.set(cacheKey, bytes)
334  return bytes
335}
336
337// Что показать в плитке: миниатюра, либо сам файл, если он мал, либо ничего.
338async function pictureOf(
339  $: EngineInterface,
340  image: LibraryImage,
341  thumbPath: string | null | undefined,
342): Promise<{ mime: string; base64: string } | null> {
343  if (thumbPath) {
344    const base64 = await base64Of($, thumbPath)
345    if (base64) return { mime: 'image/jpeg', base64 }
346  }
347
348  const mime = MIME[extensionOf(image.path)]
349  if (!mime || image.size > EMBED_LIMIT) return null
350  const base64 = await base64Of($, image.path, thumbKey(image))
351  return base64 ? { mime, base64 } : null
352}
353
354// Тело просмотра: полосы, мелкий исходник целиком или подсказка.
355async function viewerPicture(
356  $: EngineInterface,
357  { Box, Button, Svg, Text }: Pick<Elements['desktop'], 'Box' | 'Button' | 'Svg' | 'Text'>,
358  image: LibraryImage,
359  preview: Preview | null | { error: string } | undefined,
360  retry: () => Promise<void>,
361): Promise<RenderElement> {
362  if (preview && 'error' in preview) {
363    return (
364      <Box key="viewer-error" flexDirection="row" alignItems="center" gap={1}>
365        <Text color="red">Не удалось подготовить просмотр: {preview.error}</Text>
366        <Button key="viewer:retry" label="Повторить" onPress={retry} />
367      </Box>
368    )
369  }
370
371  if (preview) {
372    // Полосы по ~100 КБ не кэшируются: просмотр рисуется редко, а память нужнее миниатюрам.
373    const strips = await Promise.all(
374      preview.strips.map(strip => $.fs.read(strip.path, { as: 'bytes' }).then(read => read.base64, () => null)),
375    )
376    if (strips.every(strip => strip !== null)) {
377      return (
378        <Box key="viewer-image" flexDirection="column">
379          {preview.strips.map((strip, index) => (
380            <Svg
381              source={svgOf('image/jpeg', strips[index] ?? '', preview.width, strip.height)}
382              alt={`${image.name} (${index + 1}/${strips.length})`}
383            />
384          ))}
385        </Box>
386      )
387    }
388  }
389
390  if (preview === undefined && THUMBABLE.includes(extensionOf(image.path))) {
391    return <Text dimColor>Готовлю просмотр…</Text>
392  }
393
394  const picture = await pictureOf($, image, null)
395  if (picture) return <Svg source={svgOf(picture.mime, picture.base64, 800, 600)} alt={image.name} />
396  return <Text dimColor>Предпросмотр этого файла недоступен — «↗ Открыть в приложении».</Text>
397}
398
399function filterOptions(list: readonly LibraryImage[], currentSession: string) {
400  const counts = new Map<string, number>()
401  for (const image of list) {
402    if (image.session) counts.set(image.session, (counts.get(image.session) ?? 0) + 1)
403  }
404  const count = (source: Source) => list.filter(image => image.source === source).length
405  const options = [
406    { value: 'all', label: `Все (${list.length})` },
407    { value: 'project', label: `Проект (${count('project')})` },
408    { value: 'sessions', label: `Все сессии (${count('session')})` },
409  ]
410  if (count('codex') > 0) options.push({ value: 'codex', label: `Codex (${count('codex')})` })
411  for (const [session, n] of counts) {
412    const name = session === currentSession ? 'Эта сессия' : `Сессия ${session.slice(0, 8)}`
413    options.push({ value: `session:${session}`, label: `${name} (${n})` })
414  }
415  return options
416}
417
418async function openLibrary($: EngineInterface, settings: Settings, cacheDir: string): Promise<void> {
419  await $.ui.open({ id: PANE, title: TITLE })
420  await scan($, settings, cacheDir)
421}
422
423async function isShown($: EngineInterface): Promise<boolean> {
424  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isShown && pane.isPlaced)
425}
426
427// Смена фильтра, сортировки или страницы, затем миниатюры для новой видимой страницы.
428async function changeView($: EngineInterface, cacheDir: string, apply: () => Promise<unknown>): Promise<void> {
429  await apply()
430  await ensureThumbs($, cacheDir).catch(failure => $.ui.toast(`Миниатюры: ${String(failure)}`))
431}
432
433async function setFilter($: EngineInterface, value: Filter): Promise<void> {
434  await update($, filter, () => value)
435  await update($, page, () => 0)
436}
437
438async function toggleSort($: EngineInterface): Promise<void> {
439  await update($, isOldestFirst, value => !value)
440  await update($, page, () => 0)
441}
442
443// Переход на страницу (move — от текущей, чтобы два быстрых нажатия «›» дали два шага)
444// и прокрутка к её началу, когда она уже нарисована.
445async function goToPage($: EngineInterface, cacheDir: string, total: number, move: (current: number) => number): Promise<void> {
446  await changeView($, cacheDir, () => update($, page, current => Math.min(Math.max(0, move(current)), pageCount(total) - 1)))
447  await $.ui.scroll({ to: 'start', in: PANE }).catch(() => undefined)
448}
449
450export const register: Register = (on, options) => {
451  const settings: Settings = {
452    includeCodex: options.includeCodex !== false,
453    scanLimit: typeof options.scanLimit === 'number' && options.scanLimit > 0 ? options.scanLimit : 3000,
454  }
455  let currentSession = ''
456  let cacheDir = ''
457
458  on('session.start', async ($, e, next) => {
459    currentSession = await $.session.id()
460    const local = (await $.env.get('LOCALAPPDATA')) ?? (await $.env.get('TEMP')) ?? '.'
461    cacheDir = join(local, 'claude-image-library', 'thumbs')
462    await $.command.register({ name: COMMAND, description: 'Библиотека изображений проекта и сессий' })
463    return next(e)
464  })
465
466  on('command.run', { command: COMMAND }, async $ => {
467    await openLibrary($, settings, cacheDir)
468    return { text: 'Библиотека открыта.' }
469  })
470
471  // Новые картинки за ход: пересканировать, пока панель на экране.
472  on('turn.complete', async ($, e, next) => {
473    const result = await next(e)
474    if (await isShown($)) void scan($, settings, cacheDir)
475    return result
476  })
477
478  // Кнопка в футере поля ввода; то, что рисуют хуки ниже (режимы, другие моды), идёт следом.
479  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
480    const below = await next(e)
481    const { Box, Button } = $.ui.resolve(e)
482
483    return (
484      <Box flexDirection="row" alignItems="center" gap={1}>
485        <Button key="image-library-open" plain label="🖼 Библиотека" onPress={() => openLibrary($, settings, cacheDir)} />
486        {below}
487      </Box>
488    )
489  })
490
491  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
492    const ui = $.ui.resolve(e)
493    const { Box, Text, Button } = ui
494    const all = await read($, images)
495    const current = await read($, filter)
496    const oldestFirst = await read($, isOldestFirst)
497    const storedPage = await read($, page)
498    const known = await read($, thumbs)
499    const scanStatus = await read($, status)
500    const scanError = await read($, error)
501    const chosen = sortedFiltered(all, current, oldestFirst)
502    const { images: shown, index: pageIndex } = pageOf(chosen, storedPage)
503    const pages = pageCount(chosen.length)
504    const openPage = (move: (current: number) => number) => goToPage($, cacheDir, chosen.length, move)
505
506    // Просмотр одной картинки на всю панель; листается по текущему фильтру и сортировке.
507    const viewingPath = await read($, viewing)
508    const viewed = viewingPath === null ? undefined : all.find(image => image.path === viewingPath)
509    if (viewed && 'Svg' in ui) {
510      const inChosen = chosen.findIndex(image => image.path === viewed.path)
511      const list = inChosen >= 0 ? chosen : [viewed]
512      const position = Math.max(0, inChosen)
513      const preview = (await read($, previews))[thumbKey(viewed)]
514      const picture = await viewerPicture($, ui, viewed, preview, () => ensurePreview($, cacheDir, viewed))
515
516      return (
517        <Box flexDirection="column" gap={1}>
518          <Box key="viewer-bar" flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
519            <Button key="viewer:back" label="← Галерея" onPress={() => closeViewer($)} />
520            <Button key="viewer:prev" label="‹" onPress={() => stepViewer($, cacheDir, list, -1)} />
521            <Text>
522              {position + 1} / {list.length}
523            </Text>
524            <Button key="viewer:next" label="›" onPress={() => stepViewer($, cacheDir, list, 1)} />
525            <Button key="viewer:copy" label="📋 Копировать путь" onPress={() => copyPath($, viewed.path)} />
526            <Button key="viewer:app" label="↗ Открыть в приложении" onPress={() => openFile($, viewed.path)} />
527          </Box>
528          <Text dimColor>
529            {viewed.path} · {dateText(viewed.mtimeMs)} · {sizeText(viewed.size)}
530          </Text>
531          {picture}
532        </Box>
533      )
534    }
535
536    // Переключатель страниц: ‹ 1 … 4 5 [6] 7 8 … 40 ›; рисуется над сеткой и под ней.
537    const pager = (place: string) =>
538      pages > 1 && (
539        <Box key={`pager:${place}`} flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
540          <Button key={`${place}:prev`} label="‹" onPress={() => openPage(index => index - 1)} />
541          {pageNumbers(pageIndex, pages).map((number, slot) =>
542            number === null ? (
543              <Text key={`${place}:gap:${slot}`} dimColor>
544                …
545              </Text>
546            ) : (
547              <Button
548                key={`${place}:page:${number + 1}`}
549                label={String(number + 1)}
550                variant={number === pageIndex ? 'primary' : 'secondary'}
551                onPress={() => openPage(() => number)}
552              />
553            ),
554          )}
555          <Button key={`${place}:next`} label="›" onPress={() => openPage(index => index + 1)} />
556        </Box>
557      )
558
559    const header = (
560      <Box key="header" flexDirection="row" flexWrap="wrap" alignItems="center" gap={1}>
561        {'Select' in ui && (
562          <ui.Select
563            key="filter"
564            options={filterOptions(all, currentSession)}
565            value={current}
566            onSelect={(value: string) => changeView($, cacheDir, () => setFilter($, value))}
567          />
568        )}
569        <Button
570          key="sort"
571          label={oldestFirst ? '↑ Сначала старые' : '↓ Сначала новые'}
572          onPress={() => changeView($, cacheDir, () => toggleSort($))}
573        />
574        <Button key="refresh" label="Обновить" onPress={() => scan($, settings, cacheDir)} />
575        <Text dimColor>
576          {scanStatus === 'scanning'
577            ? 'Сканирую…'
578            : `${chosen.length} изобр.${pages > 1 ? ` · стр. ${pageIndex + 1} из ${pages}` : ''}`}
579        </Text>
580      </Box>
581    )
582
583    const pictures = 'Svg' in ui ? await Promise.all(shown.map(image => pictureOf($, image, known[thumbKey(image)]))) : []
584    const tiles: RenderElement[] = []
585
586    for (const [index, image] of shown.entries()) {
587      const caption = `${dateText(image.mtimeMs)} · ${sourceText(image, currentSession)}`
588      const open = () => openFile($, image.path)
589
590      // Svg есть на desktop, mobile и vscode; терминал получает список строк.
591      if (!('Svg' in ui)) {
592        tiles.push(
593          <Button key={`open:${image.path}`} plain onPress={open}>
594            {shortName(image.name)} <Text dimColor>{caption}</Text>
595          </Button>,
596        )
597        continue
598      }
599
600      const { Svg } = ui
601      const picture = pictures[index]
602      const view = () => openViewer($, cacheDir, image)
603      // Нажатие принимает только Button, не сама Svg: «🔍» лежит поверх угла картинки.
604      tiles.push(
605        <Box key={`tile:${image.path}`} flexDirection="column" width={26}>
606          <Box key={`frame:${image.path}`} position="relative">
607            {picture ? (
608              <Svg source={svgOf(picture.mime, picture.base64, 176, 132)} alt={image.name} width={176} height={132} />
609            ) : (
610              <Box height={6} width={24} borderStyle="round" justifyContent="center" alignItems="center">
611                <Text dimColor>{extensionOf(image.path).toUpperCase()}</Text>
612              </Box>
613            )}
614            <Box position="absolute" top={0} right={0}>
615              <Button key={`view:${image.path}`} plain label="🔍" onPress={view} />
616            </Box>
617          </Box>
618          <Button key={`open:${image.path}`} plain onPress={view}>
619            {shortName(image.name)}
620          </Button>
621          <Text dimColor>{caption}</Text>
622          <Box flexDirection="row" gap={1}>
623            <Button
624              key={`copy:${image.path}`}
625              plain
626              dimColor
627              label="📋 путь"
628              onPress={() => copyPath($, image.path)}
629            />
630            <Button key={`app:${image.path}`} plain dimColor label="↗ открыть" onPress={open} />
631          </Box>
632        </Box>,
633      )
634    }
635
636    return (
637      <Box flexDirection="column" gap={1}>
638        {header}
639        {pager('top')}
640        {scanStatus === 'error' && <Text color="red">Ошибка сканирования: {scanError}</Text>}
641        {chosen.length === 0 && scanStatus !== 'scanning' && <Text dimColor>Изображений не найдено.</Text>}
642        <Box key="grid" flexDirection={e.surface === 'terminal' ? 'column' : 'row'} flexWrap="wrap" gap={1}>
643          {tiles}
644        </Box>
645        {pager('bottom')}
646      </Box>
647    )
648  })
649}
650
types/index.d.ts 41 lines
1// Где найдено изображение: рабочая папка проекта, папка сессии Claude Code или вывод Codex.
2export type Source = 'project' | 'session' | 'codex'
3
4export type LibraryImage = {
5  path: string
6  name: string
7  source: Source
8  // Id сессии Claude Code для source === 'session'.
9  session: string | null
10  size: number
11  mtimeMs: number
12}
13
14// all | project | codex | sessions (все сессии) | session:<id>
15export type Filter = string
16
17// Картинка для просмотра, уменьшенная до ширины width и нарезанная на JPEG-полосы.
18export type Preview = { width: number; height: number; strips: { path: string; height: number }[] }
19
20export type ScanStatus = 'idle' | 'scanning' | 'error'
21
22declare module 'claude-code' {
23  interface PluginState {
24    'image-library': {
25      images: LibraryImage[]
26      status: ScanStatus
27      error: string
28      filter: Filter
29      isOldestFirst: boolean
30      // Номер страницы галереи, с нуля.
31      page: number
32      // «путь|mtime» изображения → путь его JPEG-миниатюры; null — миниатюру не сделать.
33      thumbs: Record<string, string | null>
34      // Путь картинки, открытой в просмотре; null — показана галерея.
35      viewing: string | null
36      // «путь|mtime» → полосы просмотра; null — формат не читается; { error } — сбой подготовки.
37      previews: Record<string, Preview | null | { error: string }>
38    }
39  }
40}
41