reelkit-cli 0.10.0 → 0.10.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,533 @@
1
+ import { atom, read, update } from 'claude-code'
2
+ import type { Elements, EngineInterface, Register } from 'claude-code'
3
+
4
+ import type { Film, LibraryItem, LibrarySearch, StudioTab } from '../types'
5
+ import { playerHtml } from './player'
6
+ import { baseName, findProjects, join, loadFilm, projectOf } from './project'
7
+ import type { Host } from './project'
8
+ import { clock, timelineLanes, timelineSvg } from './timeline'
9
+ import type { Cell } from './timeline'
10
+
11
+ const PANE = 'reelkit-studio'
12
+ const TITLE = 'Reelkit'
13
+ const COMMAND = 'reelkit-studio'
14
+
15
+ const TABS: readonly { id: StudioTab; label: string; hotkey: string }[] = [
16
+ { id: 'timeline', label: 'Timeline', hotkey: '1' },
17
+ { id: 'plan', label: 'Plan', hotkey: '2' },
18
+ { id: 'music', label: 'Music', hotkey: '3' },
19
+ { id: 'components', label: 'Components', hotkey: '4' },
20
+ { id: 'browser', label: 'Browser', hotkey: '5' },
21
+ ]
22
+ const KINDS = ['music', 'sfx', 'component', 'image', 'clip'] as const
23
+ const NO_SEARCH: LibrarySearch = { kind: 'music', query: '', status: 'idle', note: '', items: [] }
24
+
25
+ const tabAtom = atom({ plugin: 'reelkit', key: 'tab' } as const, 'timeline')
26
+ const filmAtom = atom({ plugin: 'reelkit', key: 'film' } as const, null)
27
+ const projectsAtom = atom({ plugin: 'reelkit', key: 'projects' } as const, [])
28
+ const sceneAtom = atom({ plugin: 'reelkit', key: 'scene' } as const, 0)
29
+ const componentAtom = atom({ plugin: 'reelkit', key: 'component' } as const, '')
30
+ const searchAtom = atom({ plugin: 'reelkit', key: 'search' } as const, NO_SEARCH)
31
+
32
+ type Kit = Pick<Elements['mobile'], 'Box' | 'Text' | 'Button' | 'Markdown' | 'Code'> &
33
+ Partial<Pick<Elements['desktop'], 'Svg' | 'Input' | 'Select'>>
34
+
35
+ // The scene pictures the timeline embeds, by file and by the load that made them, so a redraw reads each once.
36
+ const pictures = new Map<string, string>()
37
+
38
+ // The engine as the project loader takes it: plain functions over the file system, the clock and host commands.
39
+ const hostOf = ($: EngineInterface): Host => ({
40
+ read: path => $.fs.read(path),
41
+ list: path => $.fs.list(path),
42
+ exists: path => $.fs.exists(path),
43
+ stat: path => $.fs.stat(path),
44
+ run: (argv, timeoutMs) => $.process.run(argv, { timeoutMs }),
45
+ cwd: () => $.session.cwd(),
46
+ now: () => $.clock.now(),
47
+ })
48
+
49
+ // Reads the projects in reach and the chosen one's film into the state the pane draws from. `dir` switches project.
50
+ async function refresh($: EngineInterface, dir?: string): Promise<Film | null> {
51
+ const projects = await findProjects(hostOf($)).catch(() => [] as string[])
52
+ const current = await read($, filmAtom)
53
+ const wanted = dir ?? current?.dir ?? projects[0]
54
+ const film = wanted ? await loadFilm(hostOf($), wanted).catch(() => null) : null
55
+ const listed = film && !projects.includes(film.dir) ? [film.dir, ...projects] : projects
56
+ await update($, projectsAtom, () => listed)
57
+ await update($, filmAtom, () => film)
58
+ if (film?.dir !== current?.dir) await update($, sceneAtom, () => 0)
59
+ return film
60
+ }
61
+
62
+ // Opens a file or page with the machine's own opener.
63
+ async function reveal($: EngineInterface, path: string): Promise<boolean> {
64
+ for (const opener of ['open', 'xdg-open']) {
65
+ const ran = await $.process.run([opener, path]).catch(() => undefined)
66
+ if (ran?.exitCode === 0) return true
67
+ }
68
+ $.ui.toast(`Could not open ${path}`)
69
+ return false
70
+ }
71
+
72
+ // Writes the player page beside the render and opens it in the browser.
73
+ async function openPlayer($: EngineInterface, film: Film): Promise<void> {
74
+ const page = join(film.dir, 'out/studio.html')
75
+ await $.process.run(['mkdir', '-p', join(film.dir, 'out')]).catch(() => undefined)
76
+ await $.fs.write(page, playerHtml(film))
77
+ if (await reveal($, page)) $.ui.toast(film.render ? 'Opened the film with its timeline' : 'Opened the preview frames with the timeline')
78
+ }
79
+
80
+ // Hands Claude a request: the words land in the prompt for the person to finish or send.
81
+ async function ask($: EngineInterface, text: string): Promise<void> {
82
+ await $.prompt.fill({ text })
83
+ $.ui.toast('Written into the prompt')
84
+ }
85
+
86
+ async function searchLibrary($: EngineInterface, film: Film | null, kind: string, query: string): Promise<void> {
87
+ const asked = query.trim()
88
+ if (!asked) return
89
+ await update($, searchAtom, () => ({ kind, query: asked, status: 'busy' as const, note: '', items: [] }))
90
+ const ran = await $.process
91
+ .run(['reelkit', 'assets', 'search', asked, '--kind', kind, '--json'], { cwd: film?.dir, timeoutMs: 45000 })
92
+ .catch(() => undefined)
93
+ let items: LibraryItem[] = []
94
+ let note = ''
95
+ try {
96
+ const answer = JSON.parse(ran?.stdout ?? '') as { ok?: boolean; summary?: string; data?: { items?: Record<string, any>[] } }
97
+ if (answer.ok === false) note = String(answer.summary ?? 'The search failed.').split('\n')[0] ?? ''
98
+ items = (answer.data?.items ?? []).slice(0, 12).map(one => ({
99
+ id: String(one.id),
100
+ kind: String(one.kind ?? kind),
101
+ title: String(one.title ?? one.id),
102
+ about: String(one.description ?? '').slice(0, 220),
103
+ match: Math.round((Number(one.match) || 0) * 100),
104
+ detail: [
105
+ one.meta?.durationSec ? `${Math.round(Number(one.meta.durationSec))}s` : '',
106
+ one.meta?.bpm ? `${Math.round(Number(one.meta.bpm))} BPM` : '',
107
+ one.meta?.category ? String(one.meta.category) : '',
108
+ ]
109
+ .filter(Boolean)
110
+ .join(' · '),
111
+ }))
112
+ } catch {
113
+ note = (ran?.stderr || ran?.stdout || 'The reelkit command did not answer. Is it installed and on the PATH?').trim().split('\n')[0] ?? ''
114
+ }
115
+ await update($, searchAtom, () => ({ kind, query: asked, status: note ? ('failed' as const) : ('done' as const), note: note.slice(0, 300), items }))
116
+ }
117
+
118
+ const useRequest = (item: LibraryItem): string => {
119
+ if (item.kind === 'music') return `Pull ${item.id} from the Reelkit library and use it as the music for this video. `
120
+ if (item.kind === 'component') return `Pull the component ${item.id} from the Reelkit library and use it in this video: `
121
+ return `Pull ${item.id} (${item.kind}) from the Reelkit library and use it in this video: `
122
+ }
123
+
124
+ const ago = (now: number, then: number): string => {
125
+ const minutes = Math.max(0, Math.round((now - then) / 60000))
126
+ if (minutes < 1) return 'just now'
127
+ if (minutes < 60) return `${minutes} min ago`
128
+ if (minutes < 60 * 24) return `${Math.round(minutes / 60)} h ago`
129
+ return `${Math.round(minutes / 1440)} d ago`
130
+ }
131
+
132
+ const megabytes = (bytes: number): string => `${(bytes / 1048576).toFixed(1)} MB`
133
+
134
+ const TONES: Record<Cell['tone'], Record<string, string | boolean>> = {
135
+ clip: { backgroundColor: 'blue', color: 'white' },
136
+ clipAlt: { backgroundColor: 'magenta', color: 'white' },
137
+ picked: { inverse: true, bold: true },
138
+ text: { color: 'magenta' },
139
+ voice: { color: 'cyan' },
140
+ music: { color: 'blue' },
141
+ empty: { dimColor: true },
142
+ }
143
+
144
+ function planMarkdown(film: Film): string {
145
+ const settings = [
146
+ film.aspect,
147
+ `${clock(film.totalFrames / film.fps)}${film.isTimed ? '' : ' (estimated)'}`,
148
+ film.voice ? `voice ${film.voice}` : '',
149
+ film.pace ? `pace ${film.pace}` : '',
150
+ film.captions ? `captions ${film.captions}` : '',
151
+ ].filter(Boolean)
152
+ const scenes = film.scenes.map((scene, i) => {
153
+ const lines = [`**${i + 1}. ${scene.id}** · ${(scene.frames / film.fps).toFixed(1)}s${scene.treatment ? ` · ${scene.treatment}` : ''}`]
154
+ if (scene.narration) lines.push(`> ${scene.narration}`)
155
+ if (scene.text.length) lines.push(`On screen: ${scene.text.map(one => `\`${one.replace(/`/g, "'")}\``).join(' ')}`)
156
+ return lines.join('\n\n')
157
+ })
158
+ return [`## ${film.title}`, settings.join(' · '), ...scenes].join('\n\n').slice(0, 9800)
159
+ }
160
+
161
+ export const register: Register = on => {
162
+ on('session.start', async ($, e, next) => {
163
+ await $.command.register({ name: COMMAND, description: 'Open the Reelkit studio pane: timeline, plan, music, components and the library' })
164
+ const film = await refresh($).catch(() => null)
165
+ if (film) void $.ui.open({ id: PANE, title: TITLE })
166
+
167
+ return next(e)
168
+ })
169
+
170
+ on('command.run', { command: COMMAND }, async ($, e) => {
171
+ const cwd = await $.session.cwd()
172
+ const named = e.args.trim()
173
+ const dir = named ? await projectOf(hostOf($), named.startsWith('/') ? named : join(cwd, named)) : undefined
174
+ const film = await refresh($, dir)
175
+ await $.ui.open({ id: PANE, title: TITLE })
176
+ if (named && !dir) return { text: `No Reelkit project at ${named}. The studio pane shows ${film ? film.name : 'no project'}.` }
177
+
178
+ return { text: film ? `Reelkit studio opened on ${film.name}.` : 'Reelkit studio opened. No Reelkit project is in this folder yet.' }
179
+ })
180
+
181
+ // A reelkit command, or an edit inside a project, changes what the pane shows: follow the project it touched.
182
+ on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
183
+ const ran = await next(e)
184
+ if (!/\breelkit\b/.test(e.command)) return ran
185
+ const paths = (e.command.match(/\/[^\s"';&|)]+/g) ?? []).slice(0, 4)
186
+ let dir: string | undefined
187
+ for (const path of paths) dir ??= await projectOf(hostOf($), path).catch(() => undefined)
188
+ await refresh($, dir).catch(() => null)
189
+
190
+ return ran
191
+ })
192
+
193
+ on('tool.call', { tool: ['Write', 'Edit'] }, async ($, e, next) => {
194
+ const ran = await next(e)
195
+ const dir = await projectOf(hostOf($), e.file_path.split('/').slice(0, -1).join('/')).catch(() => undefined)
196
+ if (dir) await refresh($, dir).catch(() => null)
197
+
198
+ return ran
199
+ })
200
+
201
+ on('turn.complete', async ($, e, next) => {
202
+ const done = await next(e)
203
+ if (e.agentId === undefined && (await read($, filmAtom))) await refresh($).catch(() => null)
204
+
205
+ return done
206
+ })
207
+
208
+ on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
209
+ const kit: Kit = $.ui.resolve(e)
210
+ const { Box, Text, Button, Markdown, Code } = kit
211
+ // A table is completed with every surface's names, the missing ones drawing nothing: the surface says which really draw.
212
+ const Svg = e.surface === 'terminal' ? undefined : kit.Svg
213
+ const Input = e.surface === 'mobile' ? undefined : kit.Input
214
+ const Select = e.surface === 'mobile' ? undefined : kit.Select
215
+ const [tab, film, projects, sceneIndex, componentName, search] = await Promise.all([
216
+ read($, tabAtom),
217
+ read($, filmAtom),
218
+ read($, projectsAtom),
219
+ read($, sceneAtom),
220
+ read($, componentAtom),
221
+ read($, searchAtom),
222
+ ])
223
+ const columns = Math.max(40, e.props.bodyColumns)
224
+
225
+ const tabs = (
226
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
227
+ {TABS.map(one => (
228
+ <Button
229
+ key={`tab-${one.id}`}
230
+ label={one.label}
231
+ hotkey={one.hotkey}
232
+ variant={one.id === tab ? 'primary' : 'secondary'}
233
+ onPress={() => void update($, tabAtom, () => one.id)}
234
+ />
235
+ ))}
236
+ <Button key="refresh" label="Refresh" hotkey="r" dimColor onPress={() => void refresh($)} />
237
+ </Box>
238
+ )
239
+
240
+ if (!film) {
241
+ return (
242
+ <Box flexDirection="column" gap={1}>
243
+ <Text bold>Reelkit studio</Text>
244
+ <Text dimColor wrap="wrap">
245
+ No Reelkit project in this folder yet. Start a video and its timeline, plan, music and components show here.
246
+ </Text>
247
+ <Box flexDirection="row" gap={1}>
248
+ <Button key="new" label="New video" variant="primary" onPress={() => void ask($, 'Make a short video with Reelkit about ')} />
249
+ <Button key="refresh" label="Refresh" onPress={() => void refresh($)} />
250
+ </Box>
251
+ </Box>
252
+ )
253
+ }
254
+
255
+ const seconds = film.totalFrames / film.fps
256
+ const picked = Math.min(Math.max(0, sceneIndex), Math.max(0, film.scenes.length - 1))
257
+ const scene = film.scenes[picked]
258
+ const now = await $.clock.now()
259
+
260
+ const header = (
261
+ <Box flexDirection="column">
262
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
263
+ <Text bold>{film.title}</Text>
264
+ <Text dimColor>{`${film.aspect} · ${clock(seconds)}${film.isTimed ? '' : ' est.'} · ${film.scenes.length} scenes`}</Text>
265
+ </Box>
266
+ {Select && projects.length > 1 && (
267
+ <Select
268
+ key="project"
269
+ label="Project"
270
+ value={film.dir}
271
+ options={projects.map(dir => ({ value: dir, label: baseName(dir) }))}
272
+ onSelect={dir => void refresh($, dir)}
273
+ />
274
+ )}
275
+ </Box>
276
+ )
277
+
278
+ let body
279
+ if (tab === 'timeline') {
280
+ let drawing
281
+ if (Svg) {
282
+ const thumbs: Record<string, string> = {}
283
+ await Promise.all(
284
+ film.scenes.map(async one => {
285
+ if (!one.thumb) return
286
+ const key = `${one.thumb}@${film.loadedAt}`
287
+ let picture = pictures.get(key)
288
+ if (picture === undefined) {
289
+ picture = await $.fs
290
+ .read(one.thumb, { as: 'bytes' })
291
+ .then(bytes => bytes.base64)
292
+ .catch(() => '')
293
+ if (pictures.size > 96) pictures.clear()
294
+ pictures.set(key, picture)
295
+ }
296
+ if (picture && picture.length < 12000) thumbs[one.id] = picture
297
+ }),
298
+ )
299
+ drawing = <Svg source={timelineSvg(film, picked, thumbs)} alt={`Timeline of ${film.title}: ${film.scenes.length} scenes over ${clock(seconds)}`} />
300
+ } else {
301
+ const { ruler, lanes } = timelineLanes(film, picked, columns - 7)
302
+ drawing = (
303
+ <Box flexDirection="column">
304
+ <Box flexDirection="row">
305
+ <Text dimColor>{' '}</Text>
306
+ <Text dimColor wrap="truncate">
307
+ {ruler}
308
+ </Text>
309
+ </Box>
310
+ {lanes.map(lane => (
311
+ <Box flexDirection="row">
312
+ <Text dimColor>{lane.label.padEnd(7, ' ')}</Text>
313
+ {lane.cells.map(cell => (
314
+ <Text wrap="truncate" {...TONES[cell.tone]}>
315
+ {cell.text}
316
+ </Text>
317
+ ))}
318
+ </Box>
319
+ ))}
320
+ </Box>
321
+ )
322
+ }
323
+
324
+ body = (
325
+ <Box flexDirection="column" gap={1}>
326
+ {drawing}
327
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
328
+ <Button key="prev" label="◀ Scene" hotkey="j" onPress={() => void update($, sceneAtom, n => Math.max(0, Math.min(n, film.scenes.length - 1) - 1))} />
329
+ <Button key="next" label="Scene ▶" hotkey="k" onPress={() => void update($, sceneAtom, n => Math.min(film.scenes.length - 1, n + 1))} />
330
+ <Button key="player" label="▶ Play with timeline" hotkey="p" variant="primary" onPress={() => void openPlayer($, film)} />
331
+ {film.render && <Button key="video" label="Open video" hotkey="o" onPress={() => void reveal($, film.render?.path ?? '')} />}
332
+ </Box>
333
+ {scene && (
334
+ <Box flexDirection="column">
335
+ <Text bold>
336
+ {`${picked + 1}. ${scene.id} · ${clock(scene.start / film.fps)} to ${clock((scene.start + scene.frames) / film.fps)} · ${(scene.frames / film.fps).toFixed(1)}s`}
337
+ </Text>
338
+ {scene.narration !== '' && <Text wrap="wrap">{scene.narration}</Text>}
339
+ {scene.text.length > 0 && (
340
+ <Text dimColor wrap="wrap">{`On screen: ${scene.text.join(' · ')}`}</Text>
341
+ )}
342
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
343
+ <Button key="edit-scene" label="Edit this scene" hotkey="e" onPress={() => void ask($, `In the Reelkit video "${film.name}", change scene "${scene.id}": `)} />
344
+ <Button
345
+ key="look-scene"
346
+ label="Show its frames"
347
+ onPress={() => void ask($, `Run reelkit preview in ${film.dir} and show me the frames of scene "${scene.id}".`)}
348
+ />
349
+ </Box>
350
+ </Box>
351
+ )}
352
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
353
+ <Text dimColor>
354
+ {`${film.render ? `Rendered ${megabytes(film.render.bytes)}, ${ago(now, film.render.at)}` : 'Not rendered yet'} · ${film.previews} preview frames`}
355
+ </Text>
356
+ <Button key="render" label={film.render ? 'Render again' : 'Render'} onPress={() => void ask($, `Render the Reelkit video in ${film.dir}.`)} />
357
+ </Box>
358
+ </Box>
359
+ )
360
+ } else if (tab === 'plan') {
361
+ body = (
362
+ <Box flexDirection="column" gap={1}>
363
+ <Markdown text={planMarkdown(film)} />
364
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
365
+ <Button key="plan-change" label="Change the plan" variant="primary" onPress={() => void ask($, `Change the plan of the Reelkit video "${film.name}": `)} />
366
+ <Button key="plan-check" label="Check it" onPress={() => void ask($, `Run reelkit plan check in ${film.dir} and fix what it reports.`)} />
367
+ <Button key="plan-open" label="Open plan.json" onPress={() => void reveal($, join(film.dir, 'plan.json'))} />
368
+ </Box>
369
+ </Box>
370
+ )
371
+ } else if (tab === 'music') {
372
+ const sounds = film.sounds.filter(one => one.kind !== 'music')
373
+ const short = film.music && film.music.seconds > 0 && film.music.seconds + 0.5 < seconds
374
+ body = (
375
+ <Box flexDirection="column" gap={1}>
376
+ {film.music ? (
377
+ <Box flexDirection="column">
378
+ <Text bold>{`♪ ${film.music.title}`}</Text>
379
+ <Text dimColor>
380
+ {`${film.music.bpm ? `${Math.round(film.music.bpm)} BPM · ` : ''}${film.music.seconds ? `${clock(film.music.seconds)} long · ` : ''}${film.music.beats.length} beats in the film`}
381
+ </Text>
382
+ {short && (
383
+ <Text color="yellow" wrap="wrap">{`The track ends at ${clock(film.music.seconds)}; the film runs to ${clock(seconds)}.`}</Text>
384
+ )}
385
+ </Box>
386
+ ) : (
387
+ <Text dimColor>No music chosen yet.</Text>
388
+ )}
389
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
390
+ {film.music && <Button key="music-play" label="▶ Play track" hotkey="p" onPress={() => void reveal($, join(film.dir, film.music?.path ?? ''))} />}
391
+ <Button
392
+ key="music-find"
393
+ label="Find music"
394
+ variant="primary"
395
+ onPress={async () => {
396
+ await update($, searchAtom, old => ({ ...old, kind: 'music' }))
397
+ await update($, tabAtom, () => 'browser')
398
+ }}
399
+ />
400
+ <Button key="music-change" label="Change it" onPress={() => void ask($, `Change the music of the Reelkit video "${film.name}" to something `)} />
401
+ </Box>
402
+ <Text bold>Voice</Text>
403
+ <Text dimColor>
404
+ {`${film.voice ? `${film.voice}${film.pace ? ` · pace ${film.pace}` : ''}` : 'No narrator'} · ${film.scenes.filter(one => one.words.length > 0).length} of ${film.scenes.length} scenes recorded`}
405
+ </Text>
406
+ <Text bold>{`Sound effects (${sounds.length})`}</Text>
407
+ {sounds.length === 0 && <Text dimColor>None pulled yet.</Text>}
408
+ {sounds.slice(0, 14).map(one => (
409
+ <Box flexDirection="row" gap={1}>
410
+ <Button key={`sound-${one.id}`} label="▶" plain onPress={() => void reveal($, join(film.dir, one.path))} />
411
+ <Text wrap="truncate-end">{`${one.title}${one.seconds ? ` · ${one.seconds.toFixed(1)}s` : ''}`}</Text>
412
+ </Box>
413
+ ))}
414
+ </Box>
415
+ )
416
+ } else if (tab === 'components') {
417
+ const chosen = film.components.find(one => one.name === componentName) ?? film.components[0]
418
+ const source = chosen ? await $.fs.read(chosen.path).catch(() => '') : ''
419
+ body = (
420
+ <Box flexDirection="column" gap={1}>
421
+ {film.components.length === 0 && (
422
+ <Text dimColor wrap="wrap">
423
+ This project has no components of its own yet. They appear here as src/ fills up.
424
+ </Text>
425
+ )}
426
+ {chosen &&
427
+ (Select ? (
428
+ <Select
429
+ key="component"
430
+ label="Component"
431
+ value={chosen.name}
432
+ options={film.components.map(one => ({ value: one.name }))}
433
+ onSelect={name => void update($, componentAtom, () => name)}
434
+ />
435
+ ) : (
436
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
437
+ {film.components.map(one => (
438
+ <Button
439
+ key={`component-${one.name}`}
440
+ label={one.name}
441
+ variant={one.name === chosen.name ? 'primary' : 'secondary'}
442
+ onPress={() => void update($, componentAtom, () => one.name)}
443
+ />
444
+ ))}
445
+ </Box>
446
+ ))}
447
+ {chosen && (
448
+ <Box flexDirection="column" gap={1}>
449
+ <Text wrap="wrap" dimColor={chosen.about === ''}>
450
+ {chosen.about || 'No description at the top of the file.'}
451
+ </Text>
452
+ <Box flexDirection="row" gap={1} flexWrap="wrap">
453
+ <Button
454
+ key="component-use"
455
+ label="Use in a scene"
456
+ variant="primary"
457
+ onPress={() => void ask($, `In the Reelkit video "${film.name}", use the ${chosen.name} component in scene "${scene?.id ?? ''}" to show `)}
458
+ />
459
+ <Button key="component-change" label="Change it" onPress={() => void ask($, `Change the ${chosen.name} component (${chosen.path}): `)} />
460
+ <Button key="component-open" label="Open file" onPress={() => void reveal($, chosen.path)} />
461
+ </Box>
462
+ <Code source={source.split('\n').slice(0, 40).join('\n').slice(0, 4000)} language="tsx" path={chosen.path} wrap="truncate-end" />
463
+ </Box>
464
+ )}
465
+ <Button
466
+ key="component-find"
467
+ label="Find components in the library"
468
+ onPress={async () => {
469
+ await update($, searchAtom, old => ({ ...old, kind: 'component' }))
470
+ await update($, tabAtom, () => 'browser')
471
+ }}
472
+ />
473
+ </Box>
474
+ )
475
+ } else {
476
+ body = (
477
+ <Box flexDirection="column" gap={1}>
478
+ <Text dimColor wrap="wrap">{`Search the shared Reelkit library. Use hands the pick to Claude to pull into ${film.name}.`}</Text>
479
+ {Select ? (
480
+ <Select
481
+ key="kind"
482
+ label="Kind"
483
+ value={search.kind}
484
+ options={KINDS.map(kind => ({ value: kind }))}
485
+ onSelect={kind => void update($, searchAtom, old => ({ ...old, kind, status: 'idle' as const, note: '', items: [] }))}
486
+ />
487
+ ) : (
488
+ <Text dimColor>{`Kind: ${search.kind}`}</Text>
489
+ )}
490
+ {Input ? (
491
+ <Input
492
+ key="query"
493
+ label="Search"
494
+ placeholder={search.kind === 'music' ? 'upbeat, 120 BPM' : `what the ${search.kind} shows`}
495
+ value={search.query}
496
+ submitLabel="search"
497
+ onSubmit={text => void searchLibrary($, film, search.kind, text)}
498
+ />
499
+ ) : (
500
+ <Button key="ask-search" label="Ask Claude to search" onPress={() => void ask($, `Search the Reelkit library for ${search.kind}: `)} />
501
+ )}
502
+ {search.status === 'busy' && <Text dimColor>{`Searching for "${search.query}"…`}</Text>}
503
+ {search.status === 'failed' && (
504
+ <Text color="red" wrap="wrap">
505
+ {search.note}
506
+ </Text>
507
+ )}
508
+ {search.status === 'done' && search.items.length === 0 && <Text dimColor>{`Nothing matched "${search.query}".`}</Text>}
509
+ {search.items.map(item => (
510
+ <Box flexDirection="column">
511
+ <Box flexDirection="row" gap={1}>
512
+ <Button key={`use-${item.id}`} label="Use" onPress={() => void ask($, useRequest(item))} />
513
+ <Text bold wrap="truncate-end">{`${item.match}% ${item.title}`}</Text>
514
+ {item.detail !== '' && <Text dimColor>{item.detail}</Text>}
515
+ </Box>
516
+ <Text dimColor wrap="wrap">
517
+ {item.about}
518
+ </Text>
519
+ </Box>
520
+ ))}
521
+ </Box>
522
+ )
523
+ }
524
+
525
+ return (
526
+ <Box flexDirection="column" gap={1}>
527
+ {header}
528
+ {tabs}
529
+ {body}
530
+ </Box>
531
+ )
532
+ })
533
+ }