Use Sprites from Claude Code to create, inspect, and operate remote isolated development environments.

<img src="assets/sprites-logo.svg" alt="" width="72" height="58">
The Sprites plugin gives Claude Code hosted MCP access to persistent, isolated development environments, plus skills and confirmation hooks for safe remote workflows.
After installation, run /sprites:status to list visible Sprites and complete OAuth if needed. Run /sprites:smoke for an end-to-end list, create, and exec check with cleanup only after approval. Run /sprites-inspector to browse Sprites, their services, checkpoints, and logs in a read-only pane, and to point Claude at one.
The local Claude Code workspace and the remote Sprite filesystem are separate. Use the plugin-provided MCP tools for Sprite commands, services, checkpoints, and policy. Do not install the Sprites CLI or register a second MCP server for normal plugin use.
See the repository documentation for installation, authentication, safety, and troubleshooting details.
hooks/register.tsx 655 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, McpToolResult, Register } from 'claude-code'
3
4import type { CheckpointRow, InspectorState, Section, ServiceRow, SpriteSummary } from '../types'
5
6// A read-only Sprite Inspector: the Claude Code counterpart of the MCP App the
7// hosted server serves at ui://sprites/inspector.html. It calls the same tools
8// over the session's own connection, so it needs no credentials of its own.
9
10const PANE = 'sprites-inspector'
11const COMMAND = 'sprites-inspector'
12const SERVER = 'sprites'
13const LOG_LINES = 100
14const LOG_CHARS = 10000
15// Focus that returns sooner than this after a listing does not list again.
16const REFRESH_GAP_MS = 10_000
17
18// From this many columns the list and the details sit side by side.
19const WIDE_COLUMNS = 64
20const LIST_COLUMNS = 30
21const LABEL_COLUMNS = 8
22// Room for the longest checkpoint id, `Current`.
23const CHECKPOINT_ID_COLUMNS = 8
24
25// Theme keys, so the colors follow the person's light or dark theme.
26const STATUS_COLORS: Record<string, string> = { running: 'success', warm: 'warning', cold: 'inactive' }
27
28const section = <T,>(state: Section<T>['state'], items: T[] = [], message = ''): Section<T> => ({ state, items, message })
29const idle = <T,>(): Section<T> => section<T>('idle')
30
31const CLEARED = {
32 selected: null,
33 isChecking: false,
34 services: idle<ServiceRow>(),
35 checkpoints: idle<CheckpointRow>(),
36 logs: null,
37} satisfies Partial<InspectorState>
38
39const inspector = atom({ plugin: 'sprites', key: 'inspector' } as const, {
40 status: '',
41 statusTone: 'info',
42 prefix: '',
43 sprites: [],
44 cursor: null,
45 listedAt: null,
46 isListing: false,
47 attachedId: null,
48 ...CLEARED,
49} as InspectorState)
50
51// Each counter discards answers that arrive after a newer request of its kind.
52let listRun = 0
53let selectRun = 0
54let runtimeRun = 0
55let logsRun = 0
56let server: string | undefined
57// The pane's focus as the latest drawing saw it, to notice it coming back.
58let wasFocused = false
59
60async function connection($: EngineInterface): Promise<string> {
61 if (server) return server
62 const connected = await $.mcp.connect(SERVER)
63 if (!connected.isConnected) {
64 throw new Error(
65 connected.reason === 'auth'
66 ? 'The Sprites MCP server needs sign-in. Authenticate it in /mcp, then press Find again.'
67 : connected.message,
68 )
69 }
70 server = connected.server
71 return server
72}
73
74async function call($: EngineInterface, tool: string, args: Record<string, unknown>): Promise<McpToolResult> {
75 let result: McpToolResult
76 try {
77 result = await $.mcp.call(await connection($), tool, args)
78 } catch (error) {
79 server = undefined
80 throw error
81 }
82 if (result.isError) throw new Error(textOf(result) || `${tool} failed.`)
83 return result
84}
85
86function textOf(result: McpToolResult): string {
87 return result.content
88 .filter(block => block.type === 'text')
89 .map(block => block.text ?? '')
90 .join('\n')
91}
92
93// Extension tools return structuredContent; the generated runtime tools return JSON as text.
94function dataOf(result: McpToolResult): unknown {
95 return result.structuredContent ?? JSON.parse(textOf(result))
96}
97
98function errorText(error: unknown): string {
99 return error instanceof Error ? error.message : String(error)
100}
101
102const info = (status: string) => ({ status, statusTone: 'info' as const })
103const failure = (error: unknown) => ({ status: errorText(error), statusTone: 'error' as const })
104
105function record(value: unknown): Record<string, unknown> {
106 return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
107}
108
109function str(value: unknown): string {
110 return typeof value === 'string' ? value : typeof value === 'number' ? String(value) : ''
111}
112
113// A bare array, or an object holding one under `key`.
114function listOf(value: unknown, key: string, what: string): unknown[] {
115 const list = Array.isArray(value) ? value : record(value)[key]
116 if (!Array.isArray(list)) throw new Error(`Invalid ${what} response.`)
117 return list
118}
119
120function summary(value: unknown): SpriteSummary | null {
121 const v = record(value)
122 if (!str(v.id) || !str(v.name)) return null
123 return {
124 id: str(v.id),
125 name: str(v.name),
126 organization: str(v.organization),
127 orgId: str(v.org_id),
128 url: str(v.url),
129 status: str(v.status),
130 version: str(v.version),
131 created: str(v.created_at),
132 uri: str(record(v.resource).uri),
133 }
134}
135
136function safeUrl(value: string): string | null {
137 try {
138 const url = new URL(value)
139 return url.protocol === 'https:' && !url.username && !url.password ? url.href : null
140 } catch {
141 return null
142 }
143}
144
145// Logs can carry terminal escapes; Code and Text take tab and newline as their only control characters.
146function printable(text: string): string {
147 return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/[\x00-\x08\x0b-\x1f\x7f]/g, '')
148}
149
150const day = (iso: string) => iso.slice(0, 10)
151
152function timeOfDay(ms: number): string {
153 try {
154 return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
155 } catch {
156 return new Date(ms).toISOString().slice(11, 16)
157 }
158}
159const statusColor = (status: string) => STATUS_COLORS[status] ?? 'warning'
160
161async function loadPage($: EngineInterface, append: boolean, nextPrefix?: string): Promise<void> {
162 const before = await read($, inspector)
163 // Ignore a queued Load more press after another search has reset its cursor.
164 if (append && (before.isListing || !before.cursor)) return
165 const run = ++listRun
166 const prefix = append ? before.prefix : (nextPrefix ?? before.prefix).trim()
167 if (!append) {
168 ++selectRun
169 ++runtimeRun
170 ++logsRun
171 }
172 await update($, inspector, s => ({
173 ...s,
174 ...(append ? {} : { ...CLEARED, prefix, sprites: [], cursor: null, listedAt: null }),
175 ...info(''),
176 isListing: true,
177 }))
178 try {
179 const page = record(
180 dataOf(await call($, 'open_sprite_inspector', { prefix, continuation_token: append ? (before.cursor ?? '') : '' })),
181 )
182 const incoming = listOf(page, 'sprites', 'Sprite listing')
183 .map(summary)
184 .filter((one): one is SpriteSummary => one !== null)
185 const listedAt = await $.clock.now()
186 if (run !== listRun) return
187 await update($, inspector, s => {
188 const sprites = append ? [...s.sprites, ...incoming.filter(one => !s.sprites.some(old => old.id === one.id))] : incoming
189 return {
190 ...s,
191 ...info(sprites.length ? '' : 'No matching Sprites.'),
192 sprites,
193 cursor: str(page.next_continuation_token) || null,
194 listedAt,
195 prefix: typeof page.prefix === 'string' ? page.prefix : s.prefix,
196 isListing: false,
197 }
198 })
199 } catch (error) {
200 if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error), isListing: false }))
201 }
202}
203
204// The quiet re-list the pane runs when it regains focus: it changes only what
205// the listing says now, and keeps the selection, runtime details and logs.
206async function refresh($: EngineInterface): Promise<void> {
207 const before = await read($, inspector)
208 if (before.isListing || before.listedAt === null) return
209 if ((await $.clock.now()) - before.listedAt < REFRESH_GAP_MS) return
210 const run = ++listRun
211 try {
212 const page = record(dataOf(await call($, 'open_sprite_inspector', { prefix: before.prefix, continuation_token: '' })))
213 const fresh = listOf(page, 'sprites', 'Sprite listing')
214 .map(summary)
215 .filter((one): one is SpriteSummary => one !== null)
216 const listedAt = await $.clock.now()
217 if (run !== listRun) return
218 const byId = new Map(fresh.map(one => [one.id, one]))
219 await update($, inspector, s => {
220 // Restart pagination with this page's cursor: inserts or deletions can
221 // move rows across the old page boundaries while the pane is unfocused.
222 const listed = s.selected && byId.get(s.selected.id)
223 return {
224 ...s,
225 ...(s.statusTone === 'error' ? info('') : {}),
226 sprites: fresh,
227 cursor: str(page.next_continuation_token) || null,
228 listedAt,
229 selected: s.selected && listed ? { ...s.selected, status: listed.status } : s.selected,
230 }
231 })
232 } catch (error) {
233 if (run === listRun) await update($, inspector, s => ({ ...s, ...failure(error) }))
234 }
235}
236
237async function selectSprite($: EngineInterface, sprite: SpriteSummary): Promise<void> {
238 const run = ++selectRun
239 ++runtimeRun
240 ++logsRun
241 await update($, inspector, s => ({ ...s, ...CLEARED, ...info(''), selected: sprite, isChecking: true }))
242 try {
243 const checked = summary(dataOf(await call($, 'get_sprite_info', { sprite: sprite.name, sprite_id: sprite.id })))
244 if (!checked) throw new Error('Invalid Sprite info response.')
245 if (run !== selectRun) return
246 // get_sprite_info's status can disagree with the listing's live runtime
247 // state (a running Sprite reads cold), so the listing's status stands.
248 const fresh = { ...checked, status: sprite.status || checked.status }
249 await update($, inspector, s => ({
250 ...s,
251 selected: fresh,
252 isChecking: false,
253 sprites: s.sprites.map(one => (one.id === fresh.id ? fresh : one)),
254 }))
255 } catch (error) {
256 if (run === selectRun) await update($, inspector, s => ({ ...s, ...CLEARED, ...failure(error) }))
257 }
258}
259
260// The counterpart of the MCP App's ui/update-model-context: a row the model
261// reads and the person does not see as typed. Rows add up rather than replace,
262// so the newest attachment is the one that counts.
263async function attach($: EngineInterface): Promise<void> {
264 const { selected } = await read($, inspector)
265 if (!selected) return
266 const identity = {
267 sprite: selected.name,
268 sprite_id: selected.id,
269 org_id: selected.orgId,
270 organization: selected.organization,
271 uri: selected.uri,
272 }
273 const text =
274 `The user selected this Sprite in the Sprite Inspector pane: ${JSON.stringify(identity)}. ` +
275 'Treat it as the target when they refer to "the Sprite" or "this Sprite". Verify the identity with get_sprite_info ' +
276 'before operating on it, and pass sprite_id alongside sprite on Sprite-scoped tool calls.'
277 let refused: string | undefined
278 try {
279 refused = (await $.session.append({ message: { type: 'user', content: [{ type: 'text', text }] } })).deny
280 } catch (error) {
281 refused = errorText(error)
282 }
283 await update($, inspector, s =>
284 refused
285 ? { ...s, ...failure(`Could not attach the Sprite: ${refused}`) }
286 : { ...s, ...info(''), attachedId: selected.id },
287 )
288}
289
290async function loadRuntime($: EngineInterface): Promise<void> {
291 const { selected } = await read($, inspector)
292 if (!selected) return
293 const selection = selectRun
294 const run = ++runtimeRun
295 const current = () => selection === selectRun && run === runtimeRun
296 await update($, inspector, s => ({ ...s, services: section<ServiceRow>('loading'), checkpoints: section<CheckpointRow>('loading') }))
297 const args = { sprite: selected.name, sprite_id: selected.id }
298
299 await Promise.all([
300 (async () => {
301 try {
302 const items = listOf(dataOf(await call($, 'service_list', args)), 'services', 'services').map(one => {
303 const v = record(one)
304 return { name: str(v.name), status: str(record(v.state).status) || 'unknown' }
305 })
306 if (current()) await update($, inspector, s => ({ ...s, services: section('ready', items) }))
307 } catch (error) {
308 if (current()) await update($, inspector, s => ({ ...s, services: section('error', [], errorText(error)) }))
309 }
310 })(),
311 (async () => {
312 try {
313 const items = listOf(dataOf(await call($, 'checkpoint_list', args)), 'checkpoints', 'checkpoints').map(one => {
314 const v = record(one)
315 return { id: str(v.id), comment: str(v.comment), created: str(v.create_time) }
316 })
317 if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('ready', items) }))
318 } catch (error) {
319 if (current()) await update($, inspector, s => ({ ...s, checkpoints: section('error', [], errorText(error)) }))
320 }
321 })(),
322 ])
323}
324
325async function loadLogs($: EngineInterface, service: string): Promise<void> {
326 const { selected } = await read($, inspector)
327 if (!selected) return
328 const selection = selectRun
329 const run = ++logsRun
330 const current = () => selection === selectRun && run === logsRun
331 await update($, inspector, s => ({ ...s, logs: { service, state: 'loading' as const, text: '' } }))
332 try {
333 const result = await call($, 'service_logs', {
334 sprite: selected.name,
335 sprite_id: selected.id,
336 service_name: service,
337 lines: LOG_LINES,
338 duration: '0s',
339 })
340 // The endpoint streams NDJSON events; `data` carries the output itself.
341 const text = printable(
342 textOf(result)
343 .split('\n')
344 .filter(Boolean)
345 .map(line => {
346 try {
347 const event = record(JSON.parse(line))
348 return typeof event.data === 'string' ? event.data : event.type === 'error' ? `${JSON.stringify(event)}\n` : ''
349 } catch {
350 return `${line}\n`
351 }
352 })
353 .join(''),
354 )
355 .replace(/\n+$/, '')
356 .slice(-LOG_CHARS)
357 if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'ready' as const, text } }))
358 } catch (error) {
359 if (current()) await update($, inspector, s => ({ ...s, logs: { service, state: 'error' as const, text: errorText(error) } }))
360 }
361}
362
363// MCP calls can outlast the dispatch that asked for them; a timer runs them on their own.
364function later($: EngineInterface, work: () => Promise<void>): void {
365 $.clock.after(0, () => void work())
366}
367
368function listing(s: InspectorState): string {
369 if (!s.sprites.length) return s.status || 'No Sprites loaded.'
370 const rows = s.sprites.map(one => `- ${one.name} · ${one.status || 'unknown'}${one.organization ? ` · ${one.organization}` : ''}`)
371 return [`${s.sprites.length} Sprite${s.sprites.length === 1 ? '' : 's'}${s.cursor ? ' (more available)' : ''}`, ...rows].join('\n')
372}
373
374export const register: Register = on => {
375 on('session.start', async ($, e, next) => {
376 await $.command.register({
377 name: COMMAND,
378 description: 'Browse your Sprites, their services, checkpoints, and logs in a read-only pane',
379 argumentHint: '[name prefix]',
380 })
381 // State outlives a reload or resume, but the requests in flight did not.
382 await update($, inspector, s => ({
383 ...s,
384 ...info(''),
385 isListing: false,
386 isChecking: false,
387 services: s.services.state === 'loading' ? idle<ServiceRow>() : s.services,
388 checkpoints: s.checkpoints.state === 'loading' ? idle<CheckpointRow>() : s.checkpoints,
389 logs: s.logs?.state === 'loading' ? null : s.logs,
390 }))
391 return next(e)
392 })
393
394 on('command.run', { command: COMMAND }, async ($, e) => {
395 const opened = await $.ui.open({ id: PANE, title: 'Sprites' })
396 const before = await read($, inspector)
397 const prefix = e.args.trim()
398
399 // Where no pane can draw (the VS Code panel, claude -p), answer in text.
400 if (!opened.isPlaced) {
401 await loadPage($, false, prefix)
402 return { text: listing(await read($, inspector)) }
403 }
404 // Opening always lists afresh: statuses change while the pane sits idle.
405 if (!before.isListing || prefix) later($, () => loadPage($, false, prefix || undefined))
406 return { text: 'Sprite Inspector opened.' }
407 })
408
409 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
410 const { Box, Text, Button, Link, Code } = $.ui.resolve(e)
411 const Input = e.surface === 'mobile' ? undefined : $.ui.resolve(e).Input
412 // Coming back to the pane lists again; a drawing only schedules it, since it may not write.
413 if (e.props.isFocused && !wasFocused) later($, () => refresh($))
414 wasFocused = e.props.isFocused
415
416 const s = await read($, inspector)
417 const selected = s.selected
418 const isWide = e.props.bodyColumns >= WIDE_COLUMNS
419 const hasRuntime = s.services.state !== 'idle' || s.checkpoints.state !== 'idle'
420 const isRuntimeLoading = s.services.state === 'loading' || s.checkpoints.state === 'loading'
421
422 const field = (label: string, value: string) => (
423 <Box flexDirection="row">
424 <Box width={LABEL_COLUMNS} flexShrink={0}>
425 <Text dimColor>{label}</Text>
426 </Box>
427 <Text wrap="truncate-middle">{value}</Text>
428 </Box>
429 )
430
431 const heading = (title: string, detail?: string) => (
432 <Box flexDirection="row" justifyContent="space-between">
433 <Text bold>{title}</Text>
434 {detail ? <Text dimColor>{detail}</Text> : null}
435 </Box>
436 )
437
438 const search = (
439 <Box flexDirection="column">
440 {Input && (
441 <Input
442 key="prefix"
443 placeholder="Filter by name prefix"
444 value={s.prefix}
445 submitLabel="Find"
446 onSubmit={value => later($, () => loadPage($, false, value))}
447 />
448 )}
449 {s.isListing && <Text dimColor>Loading Sprites…</Text>}
450 {s.status && (
451 <Text color={s.statusTone === 'error' ? 'error' : undefined} dimColor={s.statusTone !== 'error'} wrap="wrap">
452 {s.status}
453 </Text>
454 )}
455 </Box>
456 )
457
458 const list = (
459 <Box flexDirection="column" width={isWide ? LIST_COLUMNS : undefined} flexShrink={0}>
460 {s.sprites.length > 0 &&
461 heading(
462 'Sprites',
463 `${s.sprites.length}${s.cursor ? '+' : ''}${s.listedAt !== null ? ` · as of ${timeOfDay(s.listedAt)}` : ''}`,
464 )}
465 {!s.sprites.length && !s.isListing && !s.status && (
466 <Text dimColor>Press Find to list your Sprites, or filter by a name prefix.</Text>
467 )}
468 {s.sprites.map(one => {
469 const isSelected = one.id === selected?.id
470 return (
471 <Box key={`row-${one.id}`} flexDirection="row" gap={1}>
472 <Text color="claude">{isSelected ? '▌' : ' '}</Text>
473 <Text color={statusColor(one.status)}>●</Text>
474 <Box flexGrow={1} flexShrink={1} minWidth={0}>
475 {/* The selected row stays a Button: a focused element that leaves the
476 tree takes the pane's focus with it, and on desktop the next click
477 into an unfocused pane only focuses. */}
478 <Button
479 key={`sprite-${one.id}`}
480 plain
481 label={one.name}
482 onPress={() => {
483 if (!isSelected) later($, () => selectSprite($, one))
484 }}
485 />
486 </Box>
487 {one.id === s.attachedId && <Text color="suggestion">in chat</Text>}
488 <Text dimColor>{one.status || 'unknown'}</Text>
489 </Box>
490 )
491 })}
492 {s.cursor && (
493 <Box marginTop={1}>
494 <Button key="more" label={s.isListing ? 'Loading…' : 'Load more'} onPress={() => later($, () => loadPage($, true))} />
495 </Box>
496 )}
497 </Box>
498 )
499
500 const details = selected ? (
501 <Box flexDirection="column" borderStyle="round" borderColor="claude" paddingX={1} gap={1}>
502 <Box flexDirection="row" justifyContent="space-between" gap={1}>
503 <Box flexShrink={1}>
504 <Text bold wrap="truncate-end">
505 {selected.name}
506 </Text>
507 </Box>
508 <Box flexDirection="row" gap={1} flexShrink={0}>
509 {selected.id === s.attachedId && <Text color="suggestion">in chat</Text>}
510 <Text color={statusColor(selected.status)}>● {s.isChecking ? 'checking…' : selected.status || 'unknown'}</Text>
511 </Box>
512 </Box>
513
514 <Box flexDirection="column">
515 {selected.organization && field('Org', selected.organization)}
516 {selected.created && field('Created', day(selected.created))}
517 {safeUrl(selected.url) && (
518 <Box flexDirection="row">
519 <Box width={LABEL_COLUMNS} flexShrink={0}>
520 <Text dimColor>URL</Text>
521 </Box>
522 <Link href={safeUrl(selected.url) ?? ''} label={selected.url.replace(/^https:\/\//, '').replace(/\/$/, '')} />
523 </Box>
524 )}
525 </Box>
526
527 {!s.isChecking && (
528 <Box flexDirection="column">
529 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
530 <Button
531 key="attach"
532 variant={selected.id === s.attachedId ? 'secondary' : 'primary'}
533 label={selected.id === s.attachedId ? 'In chat ✓' : 'Use in chat'}
534 onPress={() => later($, () => attach($))}
535 />
536 <Button
537 key="runtime"
538 label={isRuntimeLoading ? 'Loading…' : hasRuntime ? 'Refresh runtime' : 'Load runtime'}
539 onPress={() => later($, () => loadRuntime($))}
540 />
541 </Box>
542 {!hasRuntime && selected.status !== 'running' && (
543 <Text color="warning" wrap="wrap">
544 Loading runtime wakes this {selected.status || 'idle'} Sprite.
545 </Text>
546 )}
547 </Box>
548 )}
549 </Box>
550 ) : isWide && s.sprites.length > 0 ? (
551 <Text dimColor>Select a Sprite to see its details.</Text>
552 ) : null
553
554 const services = selected && hasRuntime && (
555 <Box flexDirection="column">
556 {heading('Services', s.services.state === 'ready' ? String(s.services.items.length) : undefined)}
557 {s.services.state === 'loading' && <Text dimColor>Loading…</Text>}
558 {s.services.state === 'error' && <Text color="error" wrap="wrap">{s.services.message}</Text>}
559 {s.services.state === 'ready' && !s.services.items.length && <Text dimColor>No services configured.</Text>}
560 {s.services.items.map(service => (
561 <Box key={`svc-${service.name}`} flexDirection="row" gap={1}>
562 <Text color={service.status === 'running' ? 'success' : service.status === 'stopped' ? 'inactive' : 'warning'}>●</Text>
563 <Box flexGrow={1} flexShrink={1} minWidth={0}>
564 <Text wrap="truncate-end">{service.name}</Text>
565 </Box>
566 <Text dimColor>{service.status}</Text>
567 <Button
568 key={`logs-${service.name}`}
569 label={s.logs?.service === service.name ? 'Logs ✓' : 'Logs'}
570 onPress={() => later($, () => loadLogs($, service.name))}
571 />
572 </Box>
573 ))}
574 </Box>
575 )
576
577 const checkpoints = selected && hasRuntime && (
578 <Box flexDirection="column">
579 {heading('Checkpoints', s.checkpoints.state === 'ready' ? String(s.checkpoints.items.length) : undefined)}
580 {s.checkpoints.state === 'loading' && <Text dimColor>Loading…</Text>}
581 {s.checkpoints.state === 'error' && <Text color="error" wrap="wrap">{s.checkpoints.message}</Text>}
582 {s.checkpoints.state === 'ready' && !s.checkpoints.items.length && <Text dimColor>No checkpoints.</Text>}
583 {s.checkpoints.items.map(checkpoint => (
584 <Box key={`cp-${checkpoint.id}`} flexDirection="row" gap={1}>
585 <Box width={CHECKPOINT_ID_COLUMNS} flexShrink={0}>
586 <Text bold wrap="truncate-end">
587 {checkpoint.id}
588 </Text>
589 </Box>
590 <Box flexGrow={1} flexShrink={1} minWidth={0}>
591 <Text wrap="truncate-end">{checkpoint.comment || 'No comment'}</Text>
592 </Box>
593 {checkpoint.created && (
594 <Box flexShrink={0}>
595 <Text dimColor>{day(checkpoint.created)}</Text>
596 </Box>
597 )}
598 </Box>
599 ))}
600 </Box>
601 )
602
603 const logs = selected && s.logs && (
604 <Box flexDirection="column">
605 <Box flexDirection="row" justifyContent="space-between">
606 <Text bold>
607 Logs <Text dimColor>· {s.logs.service} · last {LOG_LINES} lines</Text>
608 </Text>
609 <Button
610 key="hide-logs"
611 plain
612 label="Hide"
613 onPress={() =>
614 later($, async () => {
615 // Hide leaves the tree with the logs; hand the ring to a Button that stays.
616 await $.ui.focus({ requestId: PANE, key: 'runtime' })
617 await update($, inspector, st => ({ ...st, logs: null }))
618 })
619 }
620 />
621 </Box>
622 {s.logs.state === 'loading' && <Text dimColor>Loading…</Text>}
623 {s.logs.state === 'error' && <Text color="error" wrap="wrap">{s.logs.text}</Text>}
624 {s.logs.state === 'ready' && (s.logs.text ? <Code source={s.logs.text} /> : <Text dimColor>No recent logs.</Text>)}
625 </Box>
626 )
627
628 const detailColumn = (
629 <Box flexDirection="column" gap={1} flexGrow={1} flexShrink={1}>
630 {details}
631 {services}
632 {checkpoints}
633 {logs}
634 </Box>
635 )
636
637 return (
638 <Box flexDirection="column" gap={1}>
639 {search}
640 {isWide ? (
641 <Box flexDirection="row" gap={2}>
642 {list}
643 {detailColumn}
644 </Box>
645 ) : (
646 <Box flexDirection="column" gap={1}>
647 {list}
648 {detailColumn}
649 </Box>
650 )}
651 </Box>
652 )
653 })
654}
655types/index.d.ts 50 lines1export type SpriteSummary = {
2 id: string
3 name: string
4 organization: string
5 orgId: string
6 url: string
7 status: string
8 version: string
9 created: string
10 uri: string
11}
12
13export type ServiceRow = { name: string; status: string }
14
15export type CheckpointRow = { id: string; comment: string; created: string }
16
17export type Section<T> = {
18 state: 'idle' | 'loading' | 'ready' | 'error'
19 items: T[]
20 message: string
21}
22
23export type LogsView = {
24 service: string
25 state: 'loading' | 'ready' | 'error'
26 text: string
27}
28
29export type InspectorState = {
30 status: string
31 statusTone: 'info' | 'error'
32 prefix: string
33 sprites: SpriteSummary[]
34 cursor: string | null
35 listedAt: number | null
36 isListing: boolean
37 selected: SpriteSummary | null
38 isChecking: boolean
39 attachedId: string | null
40 services: Section<ServiceRow>
41 checkpoints: Section<CheckpointRow>
42 logs: LogsView | null
43}
44
45declare module 'claude-code' {
46 interface PluginState {
47 sprites: { inspector: InspectorState }
48 }
49}
50