@rse/ase 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dst/ase-config-schema.js +1 -1
  2. package/dst/ase-hook.js +12 -3
  3. package/dst/ase-setup-settings.js +111 -1
  4. package/dst/ase-setup.js +19 -7
  5. package/dst/ase-spec.js +184 -49
  6. package/dst/ase-task-board-tui-model.js +8 -1
  7. package/dst/ase-task-board-tui.js +26 -2
  8. package/dst/ase-task-board-web-client.js +10 -10
  9. package/dst/man/ase-artifact.md +1 -1
  10. package/dst/man/ase-config.md +5 -3
  11. package/dst/man/ase-mcp.md +1 -1
  12. package/dst/man/ase-setup-install.md +10 -1
  13. package/dst/man/ase-setup-status.md +6 -0
  14. package/dst/man/ase-setup-uninstall.md +3 -1
  15. package/dst/man/ase-setup-update.md +3 -0
  16. package/dst/man/ase-setup.md +4 -4
  17. package/dst/man/ase-spec-export.md +14 -3
  18. package/dst/man/ase-spec-init.md +10 -1
  19. package/dst/man/ase-spec-lint.md +2 -1
  20. package/dst/man/ase-spec-preview.md +10 -1
  21. package/dst/man/ase-spec-query.md +96 -0
  22. package/dst/man/ase-spec.md +32 -11
  23. package/dst/man/ase.md +3 -3
  24. package/package.json +21 -18
  25. package/plugin/.claude-plugin/icon.png +0 -0
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-plugin/plugin.json +1 -1
  28. package/plugin/.github/plugin/plugin.json +1 -1
  29. package/plugin/meta/ase-format-spec.md +29 -5
  30. package/plugin/meta/ase-format-specbook.md +427 -47
  31. package/plugin/meta/ase-format-specbook.yaml +180 -78
  32. package/plugin/mods/ase-mods-common.ts +474 -0
  33. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  34. package/plugin/mods/ase-mods-grill.ts +283 -0
  35. package/plugin/mods/ase-mods-help-browse.ts +410 -0
  36. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  37. package/plugin/mods/ase-mods-help-find.ts +434 -0
  38. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  39. package/plugin/mods/ase-mods-help-show.ts +142 -0
  40. package/plugin/mods/ase-mods.ts +220 -381
  41. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  42. package/plugin/skills/ase-code-lint/help.md +21 -8
  43. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  44. package/plugin/skills/ase-help-intent/data.md +42 -16
  45. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  46. package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
  47. package/plugin/skills/ase-spec-lint/help.md +21 -8
  48. package/plugin/mods/claude-code.d.ts +0 -13440
