SLOPSHOPPER

genui

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.

panerowsguardcommandtoasttool
v0.1.0MITupdated 2026-10-17emirtuncer/genui-mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · genui
│ ┃ genui ✕ › fix the failing auth test and add an audit log call │ ┃ No generated UI yet. Try /genui list. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /genui │ ⎿ genui: No generated UI yet. Templates: /genui list │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · genui
No generated UI yet. Try /genui list.
README

genui: generative UI for Claude Code

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.

A 5-minute timer drawn in the GenUI side pane

Install

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.

What Claude can draw

Claude calls the mcp__genui__render tool with a JSON component tree. Just ask, for example:

  • "set me a 5-minute timer"
  • "show me a status card for this repo"
  • "ask me for the settings of a new DB connection with a form"

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.

Templates: reusable, data-bound screens

Watching testsWatching git
Test status templateGit status template

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:

  • sources: json, text, lines, timeoutMs, allowFailure, manual;
  • filters: length, first, last, count, where, map, join, sort, take, split, trim, upper, default;
  • the each and when nodes;
  • refresh and run buttons.

Claude can write a template for you and check it with its preview_template tool.

Safety

  • Commands run as argv, with no shell.
  • A project template asks before running its commands the first time, again whenever its commands or prompt texts change, and when it is opened from a different project root. An approval is tied to a SHA-256 fingerprint of what it runs.
  • Built-in and user templates run without asking when you type /genui <name>. They always ask when Claude starts one with preview_template.
  • A template's prompt button sends its text marked as coming from the template (<genui-prompt template="…" origin="…">), not as your own words.

Develop

git clone https://github.com/emirtuncer/genui-mod
claude --plugin-dir ./genui-mod
claude plugin validate ./genui-mod
claude plugin test ./genui-mod

Note on blocking forms

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.

License

MIT

Source 8 files
hooks/register.tsx 290 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
290
hooks/bind.ts 205 lines
1export 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}
205
hooks/render.tsx 258 lines
1import 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}
258
hooks/runner.ts 379 lines
1import { 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}
379
hooks/schema.ts 245 lines
1import 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
245
hooks/template-file.ts 140 lines
1import 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}
140
hooks/builtins.ts 233 lines
1import 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 }
233
types/index.d.ts 91 lines
1export 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