@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.
- package/dst/ase-config-schema.js +1 -1
- package/dst/ase-hook.js +12 -3
- package/dst/ase-setup-settings.js +111 -1
- package/dst/ase-setup.js +19 -7
- package/dst/ase-spec.js +184 -49
- package/dst/ase-task-board-tui-model.js +8 -1
- package/dst/ase-task-board-tui.js +26 -2
- package/dst/ase-task-board-web-client.js +10 -10
- package/dst/man/ase-artifact.md +1 -1
- package/dst/man/ase-config.md +5 -3
- package/dst/man/ase-mcp.md +1 -1
- package/dst/man/ase-setup-install.md +10 -1
- package/dst/man/ase-setup-status.md +6 -0
- package/dst/man/ase-setup-uninstall.md +3 -1
- package/dst/man/ase-setup-update.md +3 -0
- package/dst/man/ase-setup.md +4 -4
- package/dst/man/ase-spec-export.md +14 -3
- package/dst/man/ase-spec-init.md +10 -1
- package/dst/man/ase-spec-lint.md +2 -1
- package/dst/man/ase-spec-preview.md +10 -1
- package/dst/man/ase-spec-query.md +96 -0
- package/dst/man/ase-spec.md +32 -11
- package/dst/man/ase.md +3 -3
- package/package.json +21 -18
- package/plugin/.claude-plugin/icon.png +0 -0
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/meta/ase-format-spec.md +29 -5
- package/plugin/meta/ase-format-specbook.md +427 -47
- package/plugin/meta/ase-format-specbook.yaml +180 -78
- package/plugin/mods/ase-mods-common.ts +474 -0
- package/plugin/mods/ase-mods-evaluate.ts +227 -0
- package/plugin/mods/ase-mods-grill.ts +283 -0
- package/plugin/mods/ase-mods-help-browse.ts +410 -0
- package/plugin/mods/ase-mods-help-compose.ts +561 -0
- package/plugin/mods/ase-mods-help-find.ts +434 -0
- package/plugin/mods/ase-mods-help-hint.ts +134 -0
- package/plugin/mods/ase-mods-help-show.ts +142 -0
- package/plugin/mods/ase-mods.ts +220 -381
- package/plugin/skills/ase-code-lint/SKILL.md +15 -15
- package/plugin/skills/ase-code-lint/help.md +21 -8
- package/plugin/skills/ase-help-intent/SKILL.md +2 -1
- package/plugin/skills/ase-help-intent/data.md +42 -16
- package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
- package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
- package/plugin/skills/ase-spec-lint/help.md +21 -8
- 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
|
+
|