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

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
}hooks/register.tsx 650 lines1import { 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}
650types/index.d.ts 41 lines1// Где найдено изображение: рабочая папка проекта, папка сессии 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