Generative UI: Claude draws cards, layouts, live timers and forms in a side pane and gets your answers back; data-bound templates show live test and git status.

A Claude Code mod that lets Claude draw UI instead of prose: cards, layouts, tables, progress bars, markdown, code, live timers and forms whose answers go back to Claude. It also ships templates: reusable screens bound to command output, such as live test or git status, that run without Claude and cost no tokens.

At the prompt of a Claude Code terminal session:
/plugin install genui --marketplace emirtuncer/genui-mod
Answer y to add the marketplace, then pick a scope (user scope is first; press Enter).
From a shell:
claude plugin install genui --marketplace emirtuncer/genui-mod
In the desktop app, where /plugin does not run: click + next to the prompt box, then Plugins, Add marketplace with emirtuncer/genui-mod, then Add plugin.
Claude calls the mcp__genui__render tool with a JSON component tree. Just ask, for example:
The UI is drawn in the GenUI side pane, with a one-line pointer in the transcript. Claude can draw it inline in the transcript instead (placement: "inline"), meant for one-off answers. /genui opens the side pane. A pane opened without asking needs a wide terminal (about 144 columns); otherwise the UI is drawn inline and /genui opens it.
Nodes: box, text, markdown, code, link, divider, table, progress, kv, input, select, button. See hooks/schema.ts for their fields.
Live nodes redraw themselves every second, done by the mod with no script and no tokens: countdown (a toast and a notification when it ends), stopwatch, clock.
Forms (awaitResponse: true) have live inputs, selects and buttons. A button can:
submit: send the field values;cancel: send nothing;action: send its id;prompt: send its prompt text to Claude without closing the form.On Submit or Cancel the form locks, and your answers reach Claude as your next message in a <genui-response> block.
| Watching tests | Watching git |
|---|---|
![]() | ![]() |
A template is a JSON file: commands to run, and a tree whose {{placeholders}} are filled from their output. /genui <name> runs it and shows it in the side pane, refreshing on a timer if it says so.
/genui list # project, user and built-in templates
/genui tests # vitest results, with "Run again" and "Ask Claude to fix" (built-in)
/genui git # branch, ahead/behind, colored changes, recent commits (built-in, every 10 s)
/genui pods # kubectl pods (built-in, every 10 s)
/genui open <name> # same as /genui <name>
/genui init git # copy a built-in template to .claude/genui/git.json to edit it
/genui refresh # read the shown template's file again and run it
The names list, init, refresh and open are reserved, so a template cannot use them.
Templates live in two places:
.claude/genui/<name>.json: project templates, shared with your team through git;~/.claude/genui/<name>.json: your own.A small one:
{
"title": "Branch",
"sources": { "branch": { "argv": ["git", "branch", "--show-current"], "parse": "text" } },
"refreshSeconds": 10,
"tree": { "type": "text", "text": "⎇ {{branch | default:—}}", "bold": true, "color": "claude" }
}
The full format is in hooks/schema.ts (TEMPLATE_DOC). It covers:
json, text, lines, timeoutMs, allowFailure, manual;length, first, last, count, where, map, join, sort, take, split, trim, upper, default;each and when nodes;refresh and run buttons.Claude can write a template for you and check it with its preview_template tool.
/genui <name>. They always ask when Claude starts one with preview_template.<genui-prompt template="…" origin="…">), not as your own words.git clone https://github.com/emirtuncer/genui-mod
claude --plugin-dir ./genui-mod
claude plugin validate ./genui-mod
claude plugin test ./genui-mod
A hook may run for only 10 seconds of its own time, so the tool can't hold the turn open while you fill a form. Instead it returns right away and tells Claude to end its turn. Your answers arrive as the next prompt, so to you the flow looks the same.
MIT
hooks/register.tsx 290 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GenuiButton, GenuiForm, GenuiLatest, GenuiView } from '../types'
5import { fillString, restoreBraces } from './bind'
6import { renderTemplate, renderView } from './render'
7import { RESERVED_NAMES, initTemplate, listTemplates, locate, openTemplate, previewTemplate, reloadTemplate, runManualSource, tickTemplate } from './runner'
8import {
9 INPUT_SCHEMA,
10 PREVIEW_DESCRIPTION,
11 PREVIEW_SCHEMA,
12 TOOL_DESCRIPTION,
13 countdownEnd,
14 initialValues,
15 isInteractive,
16 liveNodes,
17 validate,
18} from './schema'
19import { templateView } from './template-file'
20
21const TOOL = 'mcp__genui__render'
22const PREVIEW = 'mcp__genui__preview_template'
23const PANE = 'genui'
24const FORMS = { plugin: 'genui', key: 'forms' } as const
25const RUNS = { plugin: 'genui', key: 'runs' } as const
26const latest = atom({ plugin: 'genui', key: 'latest' } as const, null)
27const fired = atom({ plugin: 'genui', key: 'fired' } as const, [])
28
29/** The view a call's input describes, or undefined when it does not validate. */
30const viewOf = (id: string, input: Record<string, unknown>): GenuiView | undefined => {
31 if (validate(input.tree).length > 0) return undefined
32 return {
33 id,
34 title: typeof input.title === 'string' && input.title !== '' ? input.title : 'Generated UI',
35 tree: input.tree as GenuiView['tree'],
36 awaitResponse: input.awaitResponse === true,
37 placement: input.placement === 'inline' ? 'inline' : 'pane',
38 }
39}
40
41/** A drawing stored before `kind` existed has none: it is a drawn view. */
42const drawnOf = (shown: GenuiLatest | null): GenuiView | undefined =>
43 shown === null || shown.kind === 'template' ? undefined : shown
44
45const formOf = async ($: EngineInterface, view: GenuiView): Promise<GenuiForm> =>
46 (await read($, { ...FORMS, id: view.id })) ?? { status: 'open', values: initialValues(view.tree) }
47
48/** Whether the side pane is on screen showing this view. */
49const isInPane = async ($: EngineInterface, view: GenuiView) => {
50 if (view.placement !== 'pane') return false
51 if (drawnOf(await read($, latest))?.id !== view.id) return false
52 const panes = await $.ui.panes().catch(() => [])
53
54 return panes.some(pane => pane.id === PANE && pane.isPlaced)
55}
56
57/** `<`, `>` and `&` as entities: text inside a tag cannot close it or open another. */
58const escapeTag = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
59
60/** What the model reads as the person's answer to a form; its values' `<`, `>` and `&` as JSON escapes. */
61export const responseText = (view: GenuiView, form: GenuiForm, action: string) => {
62 const attribute = (value: string) => escapeTag(JSON.stringify(value))
63 const values = JSON.stringify(form.values).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026')
64 return `<genui-response id=${attribute(view.id)} title=${attribute(view.title)} action=${attribute(action)}>${values}</genui-response>`
65}
66
67/** A template's prompt as Claude reads it: marked as the template's, not the person's own words. */
68export const templatePromptText = (template: string, origin: string, text: string) =>
69 `<genui-prompt template=${escapeTag(JSON.stringify(template))} origin=${escapeTag(JSON.stringify(origin))}>\n${text.replace(/<\/genui-prompt/gi, '<\\/genui-prompt')}\n</genui-prompt>`
70
71/** A `prompt` button's text, its bindings filled; the raw text where a binding fails. Data braces come back last. */
72export const promptText = (button: GenuiButton, scope: Record<string, unknown>) => {
73 const text = button.prompt ?? button.label
74 try {
75 const filled = fillString(text, scope)
76 const shown = filled === undefined || filled === null ? '' : typeof filled === 'object' ? JSON.stringify(filled) : String(filled)
77 return restoreBraces(shown)
78 } catch {
79 return restoreBraces(text)
80 }
81}
82
83/** The closures a drawn view's fields and buttons call; `template` names the template a view is a run of. */
84const handlersFor = ($: EngineInterface, view: GenuiView, template?: string) => ({
85 onValue: (id: string, value: string) =>
86 void update($, { ...FORMS, id: view.id }, form => {
87 const current = form ?? { status: 'open' as const, values: initialValues(view.tree) }
88 if (current.status !== 'open') return current
89 return { ...current, values: { ...current.values, [id]: value } }
90 }).catch(() => undefined),
91 onButton: async (button: GenuiButton) => {
92 const action = button.action ?? 'action'
93 if (action === 'refresh' && template !== undefined) return void (await reloadTemplate($, template))
94 if (action === 'run' && template !== undefined && button.source !== undefined) return runManualSource($, template, button.source)
95 if (action === 'prompt') {
96 const form = await formOf($, view)
97 const run = template === undefined ? undefined : await read($, { ...RUNS, id: template })
98 const text = promptText(button, { ...run?.data, now: run?.updatedAt, form: { ...initialValues(view.tree), ...form.values } })
99 await $.prompt.submit({ text: template === undefined ? text : templatePromptText(template, run?.origin ?? 'unknown', text) })
100 return
101 }
102 let closed: GenuiForm | undefined
103 await update($, { ...FORMS, id: view.id }, form => {
104 const current = form ?? { status: 'open' as const, values: initialValues(view.tree) }
105 if (current.status !== 'open') return current
106 closed = {
107 ...current,
108 status: action === 'cancel' ? 'cancelled' : 'submitted',
109 pressed: button.id,
110 values: action === 'cancel' ? {} : { ...initialValues(view.tree), ...current.values },
111 }
112 return closed
113 })
114 if (closed === undefined) return
115 await $.prompt.submit({ text: responseText(view, closed, action === 'action' ? button.id : action) })
116 },
117})
118
119/**
120 * One second of the pane: a template refreshes on its period; a drawing
121 * redraws while a live node moves and announces each countdown once.
122 */
123async function tick($: EngineInterface) {
124 const shown = await read($, latest)
125 if (shown === null) return
126 if (shown.kind === 'template') return tickTemplate($, shown.name)
127 const view = drawnOf(shown)
128 if (view === undefined) return
129 const live = liveNodes(view.tree)
130 if (live.length === 0) return
131 const { startedAt } = await formOf($, view)
132 if (startedAt === undefined) return
133 const now = await $.clock.now()
134 let isMoving = false
135 for (const node of live) {
136 if (node.type !== 'countdown' || countdownEnd(node, startedAt) > now) {
137 isMoving = true
138 continue
139 }
140 const name = `${view.id}/${node.id}`
141 if ((await read($, fired)).includes(name)) continue
142 await update($, fired, list => [...list, name].slice(-100))
143 const label = node.label ?? view.title
144 const text = `${/^\p{Extended_Pictographic}/u.test(label) ? '' : '⏰ '}${label}: ${node.doneText ?? "time's up"}`
145 $.ui.toast(text, { timeoutMs: 10_000 })
146 void $.ui.notify(text, { title: view.title }).catch(() => undefined)
147 isMoving = true
148 }
149 if (isMoving) $.ui.invalidate('ui.render')
150}
151
152/** `/genui` and its subcommands. */
153async function runCommand($: EngineInterface, args: string): Promise<string> {
154 const [sub = '', name = ''] = args.trim().split(/\s+/)
155 if (sub === '') {
156 const shown = await read($, latest)
157 if (shown === null) return 'No generated UI yet. Templates: /genui list'
158 const title = shown.kind === 'template' ? ((await read($, { ...RUNS, id: shown.name }))?.template.title ?? shown.name) : shown.title
159 const opened = await $.ui.open({ id: PANE, title }).catch(() => undefined)
160 if (opened === undefined) return `Could not open the GenUI pane for "${title}".`
161 return `Opened "${title}" in the GenUI pane.`
162 }
163 if (sub === 'list') {
164 const templates = await listTemplates($)
165 return ['Templates (open one with /genui <name>):', ...templates.map(t => ` ${t.name} ${t.title} (${t.origin})`)].join('\n')
166 }
167 if (sub === 'init') return name === '' ? 'Usage: /genui init <built-in name>' : initTemplate($, name)
168 if (sub === 'refresh') {
169 const shown = await read($, latest)
170 if (shown?.kind !== 'template') return 'The pane shows no template.'
171 return reloadTemplate($, shown.name)
172 }
173 if (sub === 'open' && name === '') return 'Usage: /genui open <name>'
174 const wanted = sub === 'open' ? name : sub
175 const located = await locate($, wanted)
176 if (located === undefined) {
177 const names = (await listTemplates($)).map(t => t.name).join(', ')
178 return `No template "${wanted}". Templates: ${names}. (${RESERVED_NAMES.join(', ')} are subcommands, not template names.)`
179 }
180 return (await openTemplate($, located)).text
181}
182
183export const register: Register = on => {
184 on('session.start', async ($, e, next) => {
185 await $.tool.register({ name: 'render', description: TOOL_DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
186 await $.tool.register({ name: 'preview_template', description: PREVIEW_DESCRIPTION, inputSchema: PREVIEW_SCHEMA })
187 await $.command.register({
188 name: 'genui',
189 description: 'Open the GenUI pane, or a template: /genui [name | open <name> | list | init <name> | refresh]',
190 argumentHint: '[name | open <name> | list | init <name> | refresh]',
191 })
192 $.clock.every(1000, () => void tick($).catch(() => undefined))
193
194 return next(e)
195 })
196
197 on('command.run', { command: 'genui' }, async ($, e) => ({ text: await runCommand($, e.args) })).catch(($, e, next) => ({
198 text: `genui: the command failed: ${next.error.message ?? next.error.kind}`,
199 }))
200
201 on('tool.call', { tool: TOOL }, async ($, e) => {
202 const problems = validate(e.tree)
203 if (problems.length > 0) {
204 return { deny: `The UI tree does not validate; fix these and call again:\n${problems.map(p => `- ${p}`).join('\n')}` }
205 }
206 const view = viewOf(e.tool_use_id, e)
207 if (view === undefined) return { deny: 'The UI tree does not validate.' }
208 const startedAt = await $.clock.now()
209 await update($, { ...FORMS, id: view.id }, form => ({
210 ...(form ?? { status: 'open' as const, values: initialValues(view.tree) }),
211 startedAt,
212 }))
213 await update($, latest, () => ({ kind: 'drawn' as const, ...view }))
214
215 let where = 'inline in the transcript'
216 const panes = await $.ui.panes().catch(() => [])
217 if (view.placement === 'pane' || panes.some(pane => pane.id === PANE)) {
218 const opened = await $.ui.open({ id: PANE, title: view.title }).catch(() => ({ isPlaced: false }))
219 if (view.placement === 'pane') {
220 where = opened.isPlaced ? 'in the side pane' : 'inline in the transcript (the side pane did not fit; the user can open it with /genui)'
221 }
222 }
223
224 if (view.awaitResponse && isInteractive(view.tree)) {
225 return {
226 result: `Form "${view.title}" (id ${view.id}) is shown to the user ${where}. End your turn now: their answers arrive as their next message in a <genui-response id="${view.id}"> block.`,
227 }
228 }
229
230 return { result: `UI "${view.title}" is shown to the user ${where}.` }
231 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'genui could not draw this UI; answer in text instead.' }))
232
233 on('tool.call', { tool: PREVIEW }, async ($, e) => ({ result: await previewTemplate($, e) })).catch(($, e, next) =>
234 next.called ? next(e) : { deny: 'genui could not preview this template.' },
235 )
236
237 on('ui.render', { component: 'ToolUse', props: { tool: TOOL } }, async ($, e, next) => {
238 const input = e.props.input
239 if (typeof input !== 'object' || input === null || e.props.isErrored || e.props.isInterrupted) return next(e)
240 const view = viewOf(e.props.tool_use_id, input as Record<string, unknown>)
241 if (view === undefined) return next(e)
242 const table = $.ui.resolve(e)
243 if (await isInPane($, view)) {
244 const { Text } = table
245 return (
246 <Text>
247 <Text bold color="claude">
248 ◆ {view.title}
249 </Text>
250 <Text dimColor> · shown in the side pane</Text>
251 </Text>
252 )
253 }
254
255 return renderView(e.surface, table, view, await formOf($, view), handlersFor($, view), await $.clock.now())
256 })
257
258 on('ui.render', { component: 'ToolResult', props: { tool: TOOL } }, async ($, e, next) => {
259 if (e.props.isErrored) return next(e)
260 const { Box } = $.ui.resolve(e)
261
262 return <Box />
263 })
264
265 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
266 const table = $.ui.resolve(e)
267 const { Text } = table
268 const shown = await read($, latest)
269 const now = await $.clock.now()
270 if (shown === null) return <Text dimColor>No generated UI yet. Try /genui list.</Text>
271 if (shown.kind === 'template') {
272 const name = shown.name
273 const run = await read($, { ...RUNS, id: name })
274 if (run === undefined) return <Text dimColor>Loading {name}…</Text>
275 const { view, problems } = templateView(run)
276 return renderTemplate(e.surface, table, {
277 run,
278 problems,
279 now,
280 onRetry: () => void reloadTemplate($, name).catch(() => undefined),
281 ...(view === undefined ? {} : { view, form: await formOf($, view), handlers: handlersFor($, view, name) }),
282 })
283 }
284 const view = drawnOf(shown)
285 if (view === undefined) return <Text dimColor>No generated UI yet.</Text>
286
287 return renderView(e.surface, table, view, await formOf($, view), handlersFor($, view), now)
288 })
289}
290hooks/bind.ts 205 lines1export type Scope = Record<string, unknown>
2
3/** What a missing value draws as inside text. */
4export const MISSING = '—'
5export const EACH_LIMIT = 200
6
7const WHOLE = /^\{\{([^{}]*)\}\}$/
8const ANY = /\{\{([^{}]*)\}\}/g
9const SPREAD = '__genuiSpread'
10const BLOCKED = new Set(['__proto__', 'constructor', 'prototype'])
11const TOTAL_LIMIT = 2000
12
13/** Fields that keep a number or boolean; every other bound field becomes text. */
14const TYPED_KEYS = new Set(['max', 'seconds', 'gap', 'padding', 'bold', 'dim', 'italic', 'border'])
15
16/** Reads `a.b[0].c` out of the scope; undefined where any step is missing. */
17export function lookup(scope: Scope, path: string): unknown {
18 const steps = path.trim().match(/[^.[\]]+/g) ?? []
19 if (steps.length === 0) return undefined
20 let value: unknown = scope
21 for (const step of steps) {
22 if (value === null || value === undefined) return undefined
23 if (BLOCKED.has(step)) return undefined
24 if (Array.isArray(value) && /^\d+$/.test(step)) value = value[Number(step)]
25 else if (typeof value === 'object' && Object.hasOwn(value, step)) value = (value as Record<string, unknown>)[step]
26 else return undefined
27 }
28 return value
29}
30
31const unescape = (arg: string) => arg.replace(/\\t/g, '\t').replace(/\\n/g, '\n').replace(/\\s/g, ' ')
32
33/** A field of a list item; `field` empty means the item itself. */
34const fieldOf = (item: unknown, field: string) => (field === '' ? item : lookup({ item }, `item.${field}`))
35
36/** `k=v` or `k!=v`, compared as text. */
37const fieldTest = (arg: string) => {
38 const match = /^([^!=]+)(!?=)(.*)$/.exec(arg)
39 if (match === null) throw new Error(`expected field=value, got "${arg}"`)
40 const [, field = '', op, want] = match
41 return (item: unknown) => {
42 const got = fieldOf(item, field.trim())
43 return (got === undefined || got === null ? '' : String(got)) === want ? op === '=' : op !== '='
44 }
45}
46
47const asList = (value: unknown, filter: string): unknown[] => {
48 if (Array.isArray(value)) return value
49 if (value === undefined || value === null) return []
50 throw new Error(`${filter}: expected a list`)
51}
52
53const isBlank = (value: unknown) => value === undefined || value === null || value === ''
54
55export const FILTERS: Record<string, (value: unknown, arg: string) => unknown> = {
56 length: value => (Array.isArray(value) || typeof value === 'string' ? value.length : 0),
57 first: value => asList(value, 'first')[0],
58 last: value => asList(value, 'last').at(-1),
59 count: (value, arg) => asList(value, 'count').filter(fieldTest(arg)).length,
60 where: (value, arg) => asList(value, 'where').filter(fieldTest(arg)),
61 map: (value, arg) => asList(value, 'map').map(item => fieldOf(item, arg)),
62 join: (value, arg) => asList(value, 'join').map(item => (isBlank(item) ? MISSING : String(item))).join(unescape(arg)),
63 sort: (value, arg) =>
64 [...asList(value, 'sort')].sort((a, b) => {
65 const x = fieldOf(a, arg)
66 const y = fieldOf(b, arg)
67 const xMissing = x === undefined || x === null
68 const yMissing = y === undefined || y === null
69 if (xMissing || yMissing) return xMissing === yMissing ? 0 : xMissing ? 1 : -1
70 if (typeof x === 'number' && typeof y === 'number') return x - y
71 const xs = String(x)
72 const ys = String(y)
73 return xs < ys ? -1 : xs > ys ? 1 : 0
74 }),
75 take: (value, arg) => {
76 if (!/^\d+$/.test(arg)) throw new Error('take: expected a whole number ≥ 0')
77 return asList(value, 'take').slice(0, Number(arg))
78 },
79 split: (value, arg) => (isBlank(value) ? [] : String(value).split(unescape(arg))),
80 trim: value => (isBlank(value) ? value : String(value).trim()),
81 upper: value => (isBlank(value) ? value : String(value).toUpperCase()),
82 default: (value, arg) => (isBlank(value) ? unescape(arg) : value),
83}
84
85/** Evaluates `path | filter | filter:arg`. */
86export function evaluate(expression: string, scope: Scope): unknown {
87 const [path = '', ...filters] = expression.split('|')
88 let value = lookup(scope, path)
89 for (const raw of filters) {
90 const at = raw.indexOf(':')
91 const name = (at < 0 ? raw : raw.slice(0, at)).trim()
92 const arg = at < 0 ? '' : raw.slice(at + 1).trim()
93 const filter = Object.hasOwn(FILTERS, name) ? FILTERS[name] : undefined
94 if (filter === undefined) throw new Error(`unknown filter "${name}"`)
95 value = filter(value, arg)
96 }
97 return value
98}
99
100const show = (value: unknown) =>
101 value === undefined || value === null ? MISSING : typeof value === 'object' ? JSON.stringify(value) : String(value)
102
103/** A whole `{{…}}` keeps its value's type; text with expressions in it interpolates. */
104export function fillString(text: string, scope: Scope): unknown {
105 const whole = WHOLE.exec(text)
106 if (whole !== null) return evaluate(whole[1] ?? '', scope)
107 return text.replace(ANY, (_, expression: string) => show(evaluate(expression, scope)))
108}
109
110/** An expression reading the form: only a press knows its values. */
111const FORM_EXPRESSION = /^\s*form\s*(?:[.[|]|$)/
112
113/** Braces in data put into a prompt, held as private-use characters so the press reads no bindings in them. */
114const OPEN_BRACE = '\uE000'
115const CLOSE_BRACE = '\uE001'
116const holdBraces = (text: string) => text.replace(/\{/g, OPEN_BRACE).replace(/\}/g, CLOSE_BRACE)
117
118/** A prompt's text once the press filled it: the data's braces back as they were. */
119export const restoreBraces = (text: string) => text.replace(/\uE000/g, '{').replace(/\uE001/g, '}')
120
121/**
122 * A template button's prompt: its data filled now (braces in it held, see
123 * restoreBraces), its `{{form.…}}` kept for the press to fill.
124 */
125function fillPrompt(text: string, scope: Scope, at: string): string {
126 try {
127 return text.replace(ANY, (match, expression: string) =>
128 FORM_EXPRESSION.test(expression) ? match : holdBraces(show(evaluate(expression, scope))),
129 )
130 } catch (error) {
131 throw new Error(`${at}: ${(error as Error).message}`)
132 }
133}
134
135const isTruthy = (value: unknown) =>
136 !(value === undefined || value === null || value === false || value === 0 || value === '' || (Array.isArray(value) && value.length === 0))
137
138const spread = (items: unknown[]) => ({ [SPREAD]: items })
139const spreadOf = (value: unknown): unknown[] | undefined =>
140 typeof value === 'object' && value !== null && SPREAD in value ? ((value as Record<string, unknown[]>)[SPREAD] ?? []) : undefined
141
142const asText = (value: unknown) => (typeof value === 'number' || typeof value === 'boolean' ? String(value) : value)
143
144type Budget = { used: number }
145
146function fill(node: unknown, scope: Scope, at: string, budget: Budget): unknown {
147 if (typeof node === 'string') {
148 try {
149 return fillString(node, scope)
150 } catch (error) {
151 throw new Error(`${at}: ${(error as Error).message}`)
152 }
153 }
154 if (Array.isArray(node)) {
155 return node.flatMap((child, i) => {
156 const filled = fill(child, scope, `${at}[${i}]`, budget)
157 const items = spreadOf(filled)
158 if (items === undefined && typeof child === 'string' && (filled === undefined || filled === null)) return [MISSING]
159 return (items ?? [filled]).filter(item => item !== undefined && item !== null).map(asText)
160 })
161 }
162 if (typeof node !== 'object' || node === null) return node
163 const record = node as Record<string, unknown>
164 if (record.type === 'each') {
165 if (typeof record.as !== 'string') throw new Error(`${at}.as: required string`)
166 if (record.as === 'form') throw new Error(`${at}.as: "form" is reserved`)
167 const items = fill(record.items, scope, `${at}.items`, budget)
168 const list = Array.isArray(items) ? items : []
169 const shown = list.slice(0, EACH_LIMIT)
170 budget.used += shown.length
171 if (budget.used > TOTAL_LIMIT) throw new Error(`tree: more than ${TOTAL_LIMIT} repeated nodes`)
172 const drawn = shown
173 .map((item, i) => fill(record.node, { ...scope, [record.as as string]: item }, `${at}.node[${i}]`, budget))
174 .flatMap(d => spreadOf(d) ?? [d])
175 .filter(d => d !== undefined && d !== null)
176 const isNodeList = typeof record.node === 'object' && record.node !== null && !Array.isArray(record.node)
177 if (list.length > EACH_LIMIT && isNodeList) drawn.push({ type: 'text', text: `+${list.length - EACH_LIMIT} more`, dim: true })
178 return spread(drawn)
179 }
180 if (record.type === 'when') {
181 const isMet = isTruthy(fill(record.test, scope, `${at}.test`, budget))
182 const branch = isMet ? record.node : record.else
183 return branch === undefined ? spread([]) : fill(branch, scope, `${at}.${isMet ? 'node' : 'else'}`, budget)
184 }
185 const out: Record<string, unknown> = {}
186 for (const [key, value] of Object.entries(record)) {
187 const filled =
188 record.type === 'button' && key === 'prompt' && typeof value === 'string'
189 ? fillPrompt(value, scope, `${at}.${key}`)
190 : fill(value, scope, `${at}.${key}`, budget)
191 if (spreadOf(filled) !== undefined) throw new Error(`${at}.${key}: each/when must be inside a list`)
192 if (filled === undefined || filled === null) continue
193 const keepsType = TYPED_KEYS.has(key) || (key === 'value' && record.type === 'progress')
194 out[key] = keepsType ? filled : asText(filled)
195 }
196 return out
197}
198
199/** Fills a template tree; a root `each` or `when` that spreads becomes a box. */
200export function fillRoot(tree: unknown, scope: Scope): unknown {
201 const filled = fill(tree, scope, 'tree', { used: 0 })
202 const items = spreadOf(filled)
203 return items === undefined ? filled : { type: 'box', children: items }
204}
205hooks/render.tsx 258 lines1import type { ElementTable, RenderChildren, RenderSurface } from 'claude-code'
2
3import type { GenuiForm, GenuiNode, GenuiTemplateRun, GenuiView } from '../types'
4import { countdownEnd } from './schema'
5
6export type Handlers = {
7 onValue: (id: string, value: string) => void
8 onButton: (node: Extract<GenuiNode, { type: 'button' }>) => void | Promise<void>
9}
10
11const BAR = 20
12
13const progressBar = (value: number, max: number) => {
14 const share = max > 0 ? Math.min(1, Math.max(0, value / max)) : 0
15 const full = Math.round(share * BAR)
16 return `${'█'.repeat(full)}${'░'.repeat(BAR - full)} ${Math.round(share * 100)}%`
17}
18
19const pad = (n: number) => String(n).padStart(2, '0')
20
21/** `m:ss`, or `h:mm:ss` past an hour. */
22export const duration = (ms: number) => {
23 const total = Math.max(0, Math.ceil(ms / 1000))
24 const h = Math.floor(total / 3600)
25 const m = Math.floor((total % 3600) / 60)
26 const sec = total % 60
27 return h > 0 ? `${h}:${pad(m)}:${pad(sec)}` : `${m}:${pad(sec)}`
28}
29
30export const timeOfDay = (at: number) => {
31 const d = new Date(at)
32 return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
33}
34
35const markdownTable = (columns: string[], rows: string[][]) => {
36 const cell = (text: string) => text.replace(/\|/g, '\\|').replace(/\n/g, ' ')
37 const line = (cells: string[]) => `| ${columns.map((_, i) => cell(cells[i] ?? '')).join(' | ')} |`
38 return [line(columns), `| ${columns.map(() => '---').join(' | ')} |`, ...rows.map(line)].join('\n')
39}
40
41export type ViewOptions = {
42 /** A template's view: its refresh, run and prompt buttons stay live once the form closes. */
43 liveActions?: boolean
44}
45
46/** What a template's buttons do beside answering the form: never locked by its closing. */
47const LIVE_ACTIONS = new Set(['refresh', 'run', 'prompt'])
48
49const STATUS_LINE: Record<GenuiForm['status'], string> = {
50 open: '',
51 submitted: '✓ Submitted',
52 cancelled: '✗ Cancelled',
53}
54
55/**
56 * Draws a view with the surface's own elements. Fields are live while the
57 * form is open and show their answers once it closes; a surface with no
58 * fields (mobile) shows the field's value as text. With `liveActions` (a
59 * template), refresh, run and prompt buttons stay live after it closes.
60 */
61export function renderView(surface: RenderSurface, table: ElementTable, view: GenuiView, form: GenuiForm, handlers: Handlers, now: number, options: ViewOptions = {}) {
62 const startedAt = form.startedAt ?? now
63 const { Box, Text, Button, Markdown, Code, Link } = table
64 const isOpen = form.status === 'open'
65 const hasFields = surface !== 'mobile' && 'Input' in table && 'Select' in table
66
67 const field = (label: string | undefined, value: string) => (
68 <Box flexDirection="row" gap={1}>
69 {label !== undefined && <Text bold>{label}:</Text>}
70 <Text dimColor={!isOpen}>{value === '' ? '—' : value}</Text>
71 </Box>
72 )
73
74 const draw = (node: GenuiNode, path: string): RenderChildren => {
75 switch (node.type) {
76 case 'box':
77 return (
78 <Box
79 key={path}
80 flexDirection={node.direction ?? 'column'}
81 gap={node.gap ?? (node.direction === 'row' ? 2 : 0)}
82 padding={node.padding ?? (node.border ? 1 : 0)}
83 paddingY={node.border ? 0 : undefined}
84 borderStyle={node.border ? 'round' : undefined}
85 borderDimColor={node.border ? true : undefined}
86 >
87 {node.title !== undefined && <Text bold>{node.title}</Text>}
88 {(node.children ?? []).map((child, i) => draw(child, `${path}.${i}`))}
89 </Box>
90 )
91 case 'text':
92 return (
93 <Text bold={node.bold} dimColor={node.dim} italic={node.italic} color={node.color}>
94 {node.text}
95 </Text>
96 )
97 case 'markdown':
98 return <Markdown text={node.text} />
99 case 'code':
100 return <Code source={node.source} language={node.language} />
101 case 'link':
102 return <Link href={node.href}>{node.label ?? node.href}</Link>
103 case 'divider':
104 return <Text dimColor>{'─'.repeat(BAR + 5)}</Text>
105 case 'table':
106 return <Markdown text={markdownTable(node.columns, node.rows)} />
107 case 'progress':
108 return (
109 <Box flexDirection="row" gap={1}>
110 {node.label !== undefined && <Text>{node.label}</Text>}
111 <Text color="success">{progressBar(node.value, node.max ?? 100)}</Text>
112 </Box>
113 )
114 case 'kv':
115 return (
116 <Box flexDirection="column">
117 {node.items.map(item => (
118 <Box flexDirection="row" gap={1}>
119 <Text bold>{item.key}</Text>
120 <Text>{item.value}</Text>
121 </Box>
122 ))}
123 </Box>
124 )
125 case 'input': {
126 const value = form.values[node.id] ?? node.default ?? ''
127 if (!isOpen || !hasFields) return field(node.label, value)
128 const { Input } = table
129 return (
130 <Input
131 key={`input:${node.id}`}
132 label={node.label}
133 placeholder={node.placeholder}
134 value={value}
135 onInput={text => handlers.onValue(node.id, text)}
136 onSubmit={text => handlers.onValue(node.id, text)}
137 />
138 )
139 }
140 case 'select': {
141 const value = form.values[node.id] ?? node.default ?? node.options[0]?.value ?? ''
142 if (!isOpen || !hasFields) {
143 const chosen = node.options.find(option => option.value === value)
144 return field(node.label, chosen?.label ?? value)
145 }
146 const { Select } = table
147 return (
148 <Select
149 key={`select:${node.id}`}
150 label={node.label}
151 options={node.options}
152 value={value}
153 onSelect={picked => handlers.onValue(node.id, picked)}
154 />
155 )
156 }
157 case 'countdown': {
158 const end = countdownEnd(node, startedAt)
159 const left = end - now
160 const isDone = left <= 0
161 return (
162 <Box flexDirection="column">
163 <Box flexDirection="row" gap={1}>
164 {node.label !== undefined && <Text bold>{node.label}</Text>}
165 <Text bold color={isDone ? 'success' : 'claude'}>
166 {isDone ? `✓ ${node.doneText ?? "Time's up"}` : duration(left)}
167 </Text>
168 </Box>
169 <Text color={isDone ? 'success' : undefined} dimColor={!isDone}>
170 {progressBar(now - startedAt, Math.max(1, end - startedAt))}
171 </Text>
172 <Text dimColor>ends {timeOfDay(end)}</Text>
173 </Box>
174 )
175 }
176 case 'stopwatch':
177 return (
178 <Box flexDirection="row" gap={1}>
179 {node.label !== undefined && <Text bold>{node.label}</Text>}
180 <Text>{duration(now - startedAt)}</Text>
181 </Box>
182 )
183 case 'clock':
184 return (
185 <Box flexDirection="row" gap={1}>
186 {node.label !== undefined && <Text bold>{node.label}</Text>}
187 <Text>{timeOfDay(now)}</Text>
188 </Box>
189 )
190 case 'button':
191 if (!isOpen && !(options.liveActions === true && LIVE_ACTIONS.has(node.action ?? 'action'))) return <Text dimColor={form.pressed !== node.id}>[ {node.label} ]</Text>
192 return (
193 <Button
194 key={`button:${node.id}`}
195 label={node.label}
196 variant={node.variant ?? (node.action === 'submit' ? 'primary' : undefined)}
197 onPress={() => handlers.onButton(node)}
198 />
199 )
200 }
201 }
202
203 return (
204 <Box flexDirection="column">
205 <Text bold color="claude">
206 ◆ {view.title}
207 </Text>
208 {draw(view.tree, 'root')}
209 {!isOpen && <Text dimColor>{STATUS_LINE[form.status]}</Text>}
210 </Box>
211 )
212}
213
214export type TemplateFrame = {
215 run: GenuiTemplateRun
216 view?: GenuiView
217 form?: GenuiForm
218 problems: string[]
219 now: number
220 handlers?: Handlers
221 onRetry: () => void
222}
223
224/**
225 * A template run in the pane: its filled view (or its title while there is
226 * none), a status line, and a box of the problems that stand, with Retry.
227 */
228export function renderTemplate(surface: RenderSurface, table: ElementTable, frame: TemplateFrame) {
229 const { Box, Text, Button } = table
230 const { run, view, form, handlers, now } = frame
231 const problems = [...run.errors, ...frame.problems]
232 const parts = [`${run.name} (${run.origin})`]
233 if (run.isLoading) parts.push('loading…')
234 else if (run.updatedAt !== undefined) parts.push(`updated ${timeOfDay(run.updatedAt)} · ${((run.durationMs ?? 0) / 1000).toFixed(1)}s`)
235 if ((run.template.refreshSeconds ?? 0) > 0) parts.push(`every ${Math.max(5, run.template.refreshSeconds ?? 0)}s`)
236
237 return (
238 <Box flexDirection="column">
239 {view !== undefined && form !== undefined && handlers !== undefined ? (
240 renderView(surface, table, view, form, handlers, now, { liveActions: true })
241 ) : (
242 <Text bold color="claude">
243 ◆ {run.template.title}
244 </Text>
245 )}
246 <Text dimColor>{parts.join(' · ')}</Text>
247 {problems.length > 0 && (
248 <Box flexDirection="column" borderStyle="round" borderColor="error" paddingX={1}>
249 {problems.map(problem => (
250 <Text color="error">{problem}</Text>
251 ))}
252 <Button key="genui-retry" label="Retry" onPress={frame.onRetry} />
253 </Box>
254 )}
255 </Box>
256 )
257}
258hooks/runner.ts 379 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunInit, ProcessRunResult } from 'claude-code'
3
4import type { GenuiJson, GenuiTemplate, GenuiTemplateOrigin, GenuiTemplateRun, GenuiTemplateSource } from '../types'
5import { BUILTINS } from './builtins'
6import { DEFAULT_TIMEOUT_MS, NAME, fingerprint, maxTimeout, parseOutput, resolveCwd, templateView, validateTemplate } from './template-file'
7
8const RUNS = { plugin: 'genui', key: 'runs' } as const
9const FORMS = { plugin: 'genui', key: 'forms' } as const
10const latest = atom({ plugin: 'genui', key: 'latest' } as const, null)
11const PANE = 'genui'
12const MIN_REFRESH_SECONDS = 5
13const STDERR_LINES = 20
14/** A failure this soon after the start is a program that did not start. */
15const START_FAILURE_MS = 2_000
16/** Characters cmd.exe reads as syntax: an argument holding one is never retried through it. */
17const SHELL_CHARS = /[&|<>^%!"()\r\n]/
18const TIMEOUT_MESSAGE = /timed? ?out|still running/i
19/** Names `/genui` reads as subcommands: no template can take them. */
20export const RESERVED_NAMES: readonly string[] = ['list', 'init', 'refresh', 'open']
21
22export type Located = { name: string; origin: GenuiTemplateOrigin; path?: string; raw?: unknown; problem?: string }
23
24const slash = (path: string) => path.replace(/\\/g, '/')
25
26const isTemplateName = (name: string) => NAME.test(name) && !RESERVED_NAMES.includes(name)
27
28async function sessionRoot($: EngineInterface) {
29 return slash(await $.session.root())
30}
31
32async function templateDirs($: EngineInterface): Promise<[GenuiTemplateOrigin, string][]> {
33 const home = slash((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')) ?? '')
34 const dirs: [GenuiTemplateOrigin, string][] = [['project', `${await sessionRoot($)}/.claude/genui`]]
35 if (home !== '') dirs.push(['user', `${home}/.claude/genui`])
36 return dirs
37}
38
39/** Finds a template: the project's, then the user's, then a built-in one. */
40export async function locate($: EngineInterface, name: string): Promise<Located | undefined> {
41 if (!isTemplateName(name)) return undefined
42 for (const [origin, dir] of await templateDirs($)) {
43 const path = `${dir}/${name}.json`
44 if (!(await $.fs.exists(path).catch(() => false))) continue
45 try {
46 return { name, origin, path, raw: JSON.parse(await $.fs.read(path)) }
47 } catch (error) {
48 return { name, origin, path, problem: `${path}: ${(error as Error).message}` }
49 }
50 }
51 return Object.hasOwn(BUILTINS, name) ? { name, origin: 'builtin', raw: BUILTINS[name] } : undefined
52}
53
54/** Every template a name reaches, the one that wins for each name. */
55export async function listTemplates($: EngineInterface): Promise<{ name: string; origin: GenuiTemplateOrigin; title: string }[]> {
56 const found = new Map<string, { name: string; origin: GenuiTemplateOrigin; title: string }>()
57 for (const [origin, dir] of await templateDirs($)) {
58 if (!(await $.fs.exists(dir).catch(() => false))) continue
59 for (const entry of await $.fs.list(dir)) {
60 if (entry.kind !== 'file' || !entry.name.endsWith('.json')) continue
61 const name = entry.name.slice(0, -'.json'.length)
62 if (!isTemplateName(name) || found.has(name)) continue
63 let title = '(unreadable)'
64 try {
65 const raw: unknown = JSON.parse(await $.fs.read(`${dir}/${entry.name}`))
66 if (typeof raw === 'object' && raw !== null && typeof (raw as { title?: unknown }).title === 'string') title = (raw as { title: string }).title
67 } catch {
68 // listed as unreadable; opening it says why
69 }
70 found.set(name, { name, origin, title })
71 }
72 }
73 for (const [name, template] of Object.entries(BUILTINS)) {
74 if (!found.has(name)) found.set(name, { name, origin: 'builtin', title: template.title })
75 }
76 return [...found.values()]
77}
78
79/** How much of each prompt the approval dialog shows. */
80const PROMPT_SHOWN = 300
81
82/** Every button `prompt` string anywhere in a template's raw tree, each once, in order. */
83export function promptsOf(tree: unknown): string[] {
84 const found: string[] = []
85 const walk = (node: unknown): void => {
86 if (Array.isArray(node)) return node.forEach(walk)
87 if (typeof node !== 'object' || node === null) return
88 for (const [key, value] of Object.entries(node)) {
89 if (key === 'prompt' && typeof value === 'string') {
90 if (!found.includes(value)) found.push(value)
91 } else walk(value)
92 }
93 }
94 walk(tree)
95 return found
96}
97
98export type OpenOptions = {
99 /** Claude started it (preview_template): ask whatever the origin. */
100 requireApproval?: boolean
101 /** A reload of the run already shown (Retry, refresh): the pane is not opened again. */
102 isReload?: boolean
103}
104
105/**
106 * A template's commands and prompt texts run once the person approved exactly
107 * them under this root: a project's or a preview's always, a user's or a
108 * built-in's only when Claude started it.
109 */
110async function ensureApproved($: EngineInterface, root: string, located: Located, template: GenuiTemplate, options: OpenOptions): Promise<boolean> {
111 if ((located.origin === 'user' || located.origin === 'builtin') && options.requireApproval !== true) return true
112 const prompts = promptsOf(template.tree)
113 const key = `approved:${root}|${located.name}|${await fingerprint({ sources: template.sources, prompts })}`
114 if ((await $.store.get(key)) === true) return true
115 const commands = Object.entries(template.sources)
116 .map(([name, source]) => ` ${name}: ${JSON.stringify(source.argv)}${source.cwd === undefined ? '' : ` in ${resolveCwd(root, source.cwd)}`}`)
117 .join('\n')
118 const shownPrompts = prompts.map(prompt => (prompt.length > PROMPT_SHOWN ? `${prompt.slice(0, PROMPT_SHOWN)}…` : prompt))
119 const promptLine = prompts.length === 0 ? '' : `\nIts buttons send Claude:\n prompts: ${JSON.stringify(shownPrompts)}`
120 let answer: string
121 try {
122 answer = await $.ui.ask(`Template "${located.name}" (${located.origin}) runs:\n${commands}${promptLine}\nRun these commands?`, {
123 options: ['Run', 'Cancel'],
124 header: 'genui',
125 })
126 } catch {
127 return false
128 }
129 if (answer !== 'Run') return false
130 await $.store.set(key, true)
131 return true
132}
133
134/** The first lines of a command's stderr. */
135const stderrHead = (stderr: string) => stderr.split(/\r?\n/).slice(0, STDERR_LINES).join('\n').trim()
136
137/**
138 * Runs argv. On Windows, a bare program (npx is npx.cmd) or a .cmd/.bat one
139 * that fails to start is tried once more through cmd.exe, unless an argument
140 * holds a shell character, the failure was a timeout, or it came late.
141 */
142async function runArgv($: EngineInterface, argv: readonly string[], init: ProcessRunInit) {
143 const startedAt = await $.clock.now()
144 try {
145 return await $.process.run(argv, init)
146 } catch (error) {
147 const program = argv[0] ?? ''
148 const message = error instanceof Error ? error.message : String(error)
149 const isQuick = (await $.clock.now()) - startedAt < START_FAILURE_MS
150 const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS
151 const isRetryable = !/[.\\/]/.test(program) || /\.(cmd|bat)$/i.test(program)
152 if (!isQuick || timeoutMs <= START_FAILURE_MS || TIMEOUT_MESSAGE.test(message) || !isRetryable) throw error
153 if ((await $.env.get('OS')) !== 'Windows_NT') throw error
154 if (argv.some(arg => SHELL_CHARS.test(arg))) {
155 throw new Error(`${message} (on Windows, ${program} may need cmd.exe; arguments with shell characters are not retried)`)
156 }
157 const shell = (await $.env.get('ComSpec')) ?? 'C:/Windows/System32/cmd.exe'
158 let retried: ProcessRunResult
159 try {
160 retried = await $.process.run([shell, '/d', '/c', ...argv], init)
161 } catch {
162 throw error
163 }
164 if (retried.exitCode !== 0 && retried.stdout.trim() === '') {
165 const stderr = stderrHead(retried.stderr)
166 throw new Error(`${message}; through cmd.exe it exited ${retried.exitCode}${stderr === '' ? '' : `\n${stderr}`}`)
167 }
168 return retried
169 }
170}
171
172async function runSource($: EngineInterface, root: string, name: string, source: GenuiTemplateSource): Promise<{ value?: GenuiJson; error?: string }> {
173 try {
174 const result = await runArgv($, source.argv, {
175 cwd: resolveCwd(root, source.cwd),
176 timeoutMs: source.timeoutMs ?? DEFAULT_TIMEOUT_MS,
177 })
178 const stderr = result.exitCode === 0 ? '' : stderrHead(result.stderr)
179 if (result.exitCode !== 0 && source.allowFailure !== true) {
180 return { error: `${name}: exited ${result.exitCode}${stderr === '' ? '' : `\n${stderr}`}` }
181 }
182 const parsed = parseOutput(name, result.stdout, source.parse)
183 if (parsed.error === undefined || result.exitCode === 0) return parsed
184 const why = parsed.error.startsWith(`${name}: `) ? parsed.error.slice(name.length + 2) : parsed.error
185 return { error: `${name}: exited ${result.exitCode}, ${why}${stderr === '' ? '' : `\n${stderr}`}` }
186 } catch (error) {
187 return { error: `${name}: ${(error as Error).message}` }
188 }
189}
190
191/** The root a run's sources run in; a run stored before `root` existed falls back to the session's. */
192async function rootOf($: EngineInterface, run: GenuiTemplateRun): Promise<string> {
193 return (run as Partial<GenuiTemplateRun>).root ?? (await sessionRoot($))
194}
195
196/** The run with no loading mark. */
197const settled = ({ loadingSince: _, ...run }: GenuiTemplateRun): GenuiTemplateRun => ({ ...run, isLoading: false })
198
199/** Whether a run is loading, and not for so long that the load must have died. */
200const isBusy = (run: GenuiTemplateRun, now: number) => run.isLoading && now - (run.loadingSince ?? 0) < maxTimeout(run.template) + 10_000
201
202/** Only the data of sources the template still has. */
203const keptData = (data: GenuiTemplateRun['data'], template: GenuiTemplate): Record<string, GenuiJson> =>
204 Object.fromEntries(Object.entries(data ?? {}).filter(([name]) => Object.hasOwn(template.sources, name)))
205
206export type RefreshOptions = { resetForm?: boolean }
207
208/**
209 * Runs every source that is not manual and keeps the data; resets the
210 * template's form when asked. Answers false when it did not run: no such
211 * run, or a refresh of it still in flight.
212 */
213export async function refreshTemplate($: EngineInterface, name: string, options: RefreshOptions = {}): Promise<boolean> {
214 const ref = { ...RUNS, id: name }
215 const run = await read($, ref)
216 if (run === undefined) return false
217 const startedAt = await $.clock.now()
218 if (isBusy(run, startedAt)) return false
219 await update($, ref, current => ({ ...(current ?? run), isLoading: true, loadingSince: startedAt }))
220 const root = await rootOf($, run)
221 const sources = Object.entries(run.template.sources).filter(([, source]) => source.manual !== true)
222 const outcomes = await Promise.all(sources.map(([sourceName, source]) => runSource($, root, sourceName, source)))
223 const data: Record<string, GenuiJson> = {}
224 const errors: string[] = []
225 sources.forEach(([sourceName], i) => {
226 const outcome = outcomes[i]
227 if (outcome?.error !== undefined) errors.push(outcome.error)
228 else data[sourceName] = outcome?.value ?? null
229 })
230 const doneAt = await $.clock.now()
231 const hasData = Object.keys(data).length > 0
232 await update($, ref, current => {
233 const base = current ?? run
234 // No data until a source has succeeded once: a first failure shows only its error.
235 const kept = base.data === undefined && !hasData ? undefined : keptData({ ...base.data, ...data }, base.template)
236 return {
237 ...settled(base),
238 errors,
239 ...(kept === undefined ? {} : { data: kept }),
240 updatedAt: doneAt,
241 durationMs: doneAt - startedAt,
242 }
243 })
244 if (options.resetForm === true) await $.state.set({ ...FORMS, id: `template:${name}` }, { status: 'open', values: {} })
245 return true
246}
247
248/** A `run` button: runs one manual source, then refreshes the rest (the form kept). */
249export async function runManualSource($: EngineInterface, name: string, sourceName: string): Promise<void> {
250 const ref = { ...RUNS, id: name }
251 const run = await read($, ref)
252 if (run === undefined) return
253 const source = Object.hasOwn(run.template.sources, sourceName) ? run.template.sources[sourceName] : undefined
254 if (source === undefined) {
255 await update($, ref, current => ({ ...(current ?? run), errors: [`run: no source named "${sourceName}"`] }))
256 return
257 }
258 const startedAt = await $.clock.now()
259 if (isBusy(run, startedAt)) {
260 $.ui.toast(`genui: "${name}" is still running`)
261 return
262 }
263 await update($, ref, current => ({ ...(current ?? run), isLoading: true, loadingSince: startedAt }))
264 const outcome = await runSource($, await rootOf($, run), sourceName, source)
265 const error = outcome.error
266 await update($, ref, current => ({ ...settled(current ?? run), ...(error === undefined ? {} : { errors: [error] }) }))
267 if (error !== undefined) return
268 await refreshTemplate($, name, { resetForm: false })
269}
270
271export type Opened = {
272 text: string
273 problems: string[]
274 /** Whether the run was stored and shown; false when the template was unreadable, invalid or declined. */
275 isShown: boolean
276 /** A refresh of it was still in flight, so this one did not run. */
277 isStillRunning?: boolean
278}
279
280/** Validates, approves, shows and runs a template; answers a line for the person and the problems that stand. */
281export async function openTemplate($: EngineInterface, located: Located, options: OpenOptions = {}): Promise<Opened> {
282 if (located.problem !== undefined) return { text: located.problem, problems: [located.problem], isShown: false }
283 const invalid = validateTemplate(located.raw)
284 if (invalid.length > 0) {
285 return {
286 text: `Template "${located.name}"${located.path === undefined ? '' : ` (${located.path})`} is not valid:\n${invalid.map(p => `- ${p}`).join('\n')}`,
287 problems: invalid,
288 isShown: false,
289 }
290 }
291 const template = located.raw as GenuiTemplate
292 const root = await sessionRoot($)
293 if (!(await ensureApproved($, root, located, template, options))) {
294 return { text: `Template "${located.name}" was not run.`, problems: ['not approved'], isShown: false }
295 }
296 const ref = { ...RUNS, id: located.name }
297 const previous = await read($, ref)
298 const isSameSources = previous !== undefined && (await fingerprint(previous.template.sources)) === (await fingerprint(template.sources))
299 const run: GenuiTemplateRun = {
300 name: located.name,
301 origin: located.origin,
302 root,
303 template,
304 errors: [],
305 isLoading: isSameSources && previous?.isLoading === true,
306 ...(isSameSources && previous?.loadingSince !== undefined ? { loadingSince: previous.loadingSince } : {}),
307 ...(located.path === undefined ? {} : { path: located.path }),
308 ...(isSameSources && previous?.data !== undefined ? { data: previous.data } : {}),
309 }
310 await $.state.set(ref, run)
311 await update($, latest, () => ({ kind: 'template' as const, name: located.name }))
312 if (options.isReload !== true) await $.ui.open({ id: PANE, title: template.title }).catch(() => undefined)
313 if (!(await refreshTemplate($, located.name, { resetForm: true }))) {
314 return { text: stillRunning(template.title), problems: [], isShown: true, isStillRunning: true }
315 }
316 const after = (await read($, ref)) ?? run
317 const problems = [...after.errors, ...templateView(after).problems]
318 const took = ((after.durationMs ?? 0) / 1000).toFixed(1)
319 const text =
320 problems.length === 0
321 ? `"${template.title}" updated in ${took}s.`
322 : `"${template.title}": ${problems.length} problem(s):\n${problems.map(p => `- ${p}`).join('\n')}`
323 return { text, problems, isShown: true }
324}
325
326const stillRunning = (title: string) => `"${title}" is still running; the pane updates when it finishes.`
327
328/**
329 * Retry, a refresh button and `/genui refresh`: the template read again from
330 * its file and opened (asking again only if its commands or prompts changed);
331 * a preview, which has no file, runs its stored template again.
332 */
333export async function reloadTemplate($: EngineInterface, name: string): Promise<string> {
334 const ref = { ...RUNS, id: name }
335 const run = await read($, ref)
336 if (run === undefined) return `No run of "${name}".`
337 if (run.origin === 'preview') {
338 return (await refreshTemplate($, name, { resetForm: true })) ? `Refreshed "${name}".` : stillRunning(run.template.title)
339 }
340 const located = await locate($, name)
341 const opened = located === undefined ? undefined : await openTemplate($, located, { isReload: true })
342 if (opened === undefined || !opened.isShown) {
343 const why = opened?.text ?? `No template "${name}" any more.`
344 await update($, ref, current => ({ ...(current ?? run), errors: [why] }))
345 return why
346 }
347 return opened.isStillRunning === true ? opened.text : `Refreshed "${name}".`
348}
349
350/** `/genui init <name>`: copies a built-in template into the project. */
351export async function initTemplate($: EngineInterface, name: string): Promise<string> {
352 const builtin = NAME.test(name) && Object.hasOwn(BUILTINS, name) ? BUILTINS[name] : undefined
353 if (builtin === undefined) return `No built-in template "${name}". Built-in: ${Object.keys(BUILTINS).join(', ')}.`
354 const path = `${await sessionRoot($)}/.claude/genui/${name}.json`
355 if (await $.fs.exists(path).catch(() => false)) return `${path} already exists.`
356 await $.fs.write(path, `${JSON.stringify(builtin, null, 2)}\n`)
357 return `Wrote ${path}. Edit it, then open it with /genui ${name}.`
358}
359
360/** The preview tool: a template object (approved like a project's) or a saved one by name. */
361export async function previewTemplate($: EngineInterface, input: Record<string, unknown>): Promise<string> {
362 let located: Located | undefined
363 if (input.template !== undefined) located = { name: '_preview', origin: 'preview', raw: input.template }
364 else if (typeof input.name === 'string') located = await locate($, input.name)
365 if (located === undefined) return 'Give a template object, or the name of a saved template.'
366 return (await openTemplate($, located, { requireApproval: true })).text
367}
368
369/** One second of a template on screen: refreshes it once its period has passed (the form kept). */
370export async function tickTemplate($: EngineInterface, name: string): Promise<void> {
371 const run = await read($, { ...RUNS, id: name })
372 const every = run?.template.refreshSeconds ?? 0
373 if (run === undefined || every <= 0) return
374 const panes = await $.ui.panes().catch(() => [])
375 if (!panes.some(pane => pane.id === PANE && pane.isPlaced)) return
376 if ((await $.clock.now()) - (run.updatedAt ?? 0) < Math.max(MIN_REFRESH_SECONDS, every) * 1000) return
377 await refreshTemplate($, name, { resetForm: false })
378}
379hooks/schema.ts 245 lines1import type { GenuiNode } from '../types'
2
3export const MAX_DEPTH = 12
4export const MAX_NODES = 300
5
6const NODE_DOC = `Each node is a JSON object with a "type":
7- box: { direction?: "row"|"column", gap?, padding?, border?: boolean, title?, children?: Node[] }
8- text: { text, bold?, dim?, italic?, color? (a color name or #hex) }
9- markdown: { text } (headings, lists, tables, code fences, links)
10- code: { source, language? }
11- link: { href, label? }
12- divider: {}
13- table: { columns: string[], rows: string[][] }
14- progress: { value, max? (default 100), label? }
15- kv: { items: [{ key, value }] }
16- input: { id, label?, placeholder?, default? } (a text field)
17- select: { id, label?, options: [{ value, label? }], default? } (pick one)
18- button: { id, label, variant?: "primary"|"secondary", action?: "submit"|"cancel"|"action"|"prompt", prompt? } ("prompt" sends its prompt text to Claude as the user's message without closing the form; {{form.<id>}} in it is that field's value)
19Live nodes, which the mod itself redraws every second (no script needed):
20- countdown: { id, seconds? | until? (ISO time), label?, doneText? } (remaining time and a bar; a toast and a notification when it ends)
21- stopwatch: { label? } (time since the UI was drawn)
22- clock: { label? } (the current local time)
23Ids of input, select, button and countdown must be unique within the tree.`
24
25export const TOOL_DESCRIPTION = `Draw a generative UI for the user, in the side pane (or inline in the transcript with placement "inline"): cards, layouts, tables, progress, markdown, code, and forms.
26
27Use it when a visual layout or a form communicates better than prose: summaries, dashboards, comparisons, choices with several fields, gathering structured input, or live things like timers.
28
29Placement: "pane" (the default) draws it in the side pane, with a one-line pointer in the transcript; "inline" draws it in the transcript. Use "pane" for anything live or long-lived (timers, dashboards), "inline" for one-off answers.
30
31${NODE_DOC}
32
33Forms: set "awaitResponse": true when the UI holds inputs, selects or buttons whose answers you need. After calling the tool with awaitResponse, END YOUR TURN immediately without further text or tool calls: the user's answers arrive as their next message, as <genui-response id="..." action="submit|cancel|<button id>">{json of field values}</genui-response>. Buttons with action "submit" or "cancel" (or "action", which sends the button's id) close the form.`
34
35export const INPUT_SCHEMA = {
36 type: 'object',
37 properties: {
38 title: { type: 'string', description: 'A short title shown above the UI.' },
39 tree: { type: 'object', description: 'The root node of the UI. ' + NODE_DOC },
40 placement: {
41 type: 'string',
42 enum: ['pane', 'inline'],
43 description: 'Where to draw it: "pane" (default, the side pane) or "inline" (the transcript).',
44 },
45 awaitResponse: {
46 type: 'boolean',
47 description: 'True when the UI is a form whose answers you need; end your turn after the call.',
48 },
49 },
50 required: ['title', 'tree'],
51} as const
52
53type Problems = string[]
54
55const isObject = (value: unknown): value is Record<string, unknown> =>
56 typeof value === 'object' && value !== null && !Array.isArray(value)
57
58const isString = (value: unknown): value is string => typeof value === 'string'
59
60const optional = (node: Record<string, unknown>, key: string, check: (v: unknown) => boolean, kind: string, at: string, problems: Problems) => {
61 if (node[key] !== undefined && !check(node[key])) problems.push(`${at}.${key}: expected ${kind}`)
62}
63
64const required = (node: Record<string, unknown>, key: string, check: (v: unknown) => boolean, kind: string, at: string, problems: Problems) => {
65 if (!check(node[key])) problems.push(`${at}.${key}: required ${kind}`)
66}
67
68const isNumber = (v: unknown) => typeof v === 'number' && Number.isFinite(v)
69const isBoolean = (v: unknown) => typeof v === 'boolean'
70const isStringArray = (v: unknown) => Array.isArray(v) && v.every(isString)
71
72/**
73 * Checks a tree the model sent and answers the problems, each led by the
74 * path of the node it is about, so the model can fix the call; empty when
75 * the tree is good.
76 */
77export type ValidateOptions = { isTemplate?: boolean }
78
79export function validate(tree: unknown, options: ValidateOptions = {}): Problems {
80 const problems: Problems = []
81 const ids = new Set<string>()
82 let count = 0
83
84 const visit = (node: unknown, at: string, depth: number) => {
85 count += 1
86 if (count > MAX_NODES) {
87 if (count === MAX_NODES + 1) problems.push(`tree: more than ${MAX_NODES} nodes`)
88 return
89 }
90 if (depth > MAX_DEPTH) {
91 problems.push(`${at}: nested deeper than ${MAX_DEPTH}`)
92 return
93 }
94 if (!isObject(node)) {
95 problems.push(`${at}: expected a node object`)
96 return
97 }
98 const claimId = () => {
99 if (!isString(node.id) || node.id.length === 0) return problems.push(`${at}.id: required non-empty string`)
100 if (ids.has(node.id)) problems.push(`${at}.id: "${node.id}" is used twice`)
101 ids.add(node.id)
102 }
103 switch (node.type) {
104 case 'box':
105 optional(node, 'direction', v => v === 'row' || v === 'column', '"row" or "column"', at, problems)
106 optional(node, 'gap', isNumber, 'a number', at, problems)
107 optional(node, 'padding', isNumber, 'a number', at, problems)
108 optional(node, 'border', isBoolean, 'a boolean', at, problems)
109 optional(node, 'title', isString, 'a string', at, problems)
110 if (node.children !== undefined) {
111 if (!Array.isArray(node.children)) problems.push(`${at}.children: expected an array`)
112 else node.children.forEach((child, i) => visit(child, `${at}.children[${i}]`, depth + 1))
113 }
114 return
115 case 'text':
116 required(node, 'text', isString, 'string', at, problems)
117 for (const flag of ['bold', 'dim', 'italic']) optional(node, flag, isBoolean, 'a boolean', at, problems)
118 optional(node, 'color', isString, 'a string', at, problems)
119 return
120 case 'markdown':
121 required(node, 'text', isString, 'string', at, problems)
122 return
123 case 'code':
124 required(node, 'source', isString, 'string', at, problems)
125 optional(node, 'language', isString, 'a string', at, problems)
126 return
127 case 'link':
128 required(node, 'href', v => isString(v) && /^(https?|file):/.test(v), 'http(s) or file URL', at, problems)
129 optional(node, 'label', isString, 'a string', at, problems)
130 return
131 case 'divider':
132 return
133 case 'table':
134 required(node, 'columns', isStringArray, 'array of strings', at, problems)
135 required(node, 'rows', v => Array.isArray(v) && v.every(isStringArray), 'array of string arrays', at, problems)
136 return
137 case 'progress':
138 required(node, 'value', isNumber, 'number', at, problems)
139 optional(node, 'max', v => isNumber(v) && (v as number) >= 0, 'a number ≥ 0', at, problems)
140 optional(node, 'label', isString, 'a string', at, problems)
141 return
142 case 'kv':
143 required(node, 'items', v => Array.isArray(v) && v.every(item => isObject(item) && isString(item.key) && isString(item.value)), 'array of { key, value } strings', at, problems)
144 return
145 case 'input':
146 claimId()
147 for (const key of ['label', 'placeholder', 'default']) optional(node, key, isString, 'a string', at, problems)
148 return
149 case 'select':
150 claimId()
151 optional(node, 'label', isString, 'a string', at, problems)
152 optional(node, 'default', isString, 'a string', at, problems)
153 required(node, 'options', v => Array.isArray(v) && v.length > 0 && v.every(o => isObject(o) && isString(o.value) && (o.label === undefined || isString(o.label))), 'non-empty array of { value, label? }', at, problems)
154 return
155 case 'button':
156 claimId()
157 required(node, 'label', isString, 'string', at, problems)
158 optional(node, 'variant', v => v === 'primary' || v === 'secondary', '"primary" or "secondary"', at, problems)
159 const actions = options.isTemplate
160 ? ['submit', 'cancel', 'action', 'prompt', 'refresh', 'run']
161 : ['submit', 'cancel', 'action', 'prompt']
162 optional(node, 'action', v => actions.includes(v as string), actions.map(a => `"${a}"`).join(', '), at, problems)
163 optional(node, 'prompt', isString, 'a string', at, problems)
164 optional(node, 'source', isString, 'a string', at, problems)
165 if (node.action === 'prompt' && !isString(node.prompt)) problems.push(`${at}.prompt: required string for action "prompt"`)
166 if (node.action === 'run' && !isString(node.source)) problems.push(`${at}.source: required string for action "run"`)
167 return
168 case 'countdown':
169 if (options.isTemplate) return void problems.push(`${at}: live nodes are not supported in templates`)
170 claimId()
171 if (node.seconds === undefined && node.until === undefined) problems.push(`${at}: give seconds or until`)
172 optional(node, 'seconds', v => isNumber(v) && (v as number) > 0, 'a positive number', at, problems)
173 optional(node, 'until', v => isString(v) && !Number.isNaN(Date.parse(v)), 'an ISO date-time', at, problems)
174 optional(node, 'label', isString, 'a string', at, problems)
175 optional(node, 'doneText', isString, 'a string', at, problems)
176 return
177 case 'stopwatch':
178 case 'clock':
179 if (options.isTemplate) return void problems.push(`${at}: live nodes are not supported in templates`)
180 optional(node, 'label', isString, 'a string', at, problems)
181 return
182 default:
183 problems.push(`${at}.type: unknown type ${JSON.stringify(node.type)}`)
184 }
185 }
186
187 visit(tree, 'tree', 0)
188 return problems
189}
190
191/** The starting values of a tree's fields: each input's and select's default. */
192export function initialValues(tree: GenuiNode): Record<string, string> {
193 const values: Record<string, string> = {}
194 const visit = (node: GenuiNode) => {
195 if (node.type === 'input' && node.default !== undefined) values[node.id] = node.default
196 if (node.type === 'select') values[node.id] = node.default ?? node.options[0]?.value ?? ''
197 if (node.type === 'box') node.children?.forEach(visit)
198 }
199 visit(tree)
200 return values
201}
202
203/** Whether the tree holds anything the person can answer. */
204export function isInteractive(tree: GenuiNode): boolean {
205 if (tree.type === 'input' || tree.type === 'select' || tree.type === 'button') return true
206 return tree.type === 'box' && (tree.children ?? []).some(isInteractive)
207}
208
209type Live = Extract<GenuiNode, { type: 'countdown' | 'stopwatch' | 'clock' }>
210
211/** The tree's live nodes, the ones redrawn every second. */
212export function liveNodes(tree: GenuiNode): Live[] {
213 if (tree.type === 'countdown' || tree.type === 'stopwatch' || tree.type === 'clock') return [tree]
214 return tree.type === 'box' ? (tree.children ?? []).flatMap(liveNodes) : []
215}
216
217/** When a countdown ends, epoch ms, counted from when its view was drawn. */
218export function countdownEnd(node: Extract<GenuiNode, { type: 'countdown' }>, startedAt: number): number {
219 return node.until !== undefined ? Date.parse(node.until) : startedAt + (node.seconds ?? 0) * 1000
220}
221
222export const TEMPLATE_DOC = `A GenUI template is a JSON file: .claude/genui/<name>.json in the project, or ~/.claude/genui/<name>.json for the user. The user opens it with /genui <name>.
223{
224 "title": "…", "description"?: "…",
225 "sources": { "<name>": { "argv": ["cmd", "arg"], "parse": "json"|"text"|"lines", "timeoutMs"?, "cwd"?, "allowFailure"?: true, "manual"?: true } },
226 "refreshSeconds"?: 10,
227 "tree": <node>
228}
229Commands run without a shell. Their output is data under the source's name. In any string of the tree, {{path | filter:arg}} binds it: a string that is one whole {{…}} keeps the value's type (numbers for progress), others interpolate.
230Paths: r.items[0].name. Filters: length, first, last, count:k=v, where:k=v, where:k!=v, map:k, join:x, sort:k, take:n, split:x, trim, upper, default:x (\t \n \s escapes).
231Extra nodes: { "type": "each", "items": "{{…}}", "as": "x", "node": <node, or a table row array> }, { "type": "when", "test": "{{…}}", "node": <node>, "else"?: <node> }.
232Extra button actions in templates: "refresh" (run every source again), "run" with "source" (run a "manual" source, then refresh). "prompt" may use {{source.path}} and {{form.<id>}}.`
233
234export const PREVIEW_DESCRIPTION = `Run a GenUI template and show it in the side pane, answering the problems found (validation, command errors, binding errors) or a short summary. Use it while writing a template file to check it.
235
236${TEMPLATE_DOC}`
237
238export const PREVIEW_SCHEMA = {
239 type: 'object',
240 properties: {
241 name: { type: 'string', description: 'A saved template to run (project, user or built-in).' },
242 template: { type: 'object', description: 'A template object to run without saving it.' },
243 },
244} as const
245hooks/template-file.ts 140 lines1import type { GenuiJson, GenuiNode, GenuiTemplate, GenuiTemplateRun, GenuiTemplateSource, GenuiView } from '../types'
2import { fillRoot } from './bind'
3import { validate } from './schema'
4
5export const NAME = /^[A-Za-z0-9_-]{1,64}$/
6export const DEFAULT_TIMEOUT_MS = 30_000
7export const MAX_TIMEOUT_MS = 600_000
8const RESERVED = new Set(['now', 'form', '__proto__', 'constructor', 'prototype'])
9const PARSES: GenuiTemplateSource['parse'][] = ['json', 'text', 'lines']
10
11const isObject = (value: unknown): value is Record<string, unknown> =>
12 typeof value === 'object' && value !== null && !Array.isArray(value)
13
14/** Checks a template file's shape (not its tree's bindings, which only data can check). */
15export function validateTemplate(raw: unknown): string[] {
16 if (!isObject(raw)) return ['template: expected a JSON object']
17 const problems: string[] = []
18 if (typeof raw.title !== 'string' || raw.title === '') problems.push('title: required non-empty string')
19 if (raw.description !== undefined && typeof raw.description !== 'string') problems.push('description: expected a string')
20 if (raw.refreshSeconds !== undefined && !(typeof raw.refreshSeconds === 'number' && raw.refreshSeconds >= 0)) {
21 problems.push('refreshSeconds: expected a number ≥ 0')
22 }
23 if (!isObject(raw.sources) || Object.keys(raw.sources).length === 0) {
24 problems.push('sources: required object with at least one source')
25 } else {
26 for (const [name, source] of Object.entries(raw.sources)) {
27 const at = `sources[${JSON.stringify(name)}]`
28 if (!NAME.test(name)) problems.push(`${at}: name must be letters, digits, - or _`)
29 else if (RESERVED.has(name)) problems.push(`${at}: "${name}" is reserved`)
30 if (!isObject(source)) {
31 problems.push(`${at}: expected an object`)
32 continue
33 }
34 if (!Array.isArray(source.argv) || source.argv.length === 0 || !source.argv.every(arg => typeof arg === 'string' && !arg.includes('\u0000')) || String(source.argv[0]).trim() === '') {
35 problems.push(`${at}.argv: required non-empty array of strings, the first naming a program`)
36 }
37 if (!PARSES.includes(source.parse as GenuiTemplateSource['parse'])) problems.push(`${at}.parse: expected "json", "text" or "lines"`)
38 if (source.timeoutMs !== undefined && !(typeof source.timeoutMs === 'number' && source.timeoutMs > 0 && source.timeoutMs <= MAX_TIMEOUT_MS)) {
39 problems.push(`${at}.timeoutMs: expected 1 to ${MAX_TIMEOUT_MS}`)
40 }
41 if (source.cwd !== undefined && typeof source.cwd !== 'string') problems.push(`${at}.cwd: expected a string`)
42 for (const flag of ['allowFailure', 'manual']) {
43 if (source[flag] !== undefined && typeof source[flag] !== 'boolean') problems.push(`${at}.${flag}: expected a boolean`)
44 }
45 }
46 }
47 if (!isObject(raw.tree)) problems.push('tree: required node object')
48 return problems
49}
50
51const canonical = (value: unknown): unknown => {
52 if (Array.isArray(value)) return value.map(canonical)
53 if (typeof value === 'object' && value !== null) {
54 return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical((value as Record<string, unknown>)[key])]))
55 }
56 return value
57}
58
59/** SHA-256 hex of a value's canonical JSON (keys sorted): what an approval is keyed by. */
60export async function fingerprint(value: unknown): Promise<string> {
61 const text = JSON.stringify(canonical(value ?? null))
62 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
63 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
64}
65
66/** A source's working directory: absolute as given, else under the session root. */
67export function resolveCwd(root: string, cwd: string | undefined): string {
68 if (cwd === undefined || cwd === '' || cwd === '.') return root
69 if (/^([A-Za-z]:)?[\\/]/.test(cwd)) return cwd
70 return `${root}/${cwd}`
71}
72
73/** The index of the bracket closing the one at `from`, skipping strings; -1 if none. */
74function matchingClose(text: string, from: number): number {
75 let depth = 0
76 let inString = false
77 for (let i = from; i < text.length; i++) {
78 const char = text[i]
79 if (inString) {
80 if (char === '\\') i++
81 else if (char === '"') inString = false
82 } else if (char === '"') inString = true
83 else if (char === '{' || char === '[') depth++
84 else if (char === '}' || char === ']') {
85 depth--
86 if (depth === 0) return i
87 }
88 }
89 return -1
90}
91
92/** A command's stdout as data; JSON is found among log noise before or after it. */
93export function parseOutput(name: string, stdout: string, parse: GenuiTemplateSource['parse']): { value?: GenuiJson; error?: string } {
94 if (parse === 'text') return { value: stdout.trim() }
95 if (parse === 'lines') return { value: stdout.split(/\r?\n/).map(line => line.trimEnd()).filter(line => line !== '') }
96 const lineStarts: number[] = []
97 const others: number[] = []
98 for (const match of stdout.matchAll(/[{[]/g)) {
99 const at = match.index ?? 0
100 const before = stdout.slice(stdout.lastIndexOf('\n', at - 1) + 1, at)
101 ;(before.trim() === '' ? lineStarts : others).push(at)
102 }
103 try {
104 return { value: JSON.parse(stdout) as GenuiJson }
105 } catch {
106 /* look for the payload among noise */
107 }
108 for (const from of [...lineStarts, ...others]) {
109 const end = matchingClose(stdout, from)
110 if (end < 0) continue
111 try {
112 return { value: JSON.parse(stdout.slice(from, end + 1)) as GenuiJson }
113 } catch {
114 continue
115 }
116 }
117 return { error: `${name}: output is not JSON: ${stdout.trim().slice(0, 200) || '(empty)'}` }
118}
119
120/** The longest any of a template's sources may run. */
121export const maxTimeout = (template: GenuiTemplate) =>
122 Math.max(DEFAULT_TIMEOUT_MS, ...Object.values(template.sources).map(source => source.timeoutMs ?? DEFAULT_TIMEOUT_MS))
123
124/** A run's tree filled with its data and checked; no view until the first data arrives. */
125export function templateView(run: GenuiTemplateRun): { view?: GenuiView; problems: string[] } {
126 if (run.data === undefined) return { problems: [] }
127 let tree: unknown
128 try {
129 tree = fillRoot(run.template.tree, { ...run.data, now: run.updatedAt })
130 } catch (error) {
131 return { problems: [(error as Error).message] }
132 }
133 const problems = validate(tree, { isTemplate: true })
134 if (problems.length > 0) return { problems }
135 return {
136 view: { id: `template:${run.name}`, title: run.template.title, tree: tree as GenuiNode, awaitResponse: false, placement: 'pane' },
137 problems: [],
138 }
139}
140hooks/builtins.ts 233 lines1import type { GenuiTemplate } from '../types'
2
3const tests: GenuiTemplate = {
4 title: 'Test status',
5 description: 'vitest results (for jest, set argv to ["npx", "--no", "jest", "--json"])',
6 sources: { r: { argv: ['npx', '--no', 'vitest', 'run', '--reporter=json'], parse: 'json', timeoutMs: 300_000, allowFailure: true } },
7 refreshSeconds: 0,
8 tree: {
9 type: 'box',
10 gap: 1,
11 children: [
12 {
13 type: 'box',
14 direction: 'row',
15 gap: 3,
16 children: [
17 { type: 'text', text: '✓ {{r.numPassedTests}} passed', color: 'success', bold: true },
18 { type: 'text', text: '✗ {{r.numFailedTests}} failed', color: 'error', bold: true },
19 { type: 'text', text: '○ {{r.numPendingTests}} skipped', dim: true },
20 ],
21 },
22 { type: 'progress', value: '{{r.numPassedTests}}', max: '{{r.numTotalTests}}', label: 'Passing' },
23 {
24 type: 'when',
25 test: '{{r.testResults | where:status=failed}}',
26 node: {
27 type: 'box',
28 border: true,
29 title: 'Failing',
30 children: [
31 {
32 type: 'each',
33 items: '{{r.testResults | where:status=failed}}',
34 as: 'file',
35 node: {
36 type: 'box',
37 children: [
38 { type: 'text', text: '{{file.name}}', bold: true },
39 {
40 type: 'each',
41 items: '{{file.assertionResults | where:status=failed}}',
42 as: 't',
43 node: { type: 'text', text: ' ✗ {{t.fullName}}', color: 'error' },
44 },
45 { type: 'when', test: '{{file.message}}', node: { type: 'text', text: ' {{file.message}}', color: 'error', dim: true } },
46 ],
47 },
48 },
49 ],
50 },
51 else: { type: 'text', text: 'All tests pass.', color: 'success' },
52 },
53 {
54 type: 'box',
55 direction: 'row',
56 gap: 2,
57 children: [
58 { type: 'button', id: 'rerun', label: 'Run again', action: 'refresh' },
59 {
60 type: 'when',
61 test: '{{r.testResults | where:status=failed}}',
62 node: {
63 type: 'button',
64 id: 'fix',
65 label: 'Ask Claude to fix',
66 action: 'prompt',
67 prompt: 'These test files fail; find the cause and fix them:\n{{r.testResults | where:status=failed | map:name | join:\\n}}',
68 },
69 },
70 ],
71 },
72 ],
73 },
74}
75
76const git: GenuiTemplate = {
77 title: 'Git status',
78 sources: {
79 branch: { argv: ['git', 'branch', '--show-current'], parse: 'text' },
80 sync: { argv: ['git', 'rev-list', '--left-right', '--count', '@{u}...HEAD'], parse: 'text', allowFailure: true },
81 changes: { argv: ['git', 'status', '--porcelain'], parse: 'lines' },
82 staged: { argv: ['git', 'diff', '--cached', '--name-status', '--no-renames'], parse: 'lines' },
83 modified: { argv: ['git', 'diff', '--name-only', '--diff-filter=M'], parse: 'lines' },
84 deleted: { argv: ['git', 'diff', '--name-only', '--diff-filter=D'], parse: 'lines' },
85 untracked: { argv: ['git', 'ls-files', '--others', '--exclude-standard', '--directory'], parse: 'lines' },
86 log: { argv: ['git', 'log', '-8', '--format=%h%x09%s%x09%cr'], parse: 'lines', allowFailure: true },
87 },
88 refreshSeconds: 10,
89 tree: {
90 type: 'box',
91 gap: 1,
92 children: [
93 {
94 type: 'box',
95 direction: 'row',
96 gap: 2,
97 children: [
98 { type: 'text', text: '⎇ {{branch | default:—}}', bold: true, color: 'claude' },
99 { type: 'text', text: '↓ {{sync | split:\\t | first | default:—}} behind', color: 'warning' },
100 { type: 'text', text: '↑ {{sync | split:\\t | last | default:—}} ahead', color: 'success' },
101 ],
102 },
103 {
104 type: 'box',
105 direction: 'row',
106 gap: 2,
107 children: [
108 { type: 'text', text: '● {{staged | length}} staged', color: 'suggestion' },
109 { type: 'text', text: '● {{modified | length}} modified', color: 'warning' },
110 { type: 'text', text: '● {{deleted | length}} deleted', color: 'error' },
111 { type: 'text', text: '● {{untracked | length}} untracked', color: 'success' },
112 ],
113 },
114 {
115 type: 'when',
116 test: '{{changes}}',
117 node: {
118 type: 'box',
119 border: true,
120 title: 'Changes',
121 children: [
122 {
123 type: 'each',
124 items: '{{staged | take:30}}',
125 as: 's',
126 node: { type: 'box', direction: 'row', gap: 1, children: [{ type: 'text', text: '{{s | split:\\t | first}}', bold: true, color: 'suggestion' }, { type: 'text', text: '{{s | split:\\t | last}}', color: 'suggestion' }] },
127 },
128 {
129 type: 'each',
130 items: '{{modified | take:30}}',
131 as: 'm',
132 node: { type: 'box', direction: 'row', gap: 1, children: [{ type: 'text', text: 'M', bold: true, color: 'warning' }, { type: 'text', text: '{{m}}' }] },
133 },
134 {
135 type: 'each',
136 items: '{{deleted | take:30}}',
137 as: 'd',
138 node: { type: 'box', direction: 'row', gap: 1, children: [{ type: 'text', text: 'D', bold: true, color: 'error' }, { type: 'text', text: '{{d}}', dim: true }] },
139 },
140 {
141 type: 'each',
142 items: '{{untracked | take:30}}',
143 as: 'u',
144 node: { type: 'box', direction: 'row', gap: 1, children: [{ type: 'text', text: 'U', bold: true, color: 'success' }, { type: 'text', text: '{{u}}' }] },
145 },
146 ],
147 },
148 else: { type: 'text', text: '✓ Working tree clean.', color: 'success' },
149 },
150 {
151 type: 'box',
152 border: true,
153 title: 'Recent commits',
154 children: [
155 {
156 type: 'when',
157 test: '{{log}}',
158 node: {
159 type: 'each',
160 items: '{{log}}',
161 as: 'l',
162 node: {
163 type: 'box',
164 direction: 'row',
165 gap: 1,
166 children: [
167 { type: 'text', text: '{{l | split:\\t | first}}', color: 'warning' },
168 { type: 'text', text: '{{l | split:\\t | take:2 | last}}' },
169 { type: 'text', text: '({{l | split:\\t | last}})', dim: true },
170 ],
171 },
172 },
173 else: { type: 'text', text: 'No commits yet.', dim: true },
174 },
175 ],
176 },
177 { type: 'button', id: 'refresh', label: 'Refresh', action: 'refresh' },
178 ],
179 },
180}
181
182const pods: GenuiTemplate = {
183 title: 'Kubernetes pods',
184 sources: { pods: { argv: ['kubectl', 'get', 'pods', '-A', '-o', 'json'], parse: 'json', timeoutMs: 20_000 } },
185 refreshSeconds: 10,
186 tree: {
187 type: 'box',
188 gap: 1,
189 children: [
190 {
191 type: 'kv',
192 items: [
193 { key: 'Pods', value: '{{pods.items | length}}' },
194 { key: 'Running', value: '{{pods.items | count:status.phase=Running}}' },
195 { key: 'Not running', value: '{{pods.items | where:status.phase!=Running | where:status.phase!=Succeeded | length}}' },
196 ],
197 },
198 {
199 type: 'when',
200 test: '{{pods.items | where:status.phase!=Running | where:status.phase!=Succeeded}}',
201 node: {
202 type: 'box',
203 border: true,
204 title: 'Needs attention',
205 children: [
206 {
207 type: 'each',
208 items: '{{pods.items | where:status.phase!=Running | where:status.phase!=Succeeded}}',
209 as: 'p',
210 node: { type: 'text', text: '{{p.metadata.namespace}}/{{p.metadata.name}}: {{p.status.phase}}', color: 'warning' },
211 },
212 ],
213 },
214 },
215 {
216 type: 'table',
217 columns: ['Namespace', 'Pod', 'Phase', 'Restarts'],
218 rows: [
219 {
220 type: 'each',
221 items: '{{pods.items | sort:metadata.namespace}}',
222 as: 'p',
223 node: ['{{p.metadata.namespace}}', '{{p.metadata.name}}', '{{p.status.phase}}', '{{p.status.containerStatuses[0].restartCount | default:0}}'],
224 },
225 ],
226 },
227 { type: 'button', id: 'refresh', label: 'Refresh', action: 'refresh' },
228 ],
229 },
230}
231
232export const BUILTINS: Record<string, GenuiTemplate> = { tests, git, pods }
233types/index.d.ts 91 lines1export type GenuiJson = string | number | boolean | null | GenuiJson[] | { [key: string]: GenuiJson }
2
3export type GenuiButtonAction = 'submit' | 'cancel' | 'action' | 'prompt' | 'refresh' | 'run'
4
5export type GenuiNode =
6 | { type: 'box'; direction?: 'row' | 'column'; gap?: number; padding?: number; border?: boolean; title?: string; children?: GenuiNode[] }
7 | { type: 'text'; text: string; bold?: boolean; dim?: boolean; italic?: boolean; color?: string }
8 | { type: 'markdown'; text: string }
9 | { type: 'code'; source: string; language?: string }
10 | { type: 'link'; href: string; label?: string }
11 | { type: 'divider' }
12 | { type: 'table'; columns: string[]; rows: string[][] }
13 | { type: 'progress'; value: number; max?: number; label?: string }
14 | { type: 'kv'; items: { key: string; value: string }[] }
15 | { type: 'input'; id: string; label?: string; placeholder?: string; default?: string }
16 | { type: 'select'; id: string; label?: string; options: { value: string; label?: string }[]; default?: string }
17 | { type: 'button'; id: string; label: string; variant?: 'primary' | 'secondary'; action?: GenuiButtonAction; prompt?: string; source?: string }
18 | { type: 'countdown'; id: string; seconds?: number; until?: string; label?: string; doneText?: string }
19 | { type: 'stopwatch'; label?: string }
20 | { type: 'clock'; label?: string }
21
22export type GenuiButton = Extract<GenuiNode, { type: 'button' }>
23
24export type GenuiView = {
25 id: string
26 title: string
27 tree: GenuiNode
28 awaitResponse: boolean
29 placement: 'pane' | 'inline'
30}
31
32/** What the pane shows: Claude's latest drawing, or a template by name. */
33export type GenuiLatest = ({ kind: 'drawn' } & GenuiView) | { kind: 'template'; name: string }
34
35export type GenuiForm = {
36 status: 'open' | 'submitted' | 'cancelled'
37 values: Record<string, string>
38 pressed?: string
39 /** When the call drew the view, epoch ms: what live nodes count from. */
40 startedAt?: number
41}
42
43export type GenuiTemplateSource = {
44 argv: string[]
45 parse: 'json' | 'text' | 'lines'
46 timeoutMs?: number
47 cwd?: string
48 /** A non-zero exit still counts as data (test runners exit 1 on a failure). */
49 allowFailure?: boolean
50 /** Run only by a `run` button, never on refresh. */
51 manual?: boolean
52}
53
54export type GenuiTemplate = {
55 title: string
56 description?: string
57 sources: Record<string, GenuiTemplateSource>
58 refreshSeconds?: number
59 tree: { [key: string]: GenuiJson }
60}
61
62export type GenuiTemplateOrigin = 'project' | 'user' | 'builtin' | 'preview'
63
64export type GenuiTemplateRun = {
65 name: string
66 origin: GenuiTemplateOrigin
67 /** The session root the run was approved under; its sources run there. */
68 root: string
69 path?: string
70 template: GenuiTemplate
71 data?: { [source: string]: GenuiJson }
72 errors: string[]
73 isLoading: boolean
74 loadingSince?: number
75 updatedAt?: number
76 durationMs?: number
77}
78
79declare module 'claude-code' {
80 interface PluginState {
81 genui: {
82 forms: StateFamily<GenuiForm>
83 latest: GenuiLatest | null
84 /** Countdowns whose end was announced, as `<view id>/<node id>`. */
85 fired: string[]
86 /** Each template's last run, by template name. */
87 runs: StateFamily<GenuiTemplateRun>
88 }
89 }
90}
91