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

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:
svgPath draws as a mark; a named icon draws as its name.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):
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.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.
hooks/register.tsx 1183 lines1// 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}
1183hooks/a2ui.ts 209 lines1// 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}
209hooks/workItem.ts 178 lines1// 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)
178hooks/local.ts 215 lines1// 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}
215types/index.d.ts 24 lines1// 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