SLOPSHOPPER

roer-ui

Roer's Generative UI in a Claude Code pane: /roer <what to show>

newpaneguardcommandtoasttool
★ 11v0.1.0Apache-2.0updated 2026-10-09JetBrains/roer/claude-plugin/roer-ui
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roer-ui
│ ┃ Roer ✕ › fix the failing auth test and add an audit log call │ ┃ Nothing to show yet. Try /roer a form to │ ┃ file a bug ⏺ 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 │ │ › /roer │ ⎿ roer-ui: Roer pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roer
Nothing to show yet. Try /roer a form to file a bug
README

roer-ui (spike)

Roer's Generative UI drawn in a Claude Code pane, beside the transcript, with no Roer app or tmux involved: /roer <what to show> opens the pane and asks Claude to draw it through this mod's own show tool, which takes the same A2UI v1.0 messages as Roer's show_ui. Presses come back to Claude as a prompt starting [roer-ui], with the action message and, under sendDataModel, the data model.

It is a Claude Code function hooks plugin: early access, off unless CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1, and the API may change between releases.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir claude-plugin/roer-ui
# then: /roer a form to file a bug

Draws the whole of roer:catalog/1, as Roer's panel does, with what a terminal cannot do drawn another way:

  • Image, Video and AudioPlayer are named and linked rather than shown or played.
  • Icon's svgPath draws as a mark; a named icon draws as its name.
  • Modal opens beneath its trigger, in a frame, rather than over the pane.
  • DiffView draws each file's diff with its notes under it, unified only.
  • Mermaid shows the diagram's source, not the diagram, with its open threads under it, and takes no comments.
  • An obscured TextField never shows its value, only its length. The field itself cannot mask what is being typed.
  • A long TextField is one line.

Beyond A2UI v1.0, and not in Roer's panel yet, the pane can work without a turn of Claude's, which is where the waiting was (hooks/local.ts):

  • A loadData message fills a data-model path from a command's output (argv, no shell) or a file, so Claude names where a diff or a list comes from instead of writing it out.
  • createSurface { hidden: true } keeps a surface without drawing it.
  • A Button's action.local opens another surface in place of its own and fills loads, off the pressed item, in the pane itself. An event beside it still reaches Claude.

Commands, and files (read as cat), run under the person's permissions, as Claude's own Bash calls do: what their rules and mode allow runs at once, what needs asking opens the permission dialog when the call lands or the button is pressed, and what they deny is refused. So a button may merge a PR, and the pane marks it "(asks first)" when it will ask, or "(not allowed)". An allowed command runs directly rather than as a Bash tool call; PreToolUse hooks see only the ones that ask. Either way its output comes whole: what the Bash tool keeps in a file for being too long for Claude is read from there.

A DiffView draws each file under a header that opens and closes it, and colours the code by the file's path. The engine refuses a drawing of more than 100000 characters of text, so the diffs on screen share about 60000: files are open while they fit, one the person opens takes the room first, and one that does not fit says so. A file longer than one Code takes (10000 characters) is drawn in pieces, cut between hunks.

The pane keeps its own state, as Roer's panel does: the tab showing, what is expanded or open, a picker's filter, and a work item's ticks and answers.

CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin validate claude-plugin/roer-ui
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude plugin test claude-plugin/roer-ui

For tsc -p claude-plugin/roer-ui, write the API's declarations first with /plugin-types claude-plugin/roer-ui/.claude/types in a session that has the flag set.