@@ -0,0 +1,227 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+
7
+ /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
+ Decision matrix, redrawing the weighted decision matrices of the
9
+ "ase-meta-evaluate" skill as a grid and rendering in blue and bold the
10
+ column of the best alternative (resp. of all tied best ones). Its
11
+ header cells are clickable and sort the criteria rows by their column
12
+ (cycling the primary, the reverse, and the original order), while the
13
+ rating row stays the last one. */
14
+
15
+ import type { Elements, Frozen, MatchedEvent, RenderElement } from "claude-code"
16
+ import { type Engine, type Char, ROW, RULE, cells, strip, style, width, wrap, split, paint } from "./ase-mods-common.ts"
17
+
18
+ /* the marker line above a decision matrix */
19
+ const MARKER = /\*{0,2}EVALUATION\*{0,2}:\s+\*{0,2}Weighted Multi-Criteria Decision Matrix/
20
+
21
+ /* the color of the best alternatives, and the rating distance (relative to
22
+ the best rating) below which alternatives tie (as in "ase-meta-evaluate") */
23
+ const WINNER = "blue"
24
+ const TIE = 0.01
25
+
26
+ /* the sort marks of the orders (each taking the same columns) */
27
+ const MARK = { desc: " ▼", asc: " ▲", none: " " } as const
28
+
29
+ /* the indent of the wrapped lines of a criterion, the narrowest criteria
30
+ column, and the size limit of a single "Markdown" element */
31
+ const INDENT = 2
32
+ const NARROW = 12
33
+ const LIMIT = 10000
34
+
35
+ /* a decision matrix with the Markdown text before and after it: its header
36
+ cells, its criteria rows (with the values of their weight and evaluations),
37
+ its rating row, and the columns of the best alternatives */
38
+ type Matrix = {
39
+ before: string,
40
+ after: string,
41
+ header: string[],
42
+ rows: { cells: string[], values: number[] }[],
43
+ rating: string[],
44
+ winners: Set<number>
45
+ }
46
+
47
+ /* the sort of a matrix: the sorted column and its order */
48
+ type Order = "desc" | "asc"
49
+ type Sort = { column: number, order: Order }
50
+
51
+ /* read the number of a cell (without bold markup and a leading plus sign) */
52
+ const number = (cell: string): number => {
53
+ const text = cell.replace(/\*/g, "").trim().replace(/^\+/, "")
54
+ return text !== "" ? Number(text) : NaN
55
+ }
56
+
57
+ /* parse the decision matrix out of the text of a reply */
58
+ const parse = (text: string): Matrix | undefined => {
59
+ const lines = text.split("\n")
60
+ const m = lines.findIndex((line) => MARKER.test(line))
61
+ const h = lines.findIndex((line, i) => m !== -1 && i > m && ROW.test(line))
62
+ if (h === -1 || !RULE.test(lines[h + 1] ?? ""))
63
+ return undefined
64
+ const header = cells(lines[h]!).map((cell) => cell.replace(/\*/g, ""))
65
+ if (header.length < 3 || !/Criteria/.test(header[0]!) || !/Weight/.test(header[1]!))
66
+ return undefined
67
+ let end = h + 2
68
+ while (end < lines.length && ROW.test(lines[end]!))
69
+ end++
70
+ const rows = lines.slice(h + 2, end).map((line) => cells(line))
71
+ const rating = rows.pop() ?? []
72
+ if (rows.length === 0 || rows.some((row) => row.length !== header.length)
73
+ || rating.length !== header.length || rating[0]!.replace(/\*/g, "") !== "RATING")
74
+ return undefined
75
+ const values = rows.map((row) => row.slice(1).map(number))
76
+ const ratings = rating.slice(2).map(number)
77
+ if ([ ...values.flat(), ...ratings ].some((value) => !Number.isFinite(value)))
78
+ return undefined
79
+ const best = Math.max(...ratings)
80
+ return {
81
+ before: lines.slice(0, h).join("\n").trim(),
82
+ after: lines.slice(end).join("\n").replace(/^\n+/, "").trimEnd(),
83
+ header,
84
+ rows: rows.map((row, k) => ({ cells: row, values: values[k]! })),
85
+ rating,
86
+ winners: new Set(ratings.flatMap((value, j) =>
87
+ value === best || best - value < TIE * Math.abs(best) ? [ j + 2 ] : []))
88
+ }
89
+ }
90
+
91
+ /* advance the sort by a clicked column: its primary order (ascending for
92
+ the criteria names, descending for the values), then its reverse order,
93
+ and then the original order again */
94
+ const advance = (sort: Sort | undefined, column: number): Sort | undefined => {
95
+ const primary: Order = column === 0 ? "asc" : "desc"
96
+ const reverse: Order = column === 0 ? "desc" : "asc"
97
+ if (sort?.column !== column)
98
+ return { column, order: primary }
99
+ return sort.order === primary ? { column, order: reverse } : undefined
100
+ }
101
+
102
+ /* order the criteria rows by the sort (stable, so equal rows keep their original order) */
103
+ const order = (rows: Matrix["rows"], sort: Sort | undefined): Matrix["rows"] => {
104
+ if (sort === undefined)
105
+ return rows
106
+ const sign = sort.order === "asc" ? 1 : -1
107
+ return [ ...rows ].sort((a, b) => sign * (sort.column === 0 ?
108
+ strip(a.cells[0]!).localeCompare(strip(b.cells[0]!)) :
109
+ a.values[sort.column - 1]! - b.values[sort.column - 1]!))
110
+ }
111
+
112
+ /* the decision matrix: its state and its operations */
113
+ export const modEvaluate = () => {
114
+ /* the last known width of the terminal and the sorts of the matrices */
115
+ let columns = 80
116
+ const sorts = new Map<string, Sort>()
117
+
118
+ /* sort the criteria rows of a matrix by a clicked header cell and redraw it */
119
+ const press = (engine: Pick<Engine, "invalidate">, request: string, column: number) => () => {
120
+ const sort = advance(sorts.get(request), column)
121
+ if (sort !== undefined)
122
+ sorts.set(request, sort)
123
+ else
124
+ sorts.delete(request)
125
+ engine.invalidate()
126
+ }
127
+
128
+ /* redraw a reply which contains a decision matrix (terminal only),
129
+ resp. draw nothing for any other reply */
130
+ const render = (e: Frozen<MatchedEvent<"ui.render", { component: "AssistantMessage", surface: "terminal" }>>,
131
+ ui: Elements["terminal"], engine: Pick<Engine, "invalidate">): RenderElement | undefined => {
132
+ /* act on decision matrices only */
133
+ columns = e.viewport?.columns ?? columns
134
+ const matrix = parse(e.props.text)
135
+ if (matrix === undefined || matrix.before.length > LIMIT || matrix.after.length > LIMIT)
136
+ return undefined
137
+
138
+ /* style the cells: the header ones in bold, the criteria rows in their sort
139
+ order, and the columns of the best alternatives in blue and bold */
140
+ const sort = sorts.get(e.requestId)
141
+ const tint = (chars: Char[], column: number): Char[] =>
142
+ matrix.winners.has(column) ? chars.map((char) => ({ ...char, bold: true, color: WINNER })) : chars
143
+ const head = matrix.header.map((cell, c) => tint(style(`**${cell}**`, undefined), c))
144
+ const body = [ ...order(matrix.rows, sort).map((row) => row.cells), matrix.rating ]
145
+ .map((row) => row.map((cell, c) => tint(style(c === 0 ? strip(cell) : cell, undefined), c)))
146
+
147
+ /* determine the column widths: the value columns at their natural width
148
+ (the header cells with room for the sort mark), the criteria column
149
+ within the remaining columns (gutter, grid lines, cell paddings, and a
150
+ spare column subtracted), else leave the matrix to the engine */
151
+ const widths = head.map((cell, c) =>
152
+ Math.max(width(cell) + MARK.none.length, ...body.map((row) => width(row[c]!))))
153
+ const avail = columns - 2 - (3 * head.length + 1) - 1 - widths.slice(1).reduce((sum, w) => sum + w, 0)
154
+ if (avail < Math.max(NARROW, width(head[0]!) + MARK.none.length))
155
+ return undefined
156
+ widths[0] = Math.min(widths[0]!, avail)
157
+
158
+ /* resolve the UI elements */
159
+ const { Box, Text, Button, Markdown } = ui
160
+
161
+ /* pad the parts of a cell line of the given width to its column width:
162
+ left-aligned for the criteria, right-aligned for the values */
163
+ const align = <T>(c: number, used: number, parts: T[]): (T | string)[] => {
164
+ const pad = " ".repeat(Math.max(0, widths[c]! - used))
165
+ return c === 0 ? [ ...parts, pad ] : [ pad, ...parts ]
166
+ }
167
+
168
+ /* draw a header cell: its styled text inside a hover-scoping box, which while
169
+ hovered reveals a row of clickable plain buttons (one per styled run, as in
170
+ the grill table) on top of it, followed by its sort mark */
171
+ const heading = (c: number): RenderElement[] => {
172
+ const group = `h${c}`
173
+ const onPress = press(engine, e.requestId, c)
174
+ const buttons = split(head[c]!).map(({ text, bold }, r) =>
175
+ Button({ key: `${group}/${r}`, label: text, plain: true, onPress,
176
+ hover: { scope: group, inverse: true, bold } }))
177
+ return align(c, width(head[c]!) + MARK.none.length, [
178
+ Box({ key: group, hover: { scope: group }, children: [
179
+ Text({ children: paint(Text, head[c]!) }),
180
+ Box({ position: "absolute", top: 0, left: 0, display: "none",
181
+ hover: { scope: group, display: "flex" }, flexDirection: "row", children: buttons })
182
+ ] }),
183
+ Text({ children: sort?.column === c ? MARK[sort.order] : MARK.none })
184
+ ]).map((part) => typeof part === "string" ? Text({ children: part }) : part)
185
+ }
186
+
187
+ /* draw a line of the grid out of the parts of its cells, resp. a rule */
188
+ const draw = (parts: RenderElement[][]) => Box({ flexDirection: "row", children: [
189
+ Text({ children: "│ " }),
190
+ ...parts.flatMap((part, c) => c > 0 ? [ Text({ children: " │ " }), ...part ] : part),
191
+ Text({ children: " │" })
192
+ ] })
193
+ const rule = (l: string, m: string, r: string) =>
194
+ Text({ wrap: "truncate-end", children: l + widths.map((w) => "─".repeat(w + 2)).join(m) + r })
195
+
196
+ /* draw the matrix as a grid: a rule below the header and above the rating
197
+ row, and the criteria wrapped within their column */
198
+ const grid: RenderElement[] = [
199
+ rule("┌", "┬", "┐"),
200
+ draw(head.map((_, c) => heading(c))),
201
+ rule("├", "┼", "┤")
202
+ ]
203
+ body.forEach((row, k) => {
204
+ if (k === body.length - 1)
205
+ grid.push(rule("├", "┼", "┤"))
206
+ wrap(row[0]!, widths[0]!, INDENT).forEach((line, i) =>
207
+ grid.push(draw(row.map((cell, c) => {
208
+ const chars = c === 0 ? line : i === 0 ? cell : []
209
+ return [ Text({ children: align(c, width(chars), paint(Text, chars)) }) ]
210
+ }))))
211
+ })
212
+ grid.push(rule("└", "┴", "┘"))
213
+
214
+ /* mimic the reply row of the engine: blank row above and bullet gutter */
215
+ return Box({ flexDirection: "row", marginTop: 1, width: "100%", children: [
216
+ Box({ flexShrink: 0, minWidth: 2, children: Text({ children: e.props.isFirstOfReply ? "⏺" : " " }) }),
217
+ Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, children: [
218
+ ...(matrix.before !== "" ? [ Markdown({ text: matrix.before }) ] : []),
219
+ Box({ flexDirection: "column", marginTop: matrix.before !== "" ? 1 : 0, children: grid }),
220
+ ...(matrix.after !== "" ? [ Box({ marginTop: 1, children: Markdown({ text: matrix.after }) }) ] : [])
221
+ ] })
222
+ ] })
223
+ }
224
+
225
+ return { render }
226
+ }
227
+
@@ -0,0 +1,283 @@
1
+ /*
2
+ ** Agentic Software Engineering (ASE)
3
+ ** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
+ */
6
+
7
+ /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
+ Grill table, redrawing the grilling tables of ASE with one answer
9
+ alternative per line (wrapped lines indented) and rendering in red
10
+ the answers picked in the prompt via "nX" short responses and the
11
+ question selected in the prompt via the last standalone "n". In the
12
+ latest table, questions and answers are clickable and insert their
13
+ "n" or "nX" into the prompt. */
14
+
15
+ import type { Elements, Frozen, MatchedEvent, RenderElement } from "claude-code"
16
+ import { type Engine, type Char, PICKED, ROW, RULE, cells, style, width, wrap, split, paint } from "./ase-mods-common.ts"
17
+
18
+ /* the marker line above a grilling table (single-round and multi-round,
19
+ optionally labeled with the perspective and/or grounding) */
20
+ const MARKER = /⧉ \*{0,2}ASE\*{0,2}: GRILLING(?: ROUND \d+\/\d+)?(?: \([^)]*\))?:/
21
+
22
+ /* the separator between two answers, the letter of
23
+ an answer, and the number of a question */
24
+ const ANSWER = /,\s+(?=\*{0,2}[A-Z]\*{0,2}\s+▶)/
25
+ const LETTER = /^\*{0,2}([A-Z])\*{0,2}\s+▶/
26
+ const NUMBER = /^\*{0,2}(\d+)\*{0,2}\s+▶/
27
+
28
+ /* a short response "nX" inside the prompt */
29
+ const PICK = /(?<![^\s,])(\d+)([a-zA-Z])(?![^\s,])/g
30
+
31
+ /* a standalone question number "n" inside the prompt */
32
+ const SELECT = /(?<![^\s,])(\d+)(?![^\s,])/g
33
+
34
+ /* the indent of the wrapped lines of an answer
35
+ and the size limit of a single "Markdown" element */
36
+ const INDENT = 4
37
+ const LIMIT = 10000
38
+
39
+ /* a grilling table with the Markdown text before and after it */
40
+ type Table = {
41
+ before: string,
42
+ after: string,
43
+ header: string[],
44
+ rows: { number: number, question: string, answers: { letter: string, text: string }[] }[]
45
+ }
46
+
47
+ /* parse the grilling table out of the text of a reply */
48
+ const parse = (text: string): Table | undefined => {
49
+ const lines = text.split("\n")
50
+ const m = lines.findIndex((line) => MARKER.test(line))
51
+ const h = lines.findIndex((line, i) => m !== -1 && i > m && ROW.test(line))
52
+ if (h === -1 || !RULE.test(lines[h + 1] ?? ""))
53
+ return undefined
54
+ const header = cells(lines[h]!).map((cell) => cell.replace(/\*/g, ""))
55
+ if (header.length !== 2 || header[0] !== "QUESTION" || !/^ANSWERS?$/.test(header[1]!))
56
+ return undefined
57
+ let end = h + 2
58
+ while (end < lines.length && ROW.test(lines[end]!))
59
+ end++
60
+ const rows = lines.slice(h + 2, end).map((line) => cells(line))
61
+ if (rows.length === 0 || rows.some((row) => row.length !== 2))
62
+ return undefined
63
+ return {
64
+ before: lines.slice(0, h).join("\n").trim(),
65
+ after: lines.slice(end).join("\n").replace(/^\n+/, "").trimEnd(),
66
+ header,
67
+ rows: rows.map((row, i) => ({
68
+ number: Number(NUMBER.exec(row[0]!)?.[1] ?? i + 1),
69
+ question: row[0]!,
70
+ answers: row[1]!.split(ANSWER).map((answer) =>
71
+ ({ letter: LETTER.exec(answer)?.[1] ?? "", text: answer }))
72
+ }))
73
+ }
74
+ }
75
+
76
+ /* determine the picked answer letter of each question out of a prompt (last pick wins) */
77
+ const pick = (text: string): Map<number, string> =>
78
+ new Map([ ...text.matchAll(PICK) ].map((m) => [ Number(m[1]!), m[2]!.toUpperCase() ]))
79
+
80
+ /* determine the selected question number out of a prompt (last standalone number wins) */
81
+ const select = (text: string): number | undefined => {
82
+ const m = [ ...text.matchAll(SELECT) ].at(-1)
83
+ return m !== undefined ? Number(m[1]!) : undefined
84
+ }
85
+
86
+ /* the standalone tokens of question "n" inside the prompt: its bare
87
+ number "n" (letter "") or its short responses "nX" (letter "[a-zA-Z]") */
88
+ const token = (number: number, letter: string): RegExp =>
89
+ new RegExp(`(?<![^\\s,])${number}${letter}(?![^\\s,])`, "g")
90
+
91
+ /* insert the number "n" of a clicked question or the short response "nX" of a
92
+ clicked answer into a prompt. Answer: an existing "nY" is replaced in place
93
+ (and a bare "n" removed), else a bare "n" is replaced in place, else "nX" is
94
+ appended. Question: an existing bare "n" is kept, else the last bare number
95
+ of another question (the selected one) is replaced in place, else "n" is
96
+ appended. */
97
+ const insert = (text: string, number: number, letter?: string): string => {
98
+ const bare = token(number, "")
99
+ const picks = token(number, "[a-zA-Z]")
100
+ if (letter !== undefined && text.search(picks) !== -1)
101
+ return text.replace(picks, `${number}${letter}`)
102
+ .replace(new RegExp(`\\s*${bare.source}`, "g"), "").trimStart()
103
+ if (text.search(bare) !== -1)
104
+ return letter !== undefined ? text.replace(bare, `${number}${letter}`) : text
105
+ const last = letter === undefined ? [ ...text.matchAll(SELECT) ].at(-1) : undefined
106
+ if (last !== undefined)
107
+ return text.slice(0, last.index) + String(number) + text.slice(last.index + last[0].length)
108
+ const head = text.trimEnd()
109
+ return `${head}${head !== "" ? " " : ""}${number}${letter ?? ""}`
110
+ }
111
+
112
+ /* style the cells of a table and determine its column widths within the available
113
+ columns, right-aligning the question numbers to the widest one (so the wrapped
114
+ lines of all questions share the same indent) */
115
+ const layout = (table: Table, avail: number,
116
+ chosen: Map<number, string> | undefined, marked: number | undefined) => {
117
+ const head = table.header.map((cell) => style(`**${cell}**`, undefined))
118
+ const digits = Math.max(...table.rows.map((row) => String(row.number).length))
119
+ const rows = table.rows.map((row) => {
120
+ const numbered = NUMBER.test(row.question)
121
+ return {
122
+ lead: numbered ? digits - String(row.number).length : 0,
123
+ indent: numbered ? digits + 3 : 0,
124
+ question: style(row.question, marked === row.number ? PICKED : undefined),
125
+ answers: row.answers.map((answer) =>
126
+ style(answer.text, chosen?.get(row.number) === answer.letter ? PICKED : undefined))
127
+ }
128
+ })
129
+ const w1nat = Math.max(width(head[0]!), ...rows.map((row) => row.lead + width(row.question)))
130
+ const w2nat = Math.max(width(head[1]!), ...rows.flatMap((row) => row.answers.map((answer) => width(answer))))
131
+ let w1 = Math.min(w1nat, Math.floor(avail * 0.4))
132
+ const w2 = Math.min(w2nat, avail - w1)
133
+ w1 = Math.min(w1nat, avail - w2)
134
+ return { head, rows, w1, w2 }
135
+ }
136
+
137
+ /* the grill table: its state and its operations */
138
+ export const modGrill = () => {
139
+ /* the last known width of the terminal, the already seen tables, the
140
+ latest (still unanswered) table, the picks and the selected question of the
141
+ still unsubmitted prompt, and the frozen picks of the already answered tables */
142
+ let columns = 80
143
+ const seen = new Set<string>()
144
+ let latest: string | undefined
145
+ let picks = new Map<number, string>()
146
+ let selected: number | undefined
147
+ const frozen = new Map<string, Map<number, string>>()
148
+
149
+ /* refresh the picks and the selection from the prompt */
150
+ const refresh = (text: string) => {
151
+ picks = pick(text)
152
+ selected = select(text)
153
+ }
154
+
155
+ /* track the picks inside the edited prompt (and tell whether they changed) */
156
+ const track = (text: string): boolean => {
157
+ const before = JSON.stringify([ [ ...picks ], selected ])
158
+ refresh(text)
159
+ return JSON.stringify([ [ ...picks ], selected ]) !== before
160
+ }
161
+
162
+ /* freeze the picks in the latest table once the prompt is submitted */
163
+ const submit = (text: string) => {
164
+ if (latest !== undefined)
165
+ frozen.set(latest, pick(text))
166
+ latest = undefined
167
+ picks = new Map()
168
+ selected = undefined
169
+ }
170
+
171
+ /* insert the number of a clicked question or the short response of a clicked
172
+ answer into the prompt and refresh the picks and the selection from it */
173
+ const press = (engine: Pick<Engine, "invalidate" | "prompt" | "fill">,
174
+ number: number, letter?: string) => () => {
175
+ engine.prompt()
176
+ .then(({ text }) => engine.fill(insert(text, number, letter)))
177
+ .then((box) => {
178
+ refresh(box.text)
179
+ engine.invalidate()
180
+ })
181
+ .catch(() => {
182
+ /* ignore a prompt which is no longer accessible */
183
+ })
184
+ }
185
+
186
+ /* redraw a reply which contains a grilling table (terminal only),
187
+ resp. draw nothing for any other reply */
188
+ const render = (e: Frozen<MatchedEvent<"ui.render", { component: "AssistantMessage", surface: "terminal" }>>,
189
+ ui: Elements["terminal"], engine: Pick<Engine, "invalidate" | "prompt" | "fill">): RenderElement | undefined => {
190
+ /* act on grilling tables only, which fit into the terminal
191
+ (gutter, grid lines, cell paddings, and a spare column subtracted) */
192
+ columns = e.viewport?.columns ?? columns
193
+ const table = parse(e.props.text)
194
+ const avail = columns - 2 - 7 - 1
195
+ if (table === undefined || avail < 40 || table.before.length > LIMIT || table.after.length > LIMIT)
196
+ return undefined
197
+
198
+ /* determine the picks: frozen ones or, for the latest table, the live ones */
199
+ if (!seen.has(e.requestId)) {
200
+ seen.add(e.requestId)
201
+ latest = e.requestId
202
+ }
203
+ const chosen = frozen.get(e.requestId) ?? (e.requestId === latest ? picks : undefined)
204
+ const marked = e.requestId === latest ? selected : undefined
205
+
206
+ /* style the cells and determine the column widths */
207
+ const { head, rows, w1, w2 } = layout(table, avail, chosen, marked)
208
+
209
+ /* resolve the UI elements */
210
+ const { Box, Text, Button, Markdown } = ui
211
+
212
+ /* draw a line of a cell: its styled runs, padded to the column width */
213
+ const padded = (chars: Char[], span: number): (string | RenderElement)[] =>
214
+ [ ...paint(Text, chars), " ".repeat(Math.max(0, span - width(chars))) ]
215
+
216
+ /* draw a line of a cell: in the latest table as the styled text inside a
217
+ hover-scoping box, which while any line of its group (all lines of the
218
+ same question or answer) is hovered reveals a row of clickable plain
219
+ buttons (one per styled run, inverted in the default colors and carrying
220
+ the run's bold style, but not its color, as its hover style, because the
221
+ engine leaves the space cells uncolored) on top of it; otherwise as
222
+ styled text only */
223
+ const draw = (chars: Char[], span: number, group: string, line: number,
224
+ onPress: (() => void) | undefined): RenderElement[] => {
225
+ const w = width(chars)
226
+ if (e.requestId !== latest || w === 0 || onPress === undefined)
227
+ return [ Text({ children: padded(chars, span) }) ]
228
+ const buttons = split(chars).map(({ text, bold }, r) =>
229
+ Button({ key: `${group}/${line}/${r}`, label: text, plain: true, onPress,
230
+ hover: { scope: group, inverse: true, bold } }))
231
+ return [
232
+ Box({ key: `${group}/${line}`, hover: { scope: group }, children: [
233
+ Text({ children: padded(chars, w) }),
234
+ Box({ position: "absolute", top: 0, left: 0, display: "none",
235
+ hover: { scope: group, display: "flex" }, flexDirection: "row", children: buttons })
236
+ ] }),
237
+ Text({ children: " ".repeat(Math.max(0, span - w)) })
238
+ ]
239
+ }
240
+
241
+ /* draw the table as a grid: a rule between all rows and one answer per line */
242
+ const rule = (l: string, m: string, r: string) =>
243
+ Text({ wrap: "truncate-end", children: l + "─".repeat(w1 + 2) + m + "─".repeat(w2 + 2) + r })
244
+ const grid: RenderElement[] = [
245
+ rule("┌", "┬", "┐"),
246
+ Text({ wrap: "truncate-end", children:
247
+ [ "│ ", ...padded(head[0]!, w1), " │ ", ...padded(head[1]!, w2), " │" ] }),
248
+ rule("├", "┼", "┤")
249
+ ]
250
+ rows.forEach((row, k) => {
251
+ const number = table.rows[k]!.number
252
+ const q = wrap(row.question, w1, row.indent, row.lead).map((line, i) =>
253
+ draw(line, w1, `q${number}`, i, press(engine, number)))
254
+ const a = row.answers.flatMap((answer, j) => {
255
+ const letter = table.rows[k]!.answers[j]!.letter
256
+ return wrap(answer, w2, INDENT).map((line, i) =>
257
+ draw(line, w2, `a${number}${letter}`, i, letter !== "" ? press(engine, number, letter) : undefined))
258
+ })
259
+ for (let i = 0; i < Math.max(q.length, a.length); i++)
260
+ grid.push(Box({ flexDirection: "row", children: [
261
+ Text({ children: "│ " }),
262
+ ...(q[i] ?? [ Text({ children: " ".repeat(w1) }) ]),
263
+ Text({ children: " │ " }),
264
+ ...(a[i] ?? [ Text({ children: " ".repeat(w2) }) ]),
265
+ Text({ children: " │" })
266
+ ] }))
267
+ grid.push(k < rows.length - 1 ? rule("├", "┼", "┤") : rule("└", "┴", "┘"))
268
+ })
269
+
270
+ /* mimic the reply row of the engine: blank row above and bullet gutter */
271
+ return Box({ flexDirection: "row", marginTop: 1, width: "100%", children: [
272
+ Box({ flexShrink: 0, minWidth: 2, children: Text({ children: e.props.isFirstOfReply ? "⏺" : " " }) }),
273
+ Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, children: [
274
+ ...(table.before !== "" ? [ Markdown({ text: table.before }) ] : []),
275
+ Box({ flexDirection: "column", marginTop: table.before !== "" ? 1 : 0, children: grid }),
276
+ ...(table.after !== "" ? [ Box({ marginTop: 1, children: Markdown({ text: table.after }) }) ] : [])
277
+ ] })
278
+ ] })
279
+ }
280
+
281
+ return { refresh, track, submit, render }
282
+ }
283
+