Source 5 files
hooks/register.tsx 1183 lines
1// roer-ui: Roer's Generative UI drawn in a Claude Code pane instead of Roer's
2// side panel. `/roer <what to show>` opens the pane and asks the model to
3// draw it; the model draws through this mod's own `show` tool, which takes
4// the same A2UI v1.0 messages as Roer's `show_ui`, so nothing here needs Roer
5// running. A press in the pane comes back to the model as a prompt.
6//
7// Spike: layout, text and the basic inputs of the catalog. The rest of
8// `roer:catalog/1` draws as a dim placeholder naming the component.
9
10import { atom, read, update } from 'claude-code'
11import type { Register, RenderChildren } from 'claude-code'
12import type { RoerUiComponent, RoerUiSurface } from '../types'
13import { EMPTY, apply, binding, children, contextOf, own, resolve, setAt, text } from './a2ui'
14import {
15  SOURCE_NAMES,
16  progressValue,
17  readChanges,
18  readComments,
19  readDecisions,
20  readDiagramThreads,
21  readFindings,
22  readNotes,
23  readRequirements,
24  readSources,
25  shownValue,
26  statusTone,
27} from './workItem'
28import type { Comment, Decision, Finding, FindingState, Note, Requirement, SourceRef, Tone } from './workItem'
29import { bashRan, fetchLoad, opened, readLoadData, readLocal, shellCommand } from './local'
30import type { Load, LocalAction, RunIo } from './local'
31
32const PANE = 'roer-ui'
33const TOOL = 'mcp__roer-ui__show'
34const GUIDE_TOOL = 'mcp__roer-ui__guide'
35const surfaces = atom({ plugin: 'roer-ui', key: 'surfaces' } as const, EMPTY)
36
37const CATALOG = `Components (flat list, each { id, component, ...props }; one must have id "root"). Any component takes
38weight (flex-grow inside a Row/Column).
39Layout:
40- Column / Row { children, justify?, align? } and List { children, direction? }: children is an array of ids, or a
41  template { componentId, path } repeating one component per item of a data-model list. Inside it, a path without a
42  leading / is the item's own ({ "path": "title" }); one with it, /title, reads the surface's root. { "call": "@index",
43  args?: { offset } } is the item's position
44- Grid { children, columns?, minItemWidth? } wraps; Card { child }; Divider { axis? }; Tabs { tabs: [{ title, child }] };
45  Modal { trigger, content }; Expandable { title, child, defaultExpanded? }; Arrow { direction?, label? }
46Display:
47- Text { text, variant?: "caption" | "body" }; Icon { name }; Image { url, description? }; Video { url };
48  AudioPlayer { url, description? } (media is linked, not played)
49Input:
50- Button { child (usually a Text id), action: { event: { name, context?, userMessage? } }, variant?: "primary" | "borderless" }
51- TextField { label, value?, placeholder?, variant?: "shortText" | "number" | "obscured" }, CheckBox { label, value },
52  ChoicePicker { label?, options: [{ label, value }], value: [selected values], variant?: "mutuallyExclusive" |
53  "multipleSelection", displayStyle?: "checkbox" | "chips", filterable? },
54  Slider { max, value, min?, steps?, label? }, DateTimeInput { value, enableDate?, enableTime?, min?, max?, label? }
55Roer's own:
56- StatTile { label, value, trend?: { delta, direction: "up" | "down" | "flat" }, icon? }
57- StatusCard { title, subtitle?, meta?, icon?, status?, progress? (0-100), url?, footer? }
58- WorkItem { title, source?, key?, status?, url?, assignee?, labels?, meta?, footer? }; with variant: "detail" also goal,
59  requirements [{ id, text, met }], decisions [{ id, question, options: [{ label, value }], answer? }],
60  findings [{ id, severity: info|warn|error, text, at?: { changeId, path, line }, state? }], sources [{ kind, label, url?,
61  path? }], comments [{ id, author, text, at? }], changes [{ id, title, patch }]. The person's ticks, answers and settled
62  findings arrive as toggleRequirement / answerDecision / settleFinding events with the item's key as workItem.
63- Requirements / Findings / Decisions / Sources / Comments { items }: one of those sections on its own
64- DiffView { diff (a whole git diff), title?, emptyText?, notes?: [{ path, line?, text }] }
65Any string/number/boolean prop may instead be { "path": "/json/pointer" } into the surface's dataModel; bind an
66input's value that way and the person's edits write back to the data model.`
67
68// The engine caps a tool's description at 4096 characters, so the catalog
69// and the pane-side rules are the guide tool's answer, read before drawing.
70const DESCRIPTION = `Show or update a UI in the Roer pane of this Claude Code session. Call ${GUIDE_TOOL} first, once
71per session: it answers the component catalog and how the pane fetches data and handles presses itself.
72\`messages\` is a list of A2UI v1.0 messages, each { "version": "v1.0", <one body> }: createSurface { surfaceId,
73components, dataModel?, sendDataModel?, hidden? }, updateComponents { surfaceId, components }, updateDataModel
74{ surfaceId, path?, value }, deleteSurface { surfaceId }, loadData { surfaceId, path, run | file, as? }.
75Usually one createSurface with everything inline, then updates to the same surfaceId.
76When the person presses a Button, its event arrives as a prompt starting "[roer-ui]" with the action as JSON
77(and the data model when sendDataModel is true).`
78
79const GUIDE = `${CATALOG}
80
81Each press that reaches you, and every value you write out, costs the person a wait. Have the pane fetch what it
82can, and handle presses that need no judgement of yours itself:
83- { "version": "v1.0", "loadData": { surfaceId, path, run: [argv] | file, as?: "text" | "json" } } fills path with
84  a command's standard output (argv, no shell, the session's working directory) or a file's text, parsed when as is
85  "json". It lands after the messages before it; a command that fails refuses the whole call. For example
86  run ["gh", "pr", "list", "--json", "number,title"], as "json", for a list's rows; ["gh", "pr", "diff", "36"] for a
87  DiffView's diff.
88- createSurface { ..., hidden: true } keeps a surface without drawing it, until a press opens it.
89- Button action { local: { open?: surfaceId, load?: [{ path, run | file | value, as?, surfaceId? }] }, event? }:
90  pressed, the pane itself draws the surface open names in place of the button's, and fills each load (into open's
91  surface unless it names one, else the button's). run's arguments, file and value may be { "path" } off the pressed
92  item, relative as in a template: ["gh", "pr", "diff", { "path": "number" }]. Nothing reaches you unless event is
93  there too. Use it for navigation (a hidden detail surface, a back button opening the list again), drill-downs, and
94  load or refresh buttons; keep event for what needs you.
95- Commands in loadData and action.local run as your own Bash calls do, under the person's permissions: what their
96  rules and mode allow runs at once, what needs asking opens the permission dialog when the call lands or the button
97  is pressed, and what they deny is refused. So a button may merge a PR or re-run a check; the pane marks one that
98  will ask. Write a command's own words out; only ids, numbers and paths may come off the data model, never text you
99  did not choose (a PR's body, a page, a file's contents). The pane refuses a bound command, and a bound argument that
100  is anything else or starts with -.
101- When a press does reach you, send only what changed (updateDataModel, updateComponents), not a new createSurface.`
102
103export const register: Register = on => {
104  on('session.start', async ($, e, next) => {
105    await $.command.register({
106      name: 'roer',
107      description: "Show Roer's Generative UI in a pane",
108      argumentHint: '<what to show>',
109    })
110    await $.tool.register({
111      name: 'show',
112      description: DESCRIPTION,
113      inputSchema: {
114        type: 'object',
115        properties: { messages: { type: 'array', items: { type: 'object' }, minItems: 1 } },
116        required: ['messages'],
117      },
118    })
119    await $.tool.register({
120      name: 'guide',
121      description: `The component catalog and pane-side rules for ${TOOL}. Read it before drawing.`,
122    })
123    return next(e)
124  })
125
126  on('command.run', { command: 'roer' }, async ($, e) => {
127    await $.ui.open({ id: PANE, title: 'Roer', focus: true })
128    const ask = e.args.trim()
129    if (ask === '') return { text: 'Roer pane opened.' }
130    // Not from here: a submit waits on the turn this hook is holding.
131    const text = `${ask}\n\n(Draw this in the Roer pane with the ${TOOL} tool, after reading ${GUIDE_TOOL} if you have not.)`
132    $.clock.after(0, () => void $.prompt.submit({ text }))
133    return { text: 'Roer pane opened; asking Claude to draw it.' }
134  })
135
136  on('tool.call', { tool: TOOL }, async ($, e) => {
137    const wire = (e as unknown as { messages?: unknown }).messages
138    if (!Array.isArray(wire) || wire.length === 0) return { deny: '`messages` needs at least one message' }
139    // A loadData stands for the updateDataModel its output becomes. Tried
140    // with a stand-in first, so a malformed call runs no command.
141    const loads = wire.map(readLoadData)
142    const bad = loads.findIndex(load => typeof load === 'string')
143    if (bad >= 0) return { deny: `Nothing was shown: message ${bad + 1}: ${loads[bad] as string}` }
144    const asUpdate = (load: Load, value: unknown) => ({ version: 'v1.0', updateDataModel: { surfaceId: load.surfaceId, path: load.path, value } })
145    const standIns = wire.map((message, n) => {
146      const load = loads[n] as Load | undefined
147      return load === undefined ? message : asUpdate(load, '')
148    })
149    let tried: typeof EMPTY | string = await read($, surfaces)
150    for (const [n, message] of standIns.entries()) {
151      tried = apply(tried, message)
152      if (typeof tried === 'string') return { deny: `Nothing was shown: message ${n + 1}: ${tried}` }
153    }
154    // The model's own call: its loads are asked about as its Bash calls are.
155    const io: RunIo = {
156      check: command => $.tool.check({ tool: 'Bash', input: { command } }),
157      run: argv => $.process.run(argv, { timeoutMs: 60_000 }),
158      bash: async command => {
159        return bashRan(await $.tool.call({ tool: 'Bash', command, description: 'Load data for the Roer pane' }))
160      },
161      read: path => $.fs.read(path),
162    }
163    const fetched = await Promise.all(
164      loads.map(load => (load === undefined || typeof load === 'string' ? undefined : fetchLoad(load, io))),
165    )
166    const failed = fetched.findIndex(got => typeof got === 'string')
167    if (failed >= 0) return { deny: `Nothing was shown: message ${failed + 1}: ${fetched[failed] as string}` }
168    const messages = wire.map((message, n) => {
169      const load = loads[n] as Load | undefined
170      const got = fetched[n] as { value: unknown } | undefined
171      return load === undefined || got === undefined ? message : asUpdate(load, got.value)
172    })
173    let problem: string | undefined
174    await update($, surfaces, before => {
175      let after = before
176      for (const [n, message] of messages.entries()) {
177        const next = apply(after, message)
178        if (typeof next === 'string') {
179          problem = `message ${n + 1}: ${next}`
180          return before
181        }
182        after = next
183      }
184      problem = undefined
185      return after
186    })
187    if (problem !== undefined) return { deny: `Nothing was shown: ${problem}` }
188    const opened = await $.ui.open({ id: PANE, title: 'Roer' })
189    const where = opened.isPlaced ? 'the Roer pane' : `the Roer pane, which is waiting to be placed (${opened.reason})`
190    return { result: `Sent ${messages.length} message(s) to ${where}.` }
191  })
192
193  on('tool.call', { tool: GUIDE_TOOL }, async () => ({ result: GUIDE }))
194
195  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
196    const el = $.ui.resolve(e)
197    const { Box, Text, Button, Link, Code } = el
198    // The mobile app draws no fields yet, whatever its table holds.
199    const Input = e.surface !== 'mobile' && 'Input' in el ? el.Input : undefined
200    const Select = e.surface !== 'mobile' && 'Select' in el ? el.Select : undefined
201    const { order, bySurface } = await read($, surfaces)
202    if (order.length === 0) {
203      return (
204        <Box>
205          <Text dimColor>Nothing to show yet. Try /roer a form to file a bug</Text>
206        </Box>
207      )
208    }
209    const columns = Math.max(10, (e.props as { bodyColumns?: number }).bodyColumns ?? 60)
210    // The permission decision for each command a drawn button would run.
211    // A drawing names the ones it did not know; they are asked, then drawn
212    // again.
213    const verdicts = new Map<string, 'allow' | 'ask' | 'deny'>()
214    const unchecked = new Set<string>()
215
216    // Closures rather than helpers: the engine wants `$` spelled at each call
217    // site, never handed to a function of the module's own.
218    const change = (surfaceId: string, edit: (surface: RoerUiSurface) => RoerUiSurface) =>
219      update($, surfaces, all => {
220        const surface = own(all.bySurface, surfaceId)
221        if (surface === undefined) return all
222        return { ...all, bySurface: { ...all.bySurface, [surfaceId]: edit(surface) } }
223      })
224    const writeModel = (surfaceId: string, pointer: string | undefined, value: unknown) =>
225      pointer === undefined
226        ? undefined
227        : change(surfaceId, surface => ({ ...surface, dataModel: setAt(surface.dataModel, pointer, value) }))
228    // The pane's own state, never the data model's: nothing the agent sent
229    // says which tab is showing, and a press must not report it.
230    const setView = (surfaceId: string, at: string, value: unknown) =>
231      change(surfaceId, surface => ({ ...surface, view: { ...surface.view, [at]: value } }))
232
233    // Something the person did, handed to the model as v1.0's
234    // renderer-to-agent `action` message, the way Roer's panel reports it,
235    // but as a prompt.
236    const send = async (
237      surfaceId: string,
238      sourceComponentId: string,
239      name: string,
240      context: Record<string, unknown>,
241      said: string,
242      userMessage?: string,
243    ) => {
244      // Read afresh: the drawing's copy predates whatever was typed since.
245      const surface = own((await read($, surfaces)).bySurface, surfaceId)
246      if (surface === undefined) return
247      const message = {
248        version: 'v1.0',
249        action: {
250          name,
251          surfaceId,
252          sourceComponentId,
253          timestamp: new Date().toISOString(),
254          context,
255          ...(userMessage ? { userMessage } : {}),
256        },
257      }
258      const lines = [`[roer-ui] ${userMessage || said}`, JSON.stringify(message)]
259      if (surface.sendDataModel) lines.push(`dataModel: ${JSON.stringify(surface.dataModel)}`)
260      await $.prompt.submit({ text: lines.join('\n') })
261    }
262
263    // What a press does in the pane itself: open a surface in place of the
264    // pressed one at once, then fill each load. Each surface being filled
265    // says so while it is; a later press on it wins over this one.
266    const runLocal = async (from: string, local: LocalAction, label: string) => {
267      const token = `${Date.now()}.${Math.random()}`
268      const fetching = local.loads.filter(load => !('value' in load))
269      const filling = [...new Set(fetching.map(load => load.surfaceId))]
270      const into = (all: typeof EMPTY, load: Load, value: unknown) => {
271        const surface = own(all.bySurface, load.surfaceId)
272        if (surface === undefined) return all
273        return { ...all, bySurface: { ...all.bySurface, [load.surfaceId]: { ...surface, dataModel: setAt(surface.dataModel, load.path, value) } } }
274      }
275      const marked = (all: typeof EMPTY, surfaceId: string, loading: unknown) => {
276        const surface = own(all.bySurface, surfaceId)
277        if (surface === undefined) return all
278        return { ...all, bySurface: { ...all.bySurface, [surfaceId]: { ...surface, view: { ...surface.view, loading } } } }
279      }
280      await update($, surfaces, all => {
281        let next = local.open === undefined ? all : opened(all, from, local.open)
282        for (const load of local.loads) {
283          // What is being fetched is cleared, never shown under the wrong title.
284          next = into(next, load, 'value' in load ? load.value : null)
285        }
286        for (const surfaceId of filling) next = marked(next, surfaceId, { token })
287        return next
288      })
289      if (fetching.length === 0) return
290      // The person's press is theirs to vouch for, not the command: the
291      // dialog, or the auto-mode classifier, weighs one against the other.
292      const io: RunIo = {
293        check: command => $.tool.check({ tool: 'Bash', input: { command } }),
294        run: argv => $.process.run(argv, { timeoutMs: 60_000 }),
295        bash: async command => {
296          return bashRan(
297            await $.tool.call({
298              tool: 'Bash',
299              command,
300              description: `"${label}" in the Roer pane`,
301              consent: `The user pressed "${label}" in the Roer pane.`,
302            }),
303          )
304        },
305        read: path => $.fs.read(path),
306      }
307      const got = await Promise.all(fetching.map(load => fetchLoad(load, io)))
308      await update($, surfaces, all => {
309        let next = all
310        const mine = (surfaceId: string) => {
311          const loading = own(own(next.bySurface, surfaceId)?.view ?? {}, 'loading') as { token?: string } | undefined
312          return loading?.token === token
313        }
314        const current = filling.filter(mine)
315        for (const [n, load] of fetching.entries()) {
316          const one = got[n]
317          if (current.includes(load.surfaceId) && typeof one === 'object') next = into(next, load, one.value)
318        }
319        for (const surfaceId of current) {
320          const problems = fetching.flatMap((load, n) => (load.surfaceId === surfaceId && typeof got[n] === 'string' ? [got[n] as string] : []))
321          next = marked(next, surfaceId, problems.length > 0 ? { failed: problems.join('; ') } : undefined)
322        }
323        return next
324      })
325    }
326
327    const press = async (surfaceId: string, c: RoerUiComponent, label: string, scope: string) => {
328      const event = (c.action as { event?: { name?: unknown; context?: unknown; userMessage?: unknown } } | undefined)?.event
329      const hasEvent = event !== undefined && typeof event.name === 'string'
330      const surface = own((await read($, surfaces)).bySurface, surfaceId)
331      if (surface === undefined) return
332      const model = surface.dataModel
333      const local = readLocal(c.action, model, scope, surfaceId)
334      if (typeof local === 'string') {
335        $.ui.toast(`roer-ui: "${label}": ${local}`)
336        return
337      }
338      if (local === undefined && !hasEvent) {
339        $.ui.toast(`roer-ui: "${label}" has no event to send`)
340        return
341      }
342      if (local !== undefined) await runLocal(surfaceId, local, label)
343      if (!hasEvent) return
344      const said = `The person pressed "${label}" in the ${surfaceId} UI.`
345      await send(surfaceId, c.id, event.name as string, contextOf(event.context, model, scope), said, text(event.userMessage, model, scope))
346    }
347
348    // `ancestors` are the components on the way down to this one. The graph
349    // is the model's, so it can loop back on itself; a loop draws as a
350    // placeholder the moment it closes, as in Roer's GenerativeSurface. A
351    // depth limit alone would first draw exponentially many nodes.
352    const draw = (surfaceId: string, surface: RoerUiSurface, id: string, scope: string, ancestors: ReadonlySet<string>): RenderChildren => {
353      if (ancestors.has(id)) return <Text dimColor>{`(cycle at ${id})`}</Text>
354      const c = own(surface.components, id)
355      if (c === undefined) return <Text dimColor>{`(missing ${id})`}</Text>
356      const model = surface.dataModel
357      const key = `${surfaceId}.${id}${scope}`
358      const at = `${id}${scope}`
359      const view = (name: string) => own(surface.view ?? {}, `${name}:${at}`)
360      const below = new Set(ancestors).add(id)
361      // A child with a `weight` grows inside its Row or Column, as flex-grow
362      // does in Roer's panel.
363      const weighed = (child: string, node: RenderChildren) => {
364        const weight = own(surface.components, child)?.weight
365        return typeof weight === 'number' ? <Box flexGrow={weight} flexDirection="column">{node}</Box> : node
366      }
367      const kids = (list: unknown) =>
368        children(list, model, scope).map(([child, childScope]) => weighed(child, draw(surfaceId, surface, child, childScope, below)))
369      const one = (child: unknown) => (typeof child === 'string' ? draw(surfaceId, surface, child, scope, below) : null)
370      const value = (v: unknown) => resolve(v, model, scope)
371      const str = (prop: string) => text(c[prop], model, scope)
372      const write = (pointer: string | undefined, next: unknown) => writeModel(surfaceId, pointer, next)
373      const link = (url: string, label: string) =>
374        url.startsWith('https://') ? <Link href={url} label={label} /> : <Text>{label}</Text>
375
376      switch (c.component) {
377        case 'Column':
378          return (
379            <Box flexDirection="column" justifyContent={justify(c.justify)} alignItems={align(c.align)}>
380              {kids(c.children)}
381            </Box>
382          )
383        case 'Row':
384          return (
385            <Box flexDirection="row" gap={1} justifyContent={justify(c.justify)} alignItems={align(c.align)}>
386              {kids(c.children)}
387            </Box>
388          )
389        case 'List': {
390          const isRow = c.direction === 'horizontal'
391          return (
392            <Box flexDirection={isRow ? 'row' : 'column'} gap={isRow ? 1 : 0} alignItems={align(c.align)}>
393              {kids(c.children)}
394            </Box>
395          )
396        }
397        case 'Grid': {
398          // The one layout that wraps. `columns` wins over `minItemWidth`, as
399          // in Roer's panel; a pixel width becomes cells at 8 pixels each.
400          const count = typeof c.columns === 'number' && c.columns >= 1 ? Math.floor(c.columns) : undefined
401          const minWidth = typeof c.minItemWidth === 'number' ? Math.max(8, Math.ceil(c.minItemWidth / 8)) : 24
402          return (
403            <Box flexDirection="row" flexWrap="wrap" rowGap={1}>
404              {kids(c.children).map(node => (
405                <Box
406                  flexDirection="column"
407                  paddingRight={1}
408                  width={count === undefined ? undefined : `${Math.floor(100 / count)}%`}
409                  minWidth={count === undefined ? minWidth : undefined}
410                >
411                  {node}
412                </Box>
413              ))}
414            </Box>
415          )
416        }
417        case 'Card':
418          return (
419            <Box borderStyle="round" borderDimColor flexDirection="column" paddingX={1}>
420              {one(c.child)}
421            </Box>
422          )
423        case 'Tabs': {
424          // A row of buttons, one per tab, and only the chosen tab's child.
425          const tabs = Array.isArray(c.tabs) ? (c.tabs as { title?: unknown; child?: unknown }[]) : []
426          if (tabs.length === 0) return null
427          const chosen = view('tab')
428          const shown = Math.min(Math.max(0, typeof chosen === 'number' ? chosen : 0), tabs.length - 1)
429          return (
430            <Box flexDirection="column" gap={1}>
431              <Box flexDirection="row" gap={2}>
432                {tabs.map((tab, n) => {
433                  const title = text(tab.title, model, scope) || `Tab ${n + 1}`
434                  return (
435                    <Button
436                      key={`${key}.tab${n}`}
437                      plain
438                      label={n === shown ? `[${title}]` : title}
439                      dimColor={n === shown ? undefined : true}
440                      onPress={() => void setView(surfaceId, `tab:${at}`, n)}
441                    />
442                  )
443                })}
444              </Box>
445              {one(tabs[shown]?.child)}
446            </Box>
447          )
448        }
449        case 'Modal': {
450          // No layer to float it on: the trigger, a button that opens and
451          // closes it, and the content in a frame beneath while open.
452          const open = view('open') === true
453          return (
454            <Box flexDirection="column">
455              <Box flexDirection="row" gap={1}>
456                {one(c.trigger)}
457                <Button key={`${key}.open`} plain dimColor label={open ? '▾ close' : '▸ open'} onPress={() => void setView(surfaceId, `open:${at}`, !open)} />
458              </Box>
459              {open && (
460                <Box borderStyle="double" flexDirection="column" paddingX={1}>
461                  {one(c.content)}
462                </Box>
463              )}
464            </Box>
465          )
466        }
467        case 'Expandable': {
468          const set = view('expanded')
469          const open = typeof set === 'boolean' ? set : c.defaultExpanded === true
470          return (
471            <Box flexDirection="column">
472              <Button key={`${key}.toggle`} plain label={`${open ? '▾' : '▸'} ${str('title')}`} onPress={() => void setView(surfaceId, `expanded:${at}`, !open)} />
473              {open && (
474                <Box flexDirection="column" paddingLeft={2}>
475                  {one(c.child)}
476                </Box>
477              )}
478            </Box>
479          )
480        }
481        case 'Divider':
482          return c.axis === 'vertical' ? (
483            <Text dimColor>│</Text>
484          ) : (
485            <Text dimColor wrap="truncate-end">{'─'.repeat(columns)}</Text>
486          )
487        case 'Arrow': {
488          const label = str('label')
489          if (c.direction === 'vertical') {
490            return (
491              <Box flexDirection="column" alignItems="center">
492                <Text dimColor>│</Text>
493                {label !== '' && <Text dimColor>{label}</Text>}
494                <Text dimColor>▼</Text>
495              </Box>
496            )
497          }
498          return <Text dimColor>{label === '' ? '──▶' : `── ${label} ──▶`}</Text>
499        }
500        case 'Text':
501          return <Text dimColor={c.variant === 'caption'}>{str('text')}</Text>
502        case 'Icon':
503          return <Text>{iconText(c.name, model, scope)}</Text>
504        case 'Image': {
505          // A terminal cannot fetch and draw a picture from a link: the pane
506          // names it and links to it.
507          const description = str('description')
508          return link(str('url'), `[image${description ? `: ${description}` : ''}]`)
509        }
510        case 'Video':
511          return link(str('url'), '▶ video')
512        case 'AudioPlayer': {
513          const description = str('description')
514          return link(str('url'), `♪ ${description || 'audio'}`)
515        }
516        case 'Button': {
517          const label = (typeof c.child === 'string' ? labelOf(own(surface.components, c.child), model, scope) : '') || id
518          // What the person's permissions say of the commands a press would
519          // run: marked when one would ask, or would be refused.
520          const local = readLocal(c.action, model, scope, surfaceId)
521          const commands = typeof local === 'object' ? local.loads.flatMap(load => ('run' in load ? [shellCommand(load.run)] : [])) : []
522          const decisions = commands.map(command => {
523            const decided = verdicts.get(command)
524            if (decided === undefined) unchecked.add(command)
525            return decided
526          })
527          const mark = decisions.includes('deny') ? ' (not allowed)' : decisions.includes('ask') ? ' (asks first)' : ''
528          return (
529            <Button
530              key={key}
531              label={`${label}${mark}`}
532              variant={c.variant === 'primary' ? 'primary' : undefined}
533              dimColor={c.variant === 'borderless' || decisions.includes('deny') ? true : undefined}
534              onPress={() => void press(surfaceId, c, label, scope)}
535            />
536          )
537        }
538        case 'TextField': {
539          const pointer = binding(c.value, scope)
540          const shown = str('value')
541          // No masked field in a terminal: an obscured one never shows its
542          // value, only how long it is.
543          const obscured = c.variant === 'obscured'
544          const asTyped = (typed: string) =>
545            c.variant === 'number' && typed.trim() !== '' && Number.isFinite(Number(typed)) ? Number(typed) : typed
546          if (Input === undefined) return <Text>{`${str('label')}: ${obscured ? '•'.repeat(shown.length) : shown}`}</Text>
547          return (
548            <Input
549              key={key}
550              label={`${str('label')} `}
551              placeholder={obscured ? '•'.repeat(shown.length) || undefined : str('placeholder') || undefined}
552              value={obscured ? undefined : shown}
553              onInput={typed => write(pointer, asTyped(typed))}
554              onSubmit={typed => write(pointer, asTyped(typed))}
555            />
556          )
557        }
558        case 'CheckBox': {
559          const isOn = value(c.value) === true
560          return (
561            <Button
562              key={key}
563              plain
564              label={`${isOn ? '[x]' : '[ ]'} ${str('label')}`}
565              onPress={() => write(binding(c.value, scope), !isOn)}
566            />
567          )
568        }
569        case 'ChoicePicker': {
570          const options = (Array.isArray(c.options) ? c.options : []) as { label?: unknown; value?: unknown }[]
571          const picked = value(c.value)
572          const chosen = Array.isArray(picked) ? picked.map(String) : []
573          const pointer = binding(c.value, scope)
574          const multiple = c.variant === 'multipleSelection'
575          const filter = c.filterable === true ? String(view('filter') ?? '') : ''
576          const shown = options.filter(option => text(option.label, model, scope).toLowerCase().includes(filter.toLowerCase()))
577          // As in Roer's panel: a single choice pressed again is cleared.
578          const toggle = (v: string) =>
579            write(pointer, multiple ? (chosen.includes(v) ? chosen.filter(x => x !== v) : [...chosen, v]) : chosen.includes(v) ? [] : [v])
580          const filterBox =
581            c.filterable === true && Input !== undefined ? (
582              <Input key={`${key}.filter`} label="Filter " value={filter} onInput={typed => void setView(surfaceId, `filter:${at}`, typed)} onSubmit={typed => void setView(surfaceId, `filter:${at}`, typed)} />
583            ) : null
584          if (!multiple && c.displayStyle !== 'chips' && c.filterable !== true && Select !== undefined) {
585            return (
586              <Select
587                key={key}
588                label={c.label === undefined ? undefined : `${str('label')} `}
589                options={options.map(option => ({ value: String(option.value), label: text(option.label, model, scope) }))}
590                value={chosen[0]}
591                onSelect={picked => write(pointer, [picked])}
592              />
593            )
594          }
595          const mark = (isOn: boolean) => (multiple ? (isOn ? '[x]' : '[ ]') : isOn ? '(•)' : '( )')
596          const buttons = shown.map(option => {
597            const v = String(option.value)
598            const isOn = chosen.includes(v)
599            const label = text(option.label, model, scope)
600            return (
601              <Button
602                key={`${key}.${v}`}
603                plain
604                label={c.displayStyle === 'chips' ? (isOn ? `[${label}]` : ` ${label} `) : `${mark(isOn)} ${label}`}
605                dimColor={c.displayStyle === 'chips' && !isOn ? true : undefined}
606                onPress={() => toggle(v)}
607              />
608            )
609          })
610          return (
611            <Box flexDirection="column">
612              {c.label !== undefined && <Text>{str('label')}</Text>}
613              {filterBox}
614              {c.displayStyle === 'chips' ? <Box flexDirection="row" flexWrap="wrap" columnGap={1}>{buttons}</Box> : buttons}
615            </Box>
616          )
617        }
618        case 'Slider': {
619          const min = typeof c.min === 'number' ? c.min : 0
620          const max = typeof c.max === 'number' && c.max > min ? c.max : min + 100
621          const raw = Number(value(c.value) ?? min)
622          const current = Number.isFinite(raw) ? Math.min(max, Math.max(min, raw)) : min
623          const step = typeof c.steps === 'number' && c.steps > 0 ? (max - min) / c.steps : (max - min) / 10
624          const pointer = binding(c.value, scope)
625          const to = (next: number) => write(pointer, Number(Math.min(max, Math.max(min, next)).toFixed(6)))
626          const width = 20
627          const filled = Math.round(((current - min) / (max - min)) * width)
628          return (
629            <Box flexDirection="row" gap={1}>
630              {c.label !== undefined && <Text>{str('label')}</Text>}
631              <Button key={`${key}.less`} plain label="◀" onPress={() => to(current - step)} />
632              <Text>{`${'━'.repeat(filled)}${'─'.repeat(width - filled)}`}</Text>
633              <Button key={`${key}.more`} plain label="▶" onPress={() => to(current + step)} />
634              <Text key={`${key}.value`}>{String(current)}</Text>
635            </Box>
636          )
637        }
638        case 'DateTimeInput': {
639          // Both flags default to off in v1.0; with neither set, offer both,
640          // as Roer's panel does. Typed as text, in the format the value keeps.
641          const date = c.enableDate === true
642          const time = c.enableTime === true
643          const format = date && !time ? 'YYYY-MM-DD' : time && !date ? 'HH:MM' : 'YYYY-MM-DDTHH:MM'
644          const pointer = binding(c.value, scope)
645          const range = [str('min'), str('max')].some(Boolean) ? ` (${str('min') || '…'} to ${str('max') || '…'})` : ''
646          if (Input === undefined) return <Text>{`${str('label')}: ${str('value') || format}`}</Text>
647          return (
648            <Input
649              key={key}
650              label={`${str('label') || 'When'}${range} `}
651              placeholder={format}
652              value={str('value')}
653              onInput={typed => write(pointer, typed)}
654              onSubmit={typed => write(pointer, typed)}
655            />
656          )
657        }
658        case 'DiffView': {
659          const patch = str('diff')
660          const title = str('title')
661          return (
662            <Box flexDirection="column">
663              {title !== '' && <Text bold>{title}</Text>}
664              {patch.trim() === ''
665                ? <Text dimColor>{c.emptyText === undefined ? 'No changes.' : str('emptyText')}</Text>
666                : diffWithNotes(surfaceId, at, patch, readNotes(value(c.notes)))}
667            </Box>
668          )
669        }
670        case 'Mermaid': {
671          // A terminal cannot lay a graph out: the pane shows the source,
672          // which is what the diagram is anyway, and the open threads under it.
673          const source = str('source')
674          const title = str('title')
675          const open = readDiagramThreads(value(c.notes)).filter(t => t.state === 'open')
676          return (
677            <Box flexDirection="column">
678              {title !== '' && <Text bold>{title}</Text>}
679              {source.trim() === ''
680                ? <Text dimColor>No diagram.</Text>
681                : <Code source={source.slice(0, CODE_LIMIT)} />}
682              {open.map(t => (
683                <Box flexDirection="column">
684                  <Text>{`💬 ${t.node} · ${t.author || 'Agent'}: ${t.text}`}</Text>
685                  {t.replies.map(reply => <Text dimColor>{`   ${reply.author}: ${reply.text}`}</Text>)}
686                </Box>
687              ))}
688            </Box>
689          )
690        }
691        case 'StatTile': {
692          const trend = typeof c.trend === 'object' && c.trend !== null ? (c.trend as { delta?: unknown; direction?: unknown }) : undefined
693          const up = trend?.direction === 'up'
694          const down = trend?.direction === 'down'
695          return (
696            <Box borderStyle="round" borderDimColor flexDirection="row" gap={1} paddingX={1}>
697              {c.icon !== undefined && <Text>{iconText(c.icon, model, scope)}</Text>}
698              <Box flexDirection="column" flexGrow={1}>
699                <Text bold>{str('value')}</Text>
700                <Text dimColor>{str('label')}</Text>
701              </Box>
702              {trend && (
703                <Text color={up ? 'green' : down ? 'red' : undefined} dimColor={!up && !down ? true : undefined}>
704                  {`${up ? '▲' : down ? '▼' : '•'} ${text(trend.delta, model, scope)}`}
705                </Text>
706              )}
707            </Box>
708          )
709        }
710        case 'StatusCard': {
711          const status = str('status')
712          const progress = c.progress === undefined ? undefined : progressValue(value(c.progress))
713          return (
714            <Box borderStyle="round" borderDimColor flexDirection="column" paddingX={1}>
715              <Box flexDirection="row" gap={1}>
716                {c.icon !== undefined && <Text>{iconText(c.icon, model, scope)}</Text>}
717                <Box flexGrow={1}>{link(str('url'), str('title'))}</Box>
718                {status !== '' && <Text color={toneColor(statusTone(status))}>{status}</Text>}
719              </Box>
720              {c.subtitle !== undefined && <Text dimColor>{str('subtitle')}</Text>}
721              {progress !== undefined && <Text key={`${key}.progress`}>{bar(progress)}</Text>}
722              {c.meta !== undefined && <Text dimColor>{str('meta')}</Text>}
723              {one(c.footer)}
724            </Box>
725          )
726        }
727        case 'WorkItem': {
728          const status = str('status')
729          const source = str('source')
730          const itemKey = str('key')
731          const assignee = str('assignee')
732          const labels = value(c.labels)
733          const tags = Array.isArray(labels) ? labels.map(String) : []
734          const origin = [SOURCE_NAMES[source.toLowerCase()] ?? source, itemKey].filter(Boolean).join(' ')
735          return (
736            <Box borderStyle="round" borderDimColor flexDirection="column" paddingX={1}>
737              <Box flexDirection="row" gap={1}>
738                {origin !== '' && <Text dimColor>{origin}</Text>}
739                <Box flexGrow={1}>{link(str('url'), str('title'))}</Box>
740                {status !== '' && <Text color={toneColor(statusTone(status))}>{status}</Text>}
741              </Box>
742              {(assignee !== '' || tags.length > 0) && (
743                <Box flexDirection="row" gap={1} flexWrap="wrap">
744                  {assignee !== '' && <Text>{`@${assignee}`}</Text>}
745                  {tags.map(tag => <Text dimColor>{`[${tag}]`}</Text>)}
746                </Box>
747              )}
748              {c.meta !== undefined && <Text dimColor>{str('meta')}</Text>}
749              {one(c.footer)}
750              {c.variant === 'detail' && detail(surfaceId, surface, c, scope, itemKey)}
751            </Box>
752          )
753        }
754        case 'Requirements':
755          return requirements(surfaceId, surface, c, scope, readRequirements(value(c.items)), undefined)
756        case 'Findings':
757          return findings(surfaceId, surface, c, scope, readFindings(value(c.items)), undefined)
758        case 'Decisions':
759          return decisions(surfaceId, surface, c, scope, readDecisions(value(c.items)), undefined)
760        case 'Sources':
761          return section('Sources', undefined, sources(readSources(value(c.items))))
762        case 'Comments':
763          return section('Comments', undefined, comments(readComments(value(c.items))))
764        default:
765          return <Text dimColor>{`[${c.component}: not in roer:catalog/1]`}</Text>
766      }
767    }
768
769    // A work item opened up, in the order Roer's panel draws it: its goal,
770    // what needs the person, the requirements, where it came from, the
771    // discussion, and what changed.
772    const detail = (surfaceId: string, surface: RoerUiSurface, c: RoerUiComponent, scope: string, itemKey: string) => {
773      const value = (v: unknown) => resolve(v, surface.dataModel, scope)
774      const goal = text(c.goal, surface.dataModel, scope)
775      // The agent's lists, and what is shown: the person's values over them.
776      // A second change compares against the agent's, never the first change.
777      const rawFound = readFindings(value(c.findings))
778      const rawAsked = readDecisions(value(c.decisions))
779      const found = localized(surface, c, scope, 'state', rawFound)
780      const asked = localized(surface, c, scope, 'answer', rawAsked)
781      const reqs = readRequirements(value(c.requirements))
782      const srcs = readSources(value(c.sources))
783      const talk = readComments(value(c.comments))
784      const changes = readChanges(value(c.changes))
785      const waiting = asked.filter(d => d.answer === undefined).length + found.filter(f => f.state === 'open').length
786      return (
787        <Box flexDirection="column" gap={1} marginTop={1}>
788          {goal !== '' && <Text>{goal}</Text>}
789          {asked.length + found.length > 0 &&
790            section('Needs you', waiting > 0 ? String(waiting) : 'nothing open', [
791              decisionRows(surfaceId, c, scope, asked, itemKey, rawAsked),
792              findingRows(surfaceId, c, scope, found, itemKey, rawFound),
793            ])}
794          {reqs.length > 0 && requirements(surfaceId, surface, c, scope, reqs, itemKey)}
795          {srcs.length > 0 && section('Sources', undefined, sources(srcs))}
796          {talk.length > 0 && section('Comments', undefined, comments(talk))}
797          {changes.length > 0 &&
798            section(
799              'Changes',
800              undefined,
801              changes.map(one => {
802                const at = `change:${c.id}${scope}:${one.id}`
803                const set = own(surface.view ?? {}, at)
804                const open = typeof set === 'boolean' ? set : changes.length === 1
805                // An open finding about a line of this change is drawn there too.
806                const notes = [
807                  ...(one.notes ?? []),
808                  ...found
809                    .filter(f => f.state === 'open' && f.at?.changeId === one.id)
810                    .map(f => ({ path: f.at!.path, line: f.at!.line, text: `${f.severity}: ${f.text}` })),
811                ]
812                return (
813                  <Box flexDirection="column">
814                    <Button key={`${surfaceId}.${c.id}${scope}.change.${one.id}`} plain label={`${open ? '▾' : '▸'} ${one.title}`} onPress={() => void setView(surfaceId, at, !open)} />
815                    {open && <Box paddingLeft={2} flexDirection="column">{diffWithNotes(surfaceId, at, one.patch, notes)}</Box>}
816                  </Box>
817                )
818              }),
819            )}
820        </Box>
821      )
822    }
823
824    // The person's own values over the agent's, as long as the agent's value
825    // is still the one they replaced. See `shownValue`.
826    const localized = <T extends { id: string }, K extends keyof T>(surface: RoerUiSurface, c: RoerUiComponent, scope: string, field: K, items: T[]): T[] =>
827      items.map(item => ({ ...item, [field]: shownValue(own(surface.view ?? {}, `local:${c.id}${scope}:${String(field)}:${item.id}`), item[field]) }))
828    const answer = (surfaceId: string, c: RoerUiComponent, scope: string, field: string, id: string, base: unknown, next: unknown) =>
829      setView(surfaceId, `local:${c.id}${scope}:${field}:${id}`, { base: base ?? null, value: next })
830    // As a button's event would: the item's key rides along as `workItem`
831    // when the section is part of one.
832    const report = (surfaceId: string, c: RoerUiComponent, name: string, context: Record<string, unknown>, itemKey: string | undefined) =>
833      send(surfaceId, c.id, name, itemKey === undefined ? context : { workItem: itemKey, ...context }, `The person did ${name} in the ${surfaceId} UI.`)
834
835    const section = (title: string, aside: string | undefined, body: RenderChildren) => (
836      <Box flexDirection="column">
837        <Text bold>{aside === undefined ? title : `${title} · ${aside}`}</Text>
838        {body}
839      </Box>
840    )
841
842    const requirements = (surfaceId: string, surface: RoerUiSurface, c: RoerUiComponent, scope: string, raw: Requirement[], itemKey: string | undefined) => {
843      const items = localized(surface, c, scope, 'met', raw)
844      const met = items.filter(r => r.met).length
845      return section(
846        'Requirements',
847        `${met} of ${items.length}`,
848        items.map((r, n) => (
849          <Button
850            key={`${surfaceId}.${c.id}${scope}.req.${r.id}`}
851            plain
852            label={`${r.met ? '[x]' : '[ ]'} ${r.text}`}
853            dimColor={r.met ? true : undefined}
854            onPress={() => {
855              void answer(surfaceId, c, scope, 'met', r.id, raw[n]?.met, !r.met)
856              void report(surfaceId, c, 'toggleRequirement', { id: r.id, met: !r.met }, itemKey)
857            }}
858          />
859        )),
860      )
861    }
862
863    const findings = (surfaceId: string, surface: RoerUiSurface, c: RoerUiComponent, scope: string, raw: Finding[], itemKey: string | undefined) =>
864      section('Findings', undefined, findingRows(surfaceId, c, scope, localized(surface, c, scope, 'state', raw), itemKey, raw))
865
866    // Open ones first: they are what the section is for.
867    const findingRows = (surfaceId: string, c: RoerUiComponent, scope: string, items: Finding[], itemKey: string | undefined, raw: Finding[]) => {
868      const sorted = [...items.filter(f => f.state === 'open'), ...items.filter(f => f.state !== 'open')]
869      const base = (id: string) => raw.find(f => f.id === id)?.state
870      const settle = (f: Finding, state: FindingState) => {
871        void answer(surfaceId, c, scope, 'state', f.id, base(f.id) ?? f.state, state)
872        void report(surfaceId, c, 'settleFinding', { id: f.id, state }, itemKey)
873      }
874      return sorted.map(f => {
875        const keyOf = `${surfaceId}.${c.id}${scope}.finding.${f.id}`
876        return (
877          <Box flexDirection="column">
878            <Box flexDirection="row" gap={1}>
879              <Text color={f.severity === 'error' ? 'red' : f.severity === 'warn' ? 'yellow' : 'cyan'} dimColor={f.state === 'open' ? undefined : true}>
880                {f.severity}
881              </Text>
882              <Text dimColor={f.state === 'open' ? undefined : true}>{f.text}</Text>
883              {f.at && <Text dimColor>{`${f.at.path}:${f.at.line}`}</Text>}
884            </Box>
885            <Box flexDirection="row" gap={2} paddingLeft={2}>
886              {f.state === 'open' ? (
887                [
888                  <Button key={`${keyOf}.resolve`} plain label="Resolve" onPress={() => settle(f, 'resolved')} />,
889                  <Button key={`${keyOf}.dismiss`} plain dimColor label="Dismiss" onPress={() => settle(f, 'dismissed')} />,
890                ]
891              ) : (
892                [<Text dimColor>{f.state}</Text>, <Button key={`${keyOf}.reopen`} plain dimColor label="Reopen" onPress={() => settle(f, 'open')} />]
893              )}
894            </Box>
895          </Box>
896        )
897      })
898    }
899
900    const decisions = (surfaceId: string, surface: RoerUiSurface, c: RoerUiComponent, scope: string, raw: Decision[], itemKey: string | undefined) =>
901      section('Decisions', undefined, decisionRows(surfaceId, c, scope, localized(surface, c, scope, 'answer', raw), itemKey, raw))
902
903    // An option pressed is the answer, as is anything written under Other.
904    // An answered one shows what it was, with Change to answer again.
905    const decisionRows = (surfaceId: string, c: RoerUiComponent, scope: string, items: Decision[], itemKey: string | undefined, raw: Decision[]) =>
906      items.map(d => {
907        const keyOf = `${surfaceId}.${c.id}${scope}.decision.${d.id}`
908        const editing = own(own(bySurface, surfaceId)?.view ?? {}, `editing:${c.id}${scope}:${d.id}`) === true
909        const base = raw.find(x => x.id === d.id)?.answer
910        const decide = (value: string) => {
911          if (value.trim() === '') return
912          void answer(surfaceId, c, scope, 'answer', d.id, base, value.trim())
913          void setView(surfaceId, `editing:${c.id}${scope}:${d.id}`, false)
914          void report(surfaceId, c, 'answerDecision', { id: d.id, answer: value.trim() }, itemKey)
915        }
916        if (d.answer !== undefined && d.answer !== null && !editing) {
917          const label = d.options.find(o => o.value === d.answer)?.label ?? d.answer
918          return (
919            <Box flexDirection="column">
920              <Text>{d.question}</Text>
921              <Box flexDirection="row" gap={2} paddingLeft={2}>
922                <Text dimColor>{`Answered: ${label}`}</Text>
923                <Button key={`${keyOf}.change`} plain dimColor label="Change" onPress={() => void setView(surfaceId, `editing:${c.id}${scope}:${d.id}`, true)} />
924              </Box>
925            </Box>
926          )
927        }
928        return (
929          <Box flexDirection="column">
930            <Text>{d.question}</Text>
931            <Box flexDirection="column" paddingLeft={2}>
932              {d.options.map(o => (
933                <Button key={`${keyOf}.${o.value}`} plain label={`${d.answer === o.value ? '(•)' : '( )'} ${o.label}`} onPress={() => decide(o.value)} />
934              ))}
935              {Input !== undefined && <Input key={`${keyOf}.other`} label="Other " placeholder="your own answer" onSubmit={typed => decide(typed)} />}
936            </Box>
937          </Box>
938        )
939      })
940
941    const sources = (items: SourceRef[]) =>
942      items.map(s => {
943        // A path is the project's, with no file viewer here to open it in:
944        // it is named. Only https leaves for the browser.
945        const label = `${s.kind}  ${s.label}`
946        return s.url?.startsWith('https://') ? (
947          <Link href={s.url} label={label} />
948        ) : (
949          <Text>{s.path ? `${label}  ${s.path}` : label}</Text>
950        )
951      })
952
953    const comments = (items: Comment[]) =>
954      items.map(one => (
955        <Box flexDirection="column">
956          <Box flexDirection="row" gap={1}>
957            <Text bold>{one.author}</Text>
958            {one.at !== undefined && <Text dimColor>{one.at}</Text>}
959          </Box>
960          <Text>{one.text}</Text>
961        </Box>
962      ))
963
964    // The engine refuses a drawing of more than 100000 characters of text,
965    // so the diffs on screen share a budget under that, leaving the rest
966    // room. Each drawing starts it over.
967    let diffRoom = DIFF_BUDGET
968
969    // A whole `git diff`, one file at a time under a header that opens and
970    // closes it, each file's notes under it: by line where they name one,
971    // first where they do not. The files the person opened take the budget
972    // first, then the rest are open while they fit; an open one that does
973    // not fit says so.
974    const diffWithNotes = (surfaceId: string, at: string, patch: string, notes: Note[]) => {
975      const files = splitPatch(patch).map(file => {
976        const where = `file:${at}:${file.path}`
977        const set = own(own(bySurface, surfaceId)?.view ?? {}, where)
978        return { ...file, where, set: typeof set === 'boolean' ? set : undefined, drawn: false }
979      })
980      for (const asked of [true, undefined])
981        for (const file of files)
982          if (file.set === asked && file.text.length <= diffRoom) {
983            file.drawn = true
984            diffRoom -= file.text.length
985          }
986      return files.map(file => {
987        const mine = notes.filter(note => note.path === file.path)
988        const where = file.where
989        const open = file.set ?? file.drawn
990        const fits = file.drawn
991        const { added, removed } = counts(file.text)
992        return (
993          <Box flexDirection="column">
994            <Button key={`${surfaceId}.${at}.file.${file.path}`} plain label={`${open ? '▾' : '▸'} ${file.path}  +${added} −${removed}`} onPress={() => void setView(surfaceId, where, !open)} />
995            {open &&
996              (fits ? (
997                diffPieces(file.text).map(text => <Code source={text} format="diff" path={file.path} />)
998              ) : (
999                <Text dimColor>{'  Too long to draw with the other open files: close one to see this one.'}</Text>
1000              ))}
1001            {mine
1002              .sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
1003              .map(note => (
1004                <Text color="yellow">{`  ${note.line === undefined ? file.path : `${file.path}:${note.line}${note.side === 'old' ? ' (old)' : ''}`}  ${note.text}`}</Text>
1005              ))}
1006          </Box>
1007        )
1008      })
1009    }
1010
1011    const drawing = () => {
1012      diffRoom = DIFF_BUDGET
1013      return (
1014      <Box flexDirection="column" gap={1}>
1015        {order.flatMap(surfaceId => {
1016          const surface = own(bySurface, surfaceId) as RoerUiSurface
1017          if (surface.hidden === true) return []
1018          const loading = own(surface.view ?? {}, 'loading') as { token?: string; failed?: string } | undefined
1019          return [
1020            <Box flexDirection="column">
1021              {loading?.token !== undefined && <Text key={`${surfaceId}.loading`} dimColor>Loading…</Text>}
1022              {loading?.failed !== undefined && <Text key={`${surfaceId}.failed`} color="red">{loading.failed}</Text>}
1023              {draw(surfaceId, surface, 'root', '', new Set())}
1024            </Box>,
1025          ]
1026        })}
1027      </Box>
1028      )
1029    }
1030    const first = drawing()
1031    if (unchecked.size === 0) return first
1032    const asked = await Promise.all([...unchecked].map(command => $.tool.check({ tool: 'Bash', input: { command } })))
1033    for (const [n, command] of [...unchecked].entries()) verdicts.set(command, asked[n]?.decision ?? 'ask')
1034    return drawing()
1035  })
1036}
1037
1038function justify(value: unknown) {
1039  switch (value) {
1040    case 'center':
1041      return 'center'
1042    case 'end':
1043      return 'flex-end'
1044    case 'spaceBetween':
1045      return 'space-between'
1046    case 'spaceAround':
1047      return 'space-around'
1048    case 'spaceEvenly':
1049      return 'space-evenly'
1050    default:
1051      return undefined
1052  }
1053}
1054
1055function align(value: unknown) {
1056  switch (value) {
1057    case 'start':
1058      return 'flex-start'
1059    case 'center':
1060      return 'center'
1061    case 'end':
1062      return 'flex-end'
1063    case 'stretch':
1064      return 'stretch'
1065    default:
1066      return undefined
1067  }
1068}
1069
1070/** A Button's label: the text of its child, when that is a Text. */
1071function labelOf(child: RoerUiComponent | undefined, model: unknown, scope: string): string {
1072  return child?.component === 'Text' ? text(child.text, model, scope) : ''
1073}
1074
1075/** An icon's name as text, or a mark for a 24×24 svg path, which a terminal
1076 * cannot draw. */
1077function iconText(icon: unknown, model: unknown, scope: string): string {
1078  return typeof icon === 'object' && icon !== null && 'svgPath' in icon ? '◆' : text(icon, model, scope)
1079}
1080
1081const TONE_COLORS = { done: 'green', doing: 'cyan', blocked: 'yellow', failed: 'red', todo: undefined } as const
1082const toneColor = (tone: Tone) => TONE_COLORS[tone]
1083
1084/** A progress bar: 0–100 across twenty cells. */
1085function bar(progress: number): string {
1086  const filled = Math.round(progress / 5)
1087  return `${'█'.repeat(filled)}${'░'.repeat(20 - filled)} ${Math.round(progress)}%`
1088}
1089
1090/** The engine refuses a Code source past 10000 characters. */
1091const CODE_LIMIT = 10_000
1092
1093/** Characters of diff one drawing holds, under the engine's 100000 for all
1094 * its text. */
1095const DIFF_BUDGET = 60_000
1096
1097/** A file's diff's added and removed lines, read from its first hunk on. */
1098function counts(text: string): { added: number; removed: number } {
1099  let added = 0
1100  let removed = 0
1101  let hunks = false
1102  for (const line of text.split('\n')) {
1103    if (line.startsWith('@@')) hunks = true
1104    else if (hunks && line.startsWith('+')) added++
1105    else if (hunks && line.startsWith('-')) removed++
1106  }
1107  return { added, removed }
1108}
1109
1110/** One file's diff in pieces a Code takes: each a whole diff of its own,
1111 * the file's header over as many hunks as fit. A hunk too long for one
1112 * piece is cut into hunks with their own counts, and a line too long for
1113 * any is cut short. */
1114function diffPieces(text: string): string[] {
1115  if (text.length <= CODE_LIMIT) return [text]
1116  const lines = text.split('\n')
1117  const first = lines.findIndex(line => line.startsWith('@@'))
1118  if (first < 0) return [text.slice(0, CODE_LIMIT)]
1119  const header = lines.slice(0, first).join('\n')
1120  // Room for a hunk under the header, and for its lines under an @@ line.
1121  const room = CODE_LIMIT - header.length - 1
1122  const body = room - 64
1123  const hunks: string[] = []
1124  let old = 0
1125  let now = 0
1126  let rows: string[] = []
1127  let size = 0
1128  let from = { old: 0, now: 0 }
1129  const flush = () => {
1130    if (rows.length === 0) return
1131    const oldCount = rows.filter(row => !row.startsWith('+') && !row.startsWith('\\')).length
1132    const nowCount = rows.filter(row => !row.startsWith('-') && !row.startsWith('\\')).length
1133    // An empty side names the line before it, as git writes it.
1134    const at = (start: number, count: number) => `${count === 0 ? start - 1 : start},${count}`
1135    hunks.push(`@@ -${at(from.old, oldCount)} +${at(from.now, nowCount)} @@\n${rows.join('\n')}`)
1136    rows = []
1137    size = 0
1138  }
1139  for (const line of lines.slice(first)) {
1140    const opened = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line)
1141    if (opened) {
1142      flush()
1143      old = Number(opened[1]) + (opened[2] === '0' ? 1 : 0)
1144      now = Number(opened[3]) + (opened[4] === '0' ? 1 : 0)
1145      from = { old, now }
1146      continue
1147    }
1148    const row = line.length > body ? `${line.slice(0, body - 1)}…` : line
1149    if (size + row.length + 1 > body) {
1150      flush()
1151      from = { old, now }
1152    }
1153    rows.push(row)
1154    size += row.length + 1
1155    if (!line.startsWith('+') && !line.startsWith('\\')) old++
1156    if (!line.startsWith('-') && !line.startsWith('\\')) now++
1157  }
1158  flush()
1159  const pieces: string[] = []
1160  let piece = ''
1161  for (const hunk of hunks) {
1162    if (piece !== '' && piece.length + 1 + hunk.length > room) {
1163      pieces.push(`${header}\n${piece}`)
1164      piece = ''
1165    }
1166    piece = piece === '' ? hunk : `${piece}\n${hunk}`
1167  }
1168  if (piece !== '') pieces.push(`${header}\n${piece}`)
1169  return pieces
1170}
1171
1172/** A whole `git diff` split into its files, each with its own header. */
1173function splitPatch(patch: string): { path: string; text: string }[] {
1174  const files: { path: string; text: string }[] = []
1175  for (const part of patch.split(/^(?=diff --git )/m)) {
1176    if (part.trim() === '') continue
1177    const header = /^diff --git a\/(.+?) b\/(.+)$/m.exec(part)
1178    const plus = /^\+\+\+ b\/(.+)$/m.exec(part)
1179    files.push({ path: plus?.[1] ?? header?.[2] ?? '', text: part.replace(/\n$/, '') })
1180  }
1181  return files
1182}
1183
hooks/a2ui.ts 209 lines
1// The A2UI v1.0 reducer and data binding, cut down from Roer's own
2// (src/generative-ui/apply.ts, evaluate.ts) to what the pane draws. Pure:
3// every function takes the surfaces and gives back new ones.
4
5import type { RoerUiComponent, RoerUiSurface, RoerUiSurfaces } from '../types'
6
7export const EMPTY: RoerUiSurfaces = { order: [], bySurface: {} }
8
9type Message = Record<string, unknown>
10
11const isRecord = (v: unknown): v is Record<string, unknown> =>
12  typeof v === 'object' && v !== null && !Array.isArray(v)
13
14const KINDS = ['createSurface', 'updateComponents', 'updateDataModel', 'deleteSurface']
15
16/** Applies one message, or says why it was refused. The checks are
17 * isA2uiMessage's (src/generative-ui/schema.ts): a malformed message is
18 * refused whole, never shown in part. */
19export function apply(surfaces: RoerUiSurfaces, message: unknown): RoerUiSurfaces | string {
20  if (!isRecord(message)) return 'a message is not an object'
21  if (message.version !== 'v1.0') return `version is ${JSON.stringify(message.version)}, not "v1.0"`
22  const kinds = Object.keys(message).filter(key => key !== 'version')
23  if (kinds.length !== 1) return `a message carries exactly one of ${KINDS.join(', ')}, not ${kinds.length}`
24  const kind = kinds[0] as string
25  const body = (message as Message)[kind]
26  if (!KINDS.includes(kind)) return `the message has none of ${KINDS.join(', ')} (it has ${kind})`
27  if (!isRecord(body)) return `${kind} is not an object`
28  const { surfaceId } = body
29  if (typeof surfaceId !== 'string' || surfaceId === '') return `${kind} needs a surfaceId`
30
31  if (kind === 'createSurface') {
32    const { components, dataModel, sendDataModel, hidden } = body
33    if (components !== undefined && !isComponentList(components)) return badComponents(kind)
34    if (dataModel !== undefined && !isRecord(dataModel)) return 'createSurface.dataModel is not an object'
35    const surface: RoerUiSurface = {
36      components: indexed(components ?? []),
37      dataModel: dataModel ?? {},
38      sendDataModel: sendDataModel === true,
39      // Roer-only (see local.ts): kept, not drawn, until a press opens it.
40      ...(hidden === true ? { hidden: true } : {}),
41    }
42    return {
43      order: [...surfaces.order.filter(id => id !== surfaceId), surfaceId],
44      bySurface: { ...surfaces.bySurface, [surfaceId]: surface },
45    }
46  }
47  if (kind === 'deleteSurface') {
48    const { [surfaceId]: _gone, ...bySurface } = surfaces.bySurface
49    return { order: surfaces.order.filter(id => id !== surfaceId), bySurface }
50  }
51
52  const surface = own(surfaces.bySurface, surfaceId)
53  if (!surface) return `${kind} names no surface on screen (${surfaceId})`
54  if (kind === 'updateComponents') {
55    if (!isComponentList(body.components)) return badComponents(kind)
56    return put(surfaces, surfaceId, {
57      ...surface,
58      components: { ...surface.components, ...indexed(body.components) },
59    })
60  }
61  // updateDataModel. `value` is required even when it is null, the deletion
62  // sentinel: a message without one is malformed, not a delete.
63  if (!('value' in body)) return 'updateDataModel needs a value (null deletes)'
64  const { path, value } = body
65  if (path !== undefined && typeof path !== 'string') return 'updateDataModel.path is not a string'
66  return put(surfaces, surfaceId, { ...surface, dataModel: setAt(surface.dataModel, path ?? '', value) })
67}
68
69const badComponents = (kind: string) => `${kind}.components is not a list of { id, component } entries`
70
71function isComponentList(list: unknown): list is RoerUiComponent[] {
72  return (
73    Array.isArray(list) &&
74    list.every(one => isRecord(one) && typeof one.id === 'string' && typeof one.component === 'string')
75  )
76}
77
78function put(surfaces: RoerUiSurfaces, id: string, surface: RoerUiSurface): RoerUiSurfaces {
79  return { ...surfaces, bySurface: { ...surfaces.bySurface, [id]: surface } }
80}
81
82/** Components by id. From entries, so every wire id is data: assigning
83 * `out['__proto__']` would set the index's prototype instead of an entry. */
84const indexed = (list: RoerUiComponent[]): Record<string, RoerUiComponent> =>
85  Object.fromEntries(list.map(one => [one.id, one]))
86
87/** `record[key]` when it is the record's own, never something inherited: a
88 * wire id like `__proto__` or `toString` names nothing unless it was sent. */
89export const own = <T>(record: Record<string, T>, key: string): T | undefined =>
90  Object.prototype.hasOwnProperty.call(record, key) ? record[key] : undefined
91
92/** A pointer made absolute: relative ones (no leading `/`) hang off `scope`,
93 * the current template item. */
94export function absolute(scope: string, path: string): string {
95  if (path.startsWith('/')) return path
96  return path === '' ? scope : `${scope}/${path}`
97}
98
99/** Keys that would reach an object's prototype rather than its own data. */
100const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype'])
101
102/** A pointer's unescaped segments. The leading `/` is optional: a relative
103 * pointer (`title`) names the same member as `/title`. */
104const tokens = (pointer: string): string[] =>
105  pointer === ''
106    ? []
107    : (pointer.startsWith('/') ? pointer.slice(1) : pointer).split('/').map(t => t.replace(/~1/g, '/').replace(/~0/g, '~'))
108
109export function getAt(model: unknown, pointer: string): unknown {
110  let at = model
111  for (const token of tokens(pointer)) {
112    if (FORBIDDEN.has(token) || typeof at !== 'object' || at === null) return undefined
113    if (!Object.prototype.hasOwnProperty.call(at, token)) return undefined
114    at = (at as Record<string, unknown>)[token]
115  }
116  return at
117}
118
119/** Writes `value` at `pointer`, copying on the way down; `null` (or
120 * `undefined`) deletes, as updateDataModel defines it. As writePointer in
121 * src/generative-ui/schema.ts. */
122export function setAt(model: Record<string, unknown>, pointer: string, value: unknown): Record<string, unknown> {
123  const path = tokens(pointer)
124  if (path.length === 0) return isRecord(value) ? value : {}
125  if (path.some(token => FORBIDDEN.has(token))) return model
126  return writeAt(model, path, value) as Record<string, unknown>
127}
128
129function writeAt(at: unknown, [head, ...rest]: string[], value: unknown): unknown {
130  const token = head as string
131  const remove = value === null || value === undefined
132  if (Array.isArray(at)) {
133    const index = token === '-' ? at.length : Number(token)
134    if (!Number.isInteger(index) || index < 0) return at
135    // Deleting through a parent that isn't there leaves the model alone.
136    if (rest.length > 0 && remove && index >= at.length) return at
137    const copy = [...at]
138    if (rest.length > 0) copy[index] = writeAt(copy[index], rest, value)
139    else if (remove) copy.splice(index, 1)
140    else copy[index] = value
141    return copy
142  }
143  const base = isRecord(at) ? at : {}
144  if (rest.length > 0) {
145    if (remove && !Object.prototype.hasOwnProperty.call(base, token)) return at
146    return { ...base, [token]: writeAt(base[token], rest, value) }
147  }
148  if (remove) {
149    const { [token]: _gone, ...kept } = base
150    return kept
151  }
152  return { ...base, [token]: value }
153}
154
155/** A dynamic value: a literal, `{ path }` read from the data model, or a
156 * `{ call }`. `@index` is the only function, as in Roer's evaluate.ts: the
157 * template item's 0-based position, plus `args.offset`. Any other call, and
158 * `@index` outside a template, resolves to nothing. */
159export function resolve(value: unknown, model: unknown, scope: string): unknown {
160  if (isRecord(value) && typeof value.path === 'string') return getAt(model, absolute(scope, value.path))
161  if (isRecord(value) && typeof value.call === 'string') {
162    if (value.call !== '@index') return undefined
163    const index = indexIn(scope)
164    if (index === undefined) return undefined
165    const args = isRecord(value.args) ? value.args : {}
166    const offset = Number(resolve(args.offset, model, scope) ?? 0)
167    return index + (Number.isFinite(offset) ? offset : 0)
168  }
169  return value
170}
171
172/** The position of the template item `scope` points at: its last segment,
173 * when that is an array index. */
174function indexIn(scope: string): number | undefined {
175  const last = scope.slice(scope.lastIndexOf('/') + 1)
176  return scope !== '' && /^\d+$/.test(last) ? Number(last) : undefined
177}
178
179/** An action's context with each value resolved. From entries, as Roer's
180 * panel builds it: assigning a `__proto__` key would set the object's
181 * prototype and drop that value from the action. */
182export const contextOf = (context: unknown, model: unknown, scope: string): Record<string, unknown> =>
183  isRecord(context)
184    ? Object.fromEntries(Object.entries(context).map(([name, value]) => [name, resolve(value, model, scope)]))
185    : {}
186
187export const text = (value: unknown, model: unknown, scope: string): string => {
188  const v = resolve(value, model, scope)
189  return v === undefined || v === null ? '' : typeof v === 'string' ? v : JSON.stringify(v)
190}
191
192/** The bound pointer behind an input's value, when it is bound. */
193export function binding(value: unknown, scope: string): string | undefined {
194  return isRecord(value) && typeof value.path === 'string' ? absolute(scope, value.path) : undefined
195}
196
197/** A ChildList as (component id, scope) pairs: fixed ids share the scope,
198 * a template repeats one component over each item of a bound list. */
199export function children(list: unknown, model: unknown, scope: string): [string, string][] {
200  if (Array.isArray(list)) return list.filter((id): id is string => typeof id === 'string').map(id => [id, scope])
201  if (isRecord(list) && typeof list.componentId === 'string' && typeof list.path === 'string') {
202    const at = absolute(scope, list.path)
203    const items = getAt(model, at)
204    const keys = Array.isArray(items) ? items.map((_, i) => String(i)) : isRecord(items) ? Object.keys(items) : []
205    return keys.map(key => [list.componentId as string, `${at}/${key}`])
206  }
207  return []
208}
209
hooks/workItem.ts 178 lines
1// What a work item's detail view is made of, and how each piece is read off
2// the wire: Roer's own readers (src/generative-ui/workItem.ts), and the
3// status and progress rules its cards use (GenerativeSurface.tsx). Every
4// reader keeps what is shaped right and drops the rest, one element at a
5// time: a typo in one finding costs that finding, not the whole view.
6
7export type Requirement = { id: string; text: string; met: boolean }
8export type SourceRef = { kind: 'ticket' | 'slack' | 'doc' | 'file'; label: string; url?: string; path?: string }
9export type Note = { path: string; text: string; line?: number; side?: 'old' }
10export type ChangeRef = { id: string; title: string; patch: string; notes?: Note[] }
11export type FindingState = 'open' | 'resolved' | 'dismissed'
12export type Finding = {
13  id: string
14  severity: 'info' | 'warn' | 'error'
15  text: string
16  at?: { changeId: string; path: string; line: number; side?: 'old' }
17  state: FindingState
18}
19export type DiagramThread = { id: string; node: string; author: string; text: string; replies: { author: string; text: string }[]; state: 'open' | 'resolved' }
20export type Comment = { id: string; author: string; text: string; at?: string }
21export type Decision = { id: string; question: string; options: { label: string; value: string }[]; answer?: string }
22
23type Wire = Record<string, unknown>
24
25const isWire = (v: unknown): v is Wire => typeof v === 'object' && v !== null && !Array.isArray(v)
26const str = (v: unknown): string | undefined => (typeof v === 'string' ? v : undefined)
27const each = <T>(v: unknown, read: (w: Wire) => T | undefined): T[] =>
28  Array.isArray(v) ? v.flatMap(w => (isWire(w) ? (read(w) ?? []) : [])) : []
29
30export const readRequirements = (v: unknown): Requirement[] =>
31  each(v, w => {
32    const id = str(w.id)
33    const text = str(w.text)
34    return id && text ? { id, text, met: w.met === true } : undefined
35  })
36
37const SOURCE_KINDS = ['ticket', 'slack', 'doc', 'file'] as const
38
39export const readSources = (v: unknown): SourceRef[] =>
40  each(v, w => {
41    const label = str(w.label)
42    if (!label) return undefined
43    const kind = SOURCE_KINDS.find(k => k === w.kind) ?? (str(w.path) ? 'file' : 'doc')
44    const url = str(w.url)
45    const path = str(w.path)
46    return { kind, label, ...(url ? { url } : {}), ...(path ? { path } : {}) }
47  })
48
49export const readComments = (v: unknown): Comment[] =>
50  each(v, w => {
51    const id = str(w.id)
52    const author = str(w.author)
53    const text = str(w.text)
54    if (!id || !author || !text) return undefined
55    const at = str(w.at)
56    return { id, author, text, ...(at ? { at } : {}) }
57  })
58
59export const readDiagramThreads = (v: unknown): DiagramThread[] =>
60  each(v, w => {
61    const id = str(w.id)
62    const node = str(w.node)
63    const text = str(w.text)
64    if (!id || !node || !text) return undefined
65    const replies = each(w.replies, r => {
66      const author = str(r.author)
67      const said = str(r.text)
68      return author !== undefined && said !== undefined ? { author, text: said } : undefined
69    })
70    return { id, node, author: str(w.author) ?? '', text, replies, state: w.state === 'resolved' ? 'resolved' : 'open' }
71  })
72
73export const readNotes = (v: unknown): Note[] =>
74  each(v, w => {
75    const path = str(w.path)
76    const text = str(w.text)
77    if (!path || !text) return undefined
78    return {
79      path,
80      text,
81      ...(typeof w.line === 'number' ? { line: w.line } : {}),
82      ...(w.side === 'old' ? { side: 'old' as const } : {}),
83    }
84  })
85
86export const readChanges = (v: unknown): ChangeRef[] =>
87  each(v, w => {
88    const id = str(w.id)
89    const patch = str(w.patch)
90    if (!id || patch === undefined) return undefined
91    const notes = readNotes(w.notes)
92    return { id, title: str(w.title) ?? id, patch, ...(notes.length > 0 ? { notes } : {}) }
93  })
94
95const SEVERITIES = ['info', 'warn', 'error'] as const
96const FINDING_STATES = ['open', 'resolved', 'dismissed'] as const
97
98export const readFindings = (v: unknown): Finding[] =>
99  each(v, w => {
100    const id = str(w.id)
101    const text = str(w.text)
102    if (!id || !text) return undefined
103    const at = isWire(w.at) ? w.at : undefined
104    const changeId = str(at?.changeId)
105    const path = str(at?.path)
106    const line = at?.line
107    return {
108      id,
109      text,
110      severity: SEVERITIES.find(s => s === w.severity) ?? 'info',
111      state: FINDING_STATES.find(s => s === w.state) ?? 'open',
112      ...(changeId && path && typeof line === 'number'
113        ? { at: { changeId, path, line, ...(at?.side === 'old' ? { side: 'old' as const } : {}) } }
114        : {}),
115    }
116  })
117
118export const readDecisions = (v: unknown): Decision[] =>
119  each(v, w => {
120    const id = str(w.id)
121    const question = str(w.question)
122    if (!id || !question) return undefined
123    const options = each(w.options, o => {
124      const value = str(o.value)
125      return value === undefined ? undefined : { value, label: str(o.label) ?? value }
126    })
127    const answer = str(w.answer)
128    return { id, question, options, ...(answer ? { answer } : {}) }
129  })
130
131/** How each tracker is named on an item; anything else is shown as sent. */
132export const SOURCE_NAMES: Record<string, string> = {
133  github: 'GitHub',
134  youtrack: 'YouTrack',
135  notion: 'Notion',
136  jira: 'Jira',
137  personal: 'Personal',
138}
139
140export type Tone = 'done' | 'doing' | 'blocked' | 'failed' | 'todo'
141
142/** A status, by what it means for the work rather than what a tracker calls
143 * it: every tracker spells "finished" its own way. */
144export function statusTone(status: string): Tone {
145  const s = status.toLowerCase().replace(/[\s_-]+/g, ' ').trim()
146  if (
147    ['done', 'closed', 'fixed', 'resolved', 'completed', 'merged', 'verified', 'success', 'succeeded', 'passed', 'deployed'].includes(s)
148  )
149    return 'done'
150  if (['doing', 'in progress', 'in review', 'active', 'started', 'review', 'working', 'running'].includes(s)) return 'doing'
151  if (['blocked', 'on hold', 'waiting'].includes(s)) return 'blocked'
152  if (['failed', 'failure', 'error', 'errored', 'broken'].includes(s)) return 'failed'
153  return 'todo'
154}
155
156/** Only a number or a numeric string counts as progress; anything else,
157 * `false`, `null` and `" "` included, draws no bar at all. */
158export function progressValue(raw: unknown): number | undefined {
159  const n = typeof raw === 'number' ? raw : typeof raw === 'string' && raw.trim() !== '' ? Number(raw) : NaN
160  return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined
161}
162
163/** A value the person set in the pane, shown only while the agent's value is
164 * still the one it replaced: the agent resending the old item does not undo
165 * the person's click, and the agent sending anything else wins. As
166 * `useLocal` in Roer's WorkItemDetail. */
167export type Local = { base: unknown; value: unknown }
168
169export function shownValue<T>(local: unknown, current: T): T {
170  if (typeof local !== 'object' || local === null || !('value' in local)) return current
171  const { base, value } = local as Local
172  return same(base, current) ? (value as T) : current
173}
174
175/** Plugin state keeps no `undefined`, so a missing value is stored as `null`
176 * and the two compare equal. */
177const same = (a: unknown, b: unknown) => JSON.stringify(a ?? null) === JSON.stringify(b ?? null)
178
hooks/local.ts 215 lines
1// What the pane does on its own, with no turn of the model's: Roer-only
2// additions to A2UI v1.0, none of them in Roer's panel yet.
3//
4// - A `loadData` message fills a data-model path from a command's output or
5//   a file, so the model names where data comes from instead of writing it.
6// - A Button's `action.local` loads data and opens another surface when it is
7//   pressed; `action.event`, beside it or not, still reaches the model.
8// - A surface created with `hidden: true` is kept but not drawn until a
9//   press opens it.
10//
11// Every command runs as the model's own Bash calls do, under the person's
12// permissions: what their rules and mode allow runs at once, what needs asking
13// opens the permission dialog, what they deny is refused (`runPermitted`).
14//
15// Pure but for `runPermitted` and `fetchLoad`, which reach the host through
16// closures register.tsx makes over `$`.
17
18import type { RoerUiSurfaces } from '../types'
19import { absolute, own, resolve } from './a2ui'
20
21/** Where a load's value comes from: a command's standard output (argv; a
22 * file is `cat` of it, so the same permissions decide), or, in a press, a
23 * value off the pressed item. */
24export type LoadSource = { run: string[] } | { value: unknown }
25
26export type Load = LoadSource & { surfaceId: string; path: string; as: 'text' | 'json' }
27
28export type LocalAction = { loads: Load[]; open?: string }
29
30const isRecord = (v: unknown): v is Record<string, unknown> =>
31  typeof v === 'object' && v !== null && !Array.isArray(v)
32
33/** Why a wire value did not resolve to what a load needs: what it came to,
34 * and for a binding, the pointer it read. Inside a template a pointer with
35 * a leading `/` reads the surface's root, not the item: said when that is
36 * the likely slip. */
37function unlike(wire: unknown, got: unknown, scope: string, wanted: string): string {
38  const came = got === undefined ? 'nothing' : got === null ? 'null' : Array.isArray(got) ? 'a list' : `a ${typeof got}`
39  const path = isRecord(wire) && typeof wire.path === 'string' ? wire.path : undefined
40  if (path === undefined) return `is ${came}, not ${wanted}`
41  const slip = scope !== '' && path.startsWith('/') ? `; inside a template, "${path.slice(1)}" is the item's own` : ''
42  return `{ "path": ${JSON.stringify(path)} } found ${came} at ${absolute(scope, path)}, not ${wanted}${slip}`
43}
44
45/** An id, a number or a path, as a bound argument must be: nothing a shell
46 * or a command would read as more, and no leading `-` to be an option. */
47const isToken = (v: unknown): boolean =>
48  (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && /^[\w@%+=:,./~#-]+$/.test(v) && !v.startsWith('-'))
49
50/** A load off the wire, each dynamic value resolved against `model` at
51 * `scope`, or why it is malformed. `surfaceId` is the load's own, else
52 * `fallback`. `value` is offered only where `allowValue` (a press: in a
53 * message, updateDataModel says it). */
54export function readLoad(
55  wire: unknown,
56  model: unknown,
57  scope: string,
58  fallback: string | undefined,
59  allowValue: boolean,
60): Load | string {
61  if (!isRecord(wire)) return 'a load is not an object'
62  const surfaceId = wire.surfaceId === undefined ? fallback : wire.surfaceId
63  if (typeof surfaceId !== 'string' || surfaceId === '') return 'a load needs a surfaceId'
64  if (typeof wire.path !== 'string') return 'a load needs a path'
65  const as = wire.as ?? 'text'
66  if (as !== 'text' && as !== 'json') return `a load's as is ${JSON.stringify(as)}, not "text" or "json"`
67  const sources = ['run', 'file', ...(allowValue ? ['value'] : [])].filter(key => key in wire)
68  if (sources.length !== 1) return `a load carries exactly one of ${allowValue ? 'run, file, value' : 'run, file'}`
69  const base = { surfaceId, path: wire.path, as } as const
70  if ('run' in wire) {
71    if (!Array.isArray(wire.run) || wire.run.length === 0) return 'a load\'s run is not a non-empty argv list'
72    if (typeof wire.run[0] !== 'string') return 'a load\'s command is written out, never bound to the data model'
73    const argv = wire.run.map(arg => resolve(arg, model, scope))
74    // A number off the data model (a PR's number) is an argument as well.
75    const bad = argv.findIndex(arg => typeof arg !== 'string' && typeof arg !== 'number')
76    if (bad >= 0) return `argument ${bad + 1} of a load's run ${unlike(wire.run[bad], argv[bad], scope, 'a string or a number')}`
77    // What comes off the data model may have come from anywhere (a PR's
78    // body, a page), so it may be an id, a number or a path, never an option
79    // or text: a model's `['sh', '-c', { path: '/body' }]` runs nothing.
80    const loose = wire.run.findIndex((arg, n) => typeof arg !== 'string' && !isToken(argv[n]))
81    if (loose >= 0) return `argument ${loose + 1} of a load's run is bound to ${JSON.stringify(String(argv[loose]).slice(0, 40))}, not an id, a number or a path`
82    const run = argv.map(String)
83    if (run[0] === '') return 'a load\'s run names no command'
84    return { ...base, run }
85  }
86  if ('file' in wire) {
87    const file = resolve(wire.file, model, scope)
88    if (typeof file !== 'string' || file === '') return `a load's file ${unlike(wire.file, file, scope, 'a path')}`
89    return { ...base, run: ['cat', '--', file] }
90  }
91  return { ...base, value: resolve(wire.value, model, scope) }
92}
93
94/** A Button's `action.local`, resolved as `readLoad` does, or undefined when
95 * the action has none. A load names the surface it opens unless it says
96 * otherwise, else the button's own. */
97export function readLocal(action: unknown, model: unknown, scope: string, from: string): LocalAction | string | undefined {
98  if (!isRecord(action) || action.local === undefined) return undefined
99  const local = action.local
100  if (!isRecord(local)) return 'action.local is not an object'
101  const open = local.open
102  if (open !== undefined && (typeof open !== 'string' || open === '')) return 'action.local.open is not a surfaceId'
103  const wires = local.load ?? []
104  if (!Array.isArray(wires)) return 'action.local.load is not a list'
105  const loads: Load[] = []
106  for (const wire of wires) {
107    const load = readLoad(wire, model, scope, open ?? from, true)
108    if (typeof load === 'string') return load
109    loads.push(load)
110  }
111  return { loads, ...(open === undefined ? {} : { open }) }
112}
113
114/** A command's or a file's text as the load asked for it, or why it could
115 * not be read that way. */
116export function parsed(load: Load, text: string): { value: unknown } | string {
117  if (load.as === 'text') return { value: text }
118  try {
119    return { value: JSON.parse(text) }
120  } catch {
121    return `${describe(load)} did not print JSON`
122  }
123}
124
125/** A load, as a person reading an error would name it. */
126export const describe = (load: Load): string => ('run' in load ? `\`${shellCommand(load.run)}\`` : load.path)
127
128/** At most `limit` characters of a command's complaint, on one line. */
129export const brief = (text: string, limit = 300): string => {
130  const line = text.trim().replace(/\s+/g, ' ')
131  return line.length > limit ? `${line.slice(0, limit)}…` : line
132}
133
134/** The surfaces with `to` drawn in place of `from`, the surface pressed:
135 * `from` is kept, hidden, to be opened again. Nothing changes when `to`
136 * names no surface. */
137export function opened(surfaces: RoerUiSurfaces, from: string, to: string): RoerUiSurfaces {
138  const target = own(surfaces.bySurface, to)
139  if (target === undefined) return surfaces
140  const source = own(surfaces.bySurface, from)
141  const bySurface = { ...surfaces.bySurface, [to]: { ...target, hidden: false } }
142  if (source !== undefined && from !== to) bySurface[from] = { ...source, hidden: true }
143  return { ...surfaces, bySurface }
144}
145
146/** A `loadData` message read as a load, or why it is malformed; undefined
147 * for any other message. */
148export function readLoadData(message: unknown): Load | string | undefined {
149  if (!isRecord(message) || !('loadData' in message)) return undefined
150  if (message.version !== 'v1.0') return `version is ${JSON.stringify(message.version)}, not "v1.0"`
151  if (Object.keys(message).length !== 2) return 'a loadData message carries nothing else'
152  return readLoad(message.loadData, {}, '', undefined, false)
153}
154
155/** `argv` as one shell command line that runs exactly it: what the
156 * permission rules read (they are written over Bash command lines) and what
157 * the dialog shows. */
158export const shellCommand = (argv: string[]): string =>
159  argv.map(arg => (/^[\w@%+=:,./-]+$/.test(arg) ? arg : `'${arg.replace(/'/g, `'\\''`)}'`)).join(' ')
160
161type Ran = { exitCode: number; stdout: string; stderr: string }
162
163/** How a command reaches the host, as closures over the hook's `$` (the
164 * engine wants `$` spelled where it is called). */
165export type RunIo = {
166  /** The session's permission decision for a Bash command line. */
167  check: (command: string) => Promise<{ decision: 'allow' | 'ask' | 'deny'; reason?: string }>
168  /** Runs argv at once, whole output. */
169  run: (argv: string[]) => Promise<Ran>
170  /** Runs a command line as a Bash tool call: the permission dialog, hooks
171   * and classifier on the way. */
172  bash: (command: string) => Promise<BashRan>
173  /** A file's whole text: where the Bash tool kept output too long to hand back. */
174  read: (path: string) => Promise<string>
175}
176
177/** What a Bash tool call answered, as `runPermitted` reads it. */
178export type BashRan = { deny?: string; isError?: boolean; stdout?: unknown; persisted?: unknown; text?: string }
179
180/** A Bash tool call's answer, from `$.tool.call`'s result. */
181export const bashRan = (ran: { deny?: string; isError?: boolean; result?: unknown; text?: string }): BashRan => {
182  const result = ran.result as { stdout?: unknown; persistedOutputPath?: unknown } | undefined
183  return { deny: ran.deny, isError: ran.isError, stdout: result?.stdout, persisted: result?.persistedOutputPath, text: ran.text }
184}
185
186/** Runs argv as the person's permissions say. An allowed command runs
187 * directly, so its output comes whole (a Bash tool's is cut for the model);
188 * one that needs asking goes through the Bash tool, whose dialog asks; a
189 * denied one throws. */
190export async function runPermitted(argv: string[], io: RunIo): Promise<Ran> {
191  const command = shellCommand(argv)
192  const { decision, reason } = await io.check(command)
193  if (decision === 'deny') throw new Error(`denied by your permissions${reason ? ` (${reason})` : ''}`)
194  if (decision === 'allow') return io.run(argv)
195  const ran = await io.bash(command)
196  if (ran.deny !== undefined) throw new Error(`not run: ${ran.deny}`)
197  if (ran.isError) return { exitCode: 1, stdout: '', stderr: ran.text ?? '' }
198  // Output too long for the model is kept whole in a file, and `stdout` is
199  // only its start: a diff's first hunks, half a JSON list.
200  if (typeof ran.persisted === 'string' && ran.persisted !== '') return { exitCode: 0, stdout: await io.read(ran.persisted), stderr: '' }
201  return { exitCode: 0, stdout: typeof ran.stdout === 'string' ? ran.stdout : (ran.text ?? ''), stderr: '' }
202}
203
204/** A load's value, or why it could not be had. Never throws. */
205export async function fetchLoad(load: Load, io: RunIo): Promise<{ value: unknown } | string> {
206  if ('value' in load) return { value: load.value }
207  try {
208    const ran = await runPermitted(load.run, io)
209    if (ran.exitCode !== 0) return `${describe(load)} exited ${ran.exitCode}: ${brief(ran.stderr || ran.stdout)}`
210    return parsed(load, ran.stdout)
211  } catch (err) {
212    return `${describe(load)}: ${brief(err instanceof Error ? err.message : String(err))}`
213  }
214}
215
types/index.d.ts 24 lines
1// The A2UI v1.0 surfaces this mod keeps, as the model sent them through its
2// `show` tool: flat, id-referenced components and one data model each, the
3// same shape Roer's own panel holds (src/generative-ui/schema.ts).
4export type RoerUiComponent = { id: string; component: string; [prop: string]: unknown }
5export type RoerUiSurface = {
6  components: Record<string, RoerUiComponent>
7  dataModel: Record<string, unknown>
8  sendDataModel: boolean
9  /** Kept but not drawn, until a press's `action.local.open` names it. */
10  hidden?: boolean
11  /** The pane's own state, by what it is about: which tab a Tabs shows,
12   * which Expandable and Modal are open, a picker's filter, the person's
13   * answers in a work item. Nothing the agent sent says any of it, so it is
14   * never written to the data model, and a new createSurface starts over. */
15  view?: Record<string, unknown>
16}
17export type RoerUiSurfaces = { order: string[]; bySurface: Record<string, RoerUiSurface> }
18
19declare module 'claude-code' {
20  interface PluginState {
21    'roer-ui': { surfaces: RoerUiSurfaces }
22  }
23}
24