Agentic Software Engineering (ASE)

About
Agentic Software Engineering (ASE) is the opinionated Open Source companion toolkit of *Dr. Ralf S. Engelschall* for fusing the concept of Agentic AI Coding and traditional Software Engineering in the software development tool Anthropic Claude Code — and with reduced support also in the alternative tool GitHub Copilot CLI or OpenAI Codex CLI.
ASE ships with 49 agent skills, agent hooks, agent sub-agents, an underlying Model-Context-Protocol (MCP) service and a Command-Line Interface (CLI), incorporating reasonable methodology and automation aspects, to support the recurring tasks of a Software Developer and Software Architect.
ASE is primarily motivated by the following statement of its primary author, *Dr. Ralf S. Engelschall*:
"Software developers in the industrial Software Engineering context, in their recurring tasks, should leverage pre-manufactured agentic AI skills to boost their daily productivity. Those skills incorporate reasonable methodology and automation aspects while keeping the developers in the driver's seat to ensure stable result quality."
Notices
[!NOTE] TERMINOLOGY: The discipline of *Agentic Software Engineering* in general is Software Engineering, supported by autonomous AI Agents to perform tasks across the software development lifecycle. This ASE product in particular is also agentic, but not strictly based on autonomous agents. Instead, ASE focuses on supporting the role of a Software Engineer with Agentic AI Coding Tools towards multi-step operations and a plan/task-driven approach, but still strongly focuses on Human-in-the-Loop.
[!NOTE] TOOL SUPPORT FOCUS: The primary focus of ASE is on the Agentic AI Coding tool *Claude Code*. The secondary focus is on the support for *GitHub Copilot CLI* (just set environment variable
ASE_TOOL=copilot) and *OpenAI Codex CLI* (just set environment variableASE_TOOL=codex). In the future, additional support could be provided also for alternative tools — if their agent harness features (especially hooks, interactive user dialog tool, etc.) realistically allow it.
Highlights
Check out the following scenarios and corresponding ASE examples to see whether ASE is right for you:
<table> <tr> <td width="50%" valign="top">
/ase-meta-config set agent.persona engineer or even /ase-meta-config set agent.persona caveman/ase-code-craft hello: "ase hello" CLI command which prints a nice "Hello World" in red/ase-task-edit hello/ase-task-grill hello/ase-task-preflight hello/ase-code-insight @tool/ase-code-explain @tool/src/*.ts/ase-code-lint @tool/src/*.ts/ase-docs-shorten --chars 4000 @README.md/ase-docs-refine @README.md/ase-docs-proofread @README.md/ase-meta-quorum What is Agentic Software Engineering?/ase-code-analyze @tool/src/*.tsCHANGELOG.md entries automatically derived from your recent Git commits? → /ase-repo-changelog/ase-task-implement hello/ase-task-dissect hello/ase-spec-activate which personas does the specification define?/ase-spec-edit -g -v add a Reviewer persona/ase-sync-reconcile -s SPEC -t DOCS/ase-sync-import -t SPEC @requirements.txt/ase-sync-export -o docs/spec.html,docs/spec.pdf/ase-task-id, /ase-task-list, /ase-task-view, /ase-task-status, /ase-task-rename, /ase-task-condense, /ase-task-reboot, /ase-task-delete/ase-xxx-xxx --help or /ase-help-skill root cause/ase-xxx-xxx commands generated for you? → /ase-help-intent split my huge change set into separate commits</td> <td width="50%" valign="top">
/ase-repo-diff -c -r -b/ase-repo-review/ase-repo-dissect -m 4/ase-repo-merge -t develop -c feature-x/ase-repo-resolve --interactive src//ase-code-resolve the CLI crashes on an empty config file/ase-code-refactor extract the config loading into a dedicated module/ase-arch-analyze @tool/src/ase-meta-brainstorm an offline-first sync layer for the mobile app/ase-meta-why is the Decibel (dB) unit a logarithmic one?/ase-meta-diaboli The Decibel (dB) is an intuitive unit./ase-meta-steelman ASE is one of the best Anthropic Claude Code CLI add-ons./ase-docs-distill doc/architecture.md/ase-meta-search What is Agentic Software Engineering?/ase-meta-eli5 Fourier transform/ase-meta-quotes software architecture/ase-meta-chat gemini What is Agentic Software Engineering?/ase-arch-discover reactive UI DOM rendering/ase-meta-evaluate Vue vs. React vs. Angular, focus on TypeScript support and extensibility/ase-meta-workflow optimizer analyze the code, then resolve each finding in a parallel sub-agent/ase-repo-commit/ase-meta-proximity software architecture/ase-meta-mint -t name a background service bridged into the agent tool/ase-meta-compatase setup mcp activate</td> </tr> </table>
User Setup
pdf output of /ase-sync-export only: a Chromium-class browser, i.e. either a system-wide Google Chrome or the Playwright Chromium, fetched once via npx playwright install chromium# install ASE tool into PATH (bootstrapping only)
npm install -g @rse/ase
# install ASE plugin into agent tool
ase setup install [--tool claude|copilot|codex] [--scope user|project|local]
# update ASE tool in PATH and ASE plugin in agent tool
ase setup update [--tool claude|copilot|codex] [--scope user|project|local]
# uninstall ASE tool from PATH and ASE plugin from agent tool
ase setup uninstall [--tool claude|copilot|codex] [--scope user|project|local]
# enable/disable ASE plugin in agent tool (without uninstalling it)
ase setup enable [--tool claude|copilot|codex] [--scope user|project|local]
ase setup disable [--tool claude|copilot|codex] [--scope user|project|local]
# report ASE plugin, MCP server, output style, and statusline registrations in agent tool
ase setup status [--tool claude|copilot|codex]
The report lists, across all scopes at once, the registration scope and enabled/disabled state of the ASE plugin, the registration scope of each currently registered MCP server, the settings files carrying a selected (*ASE*-owned) or foreign (hand-selected) outputStyle entry, and the settings files carrying an activated (*ASE*-owned) or foreign (hand-crafted) statusLine entry. It is read-only, takes no --scope (reporting every scope is the point), and always exits with status 0.
The ASE plugin ships the output style ase:ase-terse, which lets the agent respond tersely, leading with the result and skipping preamble, narration, and closing recaps. For --tool claude, ase setup install (and ase setup update) selects it by writing "outputStyle": "ase:ase-terse" into the settings.json of the given --scope, ase setup uninstall removes this selection again, and a hand-selected foreign outputStyle is always preserved. You can switch it at any time via /config. For --tool copilot and --tool codex, which have no output style concept, the identical style instructions are injected into the session context by the ASE session-start hook instead.
# activate/deactivate ASE statusline in agent tool
ase setup statusline activate [--tool claude|copilot] [--scope user|project|local] [<line> ...]
ase setup statusline deactivate [--tool claude|copilot] [--scope user|project|local]
The statusline is only supported for --tool claude and --tool copilot; codex has no statusline concept. Each optional <line> argument is a format template of literal text and %-prefixed placeholders (e.g. "%u %p %T"); without any, a single default line "%m %e %t" is rendered.
The --scope option defaults to user (today's global, machine-wide behavior). Use --scope project to share the plugin registration via the repository, or --scope local to keep it out of version control and confined to a single repository. --scope is only supported for --tool claude; a non-user scope is rejected for copilot and codex, whose CLIs have no scope concept.
Foreign MCP Servers
ASE can optionally leverage foreign MCP servers in various ASE skills for improved quality. They can be conveniently managed via ase setup mcp.
# check list of MCP servers known to ASE
ase setup mcp list
# activate MCP servers in the agent tool
ase setup mcp activate [--tool claude|copilot|codex] [--scope user|project|local] [<server>[,...]]
# deactivate MCP servers in the agent tool
ase setup mcp deactivate [--tool claude|copilot|codex] [--scope user|project|local] [<server>[,...]]
Each MCP server reads its API key from an environment variable ASE_MCP_KEY_XXX, where XXX is the server id in upper-case with dashes replaced by underscores (e.g. the server openai-chatgpt uses ASE_MCP_KEY_OPENAI_CHATGPT). These variables are also automatically sourced from .env files. A server whose key variable is unset or empty is silently skipped on activation. The harness-based servers (anthropic-claude, openai-codex and github-copilot) require no API key at all -- their variable instead carries the harness model identifier (or the special value default for the default model of the harness).
The following AI services are currently defined:
<table> <tr> <td width="50%" valign="top">
openai-chatgpt)google-gemini)deepseek)xai-grok)alibaba-qwen)zai-glm)anthropic-claude)openai-codex)github-copilot)</td> <td width="50%" valign="top">
brave)perplexity)exa)</td> </tr> </table>
Hint: All API-based MCP servers of type "Chat" support both the native API of the LLM vendor and the OpenRouter proxy API as an alternative, i.e., you can leverage all paid "Chat" AI services by just providing the ASE_MCP_KEY_OPENROUTER of an OpenRouter account. Alternatively, the harness-based MCP servers of type "Chat" (anthropic-claude, openai-codex and github-copilot) require no API key at all, as they bridge to a locally installed AI agent harness CLI (Anthropic Claude Code CLI, OpenAI Codex CLI, or GitHub Copilot CLI) which authenticates via its own configured credentials.
See Also
mods/ase-mods.ts 409 lines1/*
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
15import type { Register, RenderElement } from "claude-code"
16
17/* the marker line above a grilling table (single-round and multi-round) */
18const MARKER = /⧉ \*{0,2}ASE\*{0,2}: GRILLING(?: ROUND \d+\/\d+)?:/
19
20/* a row and the delimiter row of a Markdown table */
21const ROW = /^ *\|.*\| *$/
22const RULE = /^ *\|(?: *:?-+:? *\|)+ *$/
23
24/* the separator between two answers, the letter of
25 an answer, and the number of a question */
26const ANSWER = /,\s+(?=\*{0,2}[A-Z]\*{0,2}\s+▶)/
27const LETTER = /^\*{0,2}([A-Z])\*{0,2}\s+▶/
28const NUMBER = /^\*{0,2}(\d+)\*{0,2}\s+▶/
29
30/* a short response "nX" inside the prompt */
31const PICK = /(?<![^\s,])(\d+)([a-zA-Z])(?![^\s,])/g
32
33/* a standalone question number "n" inside the prompt */
34const SELECT = /(?<![^\s,])(\d+)(?![^\s,])/g
35
36/* the indent of the wrapped lines of an answer, the colors of a picked answer
37 and of a code span, and the size limit of a single "Markdown" element */
38const INDENT = 4
39const PICKED = "red"
40const CODE = "blue"
41const LIMIT = 10000
42
43/* a single styled character of a table cell */
44type Char = { ch: string, bold: boolean, color: string | undefined }
45
46/* a grilling table with the Markdown text before and after it */
47type Table = {
48 before: string,
49 after: string,
50 header: string[],
51 rows: { number: number, question: string, answers: { letter: string, text: string }[] }[]
52}
53
54/* split a Markdown table row into its cells */
55const cells = (line: string): string[] => {
56 const result: string[] = []
57 const body = line.trim().replace(/^\|/, "").replace(/\|$/, "")
58 let cell = ""
59 let code = false
60 for (let i = 0; i < body.length; i++) {
61 const ch = body[i]!
62 if (ch === "`")
63 code = !code
64 if (!code && ch === "\\" && body[i + 1] === "|") {
65 cell += "|"
66 i++
67 }
68 else if (!code && ch === "|") {
69 result.push(cell.trim())
70 cell = ""
71 }
72 else
73 cell += ch
74 }
75 result.push(cell.trim())
76 return result
77}
78
79/* parse the grilling table out of the text of a reply */
80const parse = (text: string): Table | undefined => {
81 const lines = text.split("\n")
82 const m = lines.findIndex((line) => MARKER.test(line))
83 const h = lines.findIndex((line, i) => m !== -1 && i > m && ROW.test(line))
84 if (h === -1 || !RULE.test(lines[h + 1] ?? ""))
85 return undefined
86 const header = cells(lines[h]!).map((cell) => cell.replace(/\*/g, ""))
87 if (header.length !== 2 || header[0] !== "QUESTION" || !/^ANSWERS?$/.test(header[1]!))
88 return undefined
89 let end = h + 2
90 while (end < lines.length && ROW.test(lines[end]!))
91 end++
92 const rows = lines.slice(h + 2, end).map((line) => cells(line))
93 if (rows.some((row) => row.length !== 2))
94 return undefined
95 return {
96 before: lines.slice(0, h).join("\n").trim(),
97 after: lines.slice(end).join("\n").replace(/^\n+/, "").trimEnd(),
98 header,
99 rows: rows.map((row, i) => ({
100 number: Number(NUMBER.exec(row[0]!)?.[1] ?? i + 1),
101 question: row[0]!,
102 answers: row[1]!.split(ANSWER).map((answer) =>
103 ({ letter: LETTER.exec(answer)?.[1] ?? "", text: answer }))
104 }))
105 }
106}
107
108/* determine the picked answer letter of each question out of a prompt (last pick wins) */
109const pick = (text: string): Map<number, string> =>
110 new Map([ ...text.matchAll(PICK) ].map((m) => [ Number(m[1]!), m[2]!.toUpperCase() ]))
111
112/* determine the selected question number out of a prompt (last standalone number wins) */
113const select = (text: string): number | undefined => {
114 const m = [ ...text.matchAll(SELECT) ].at(-1)
115 return m !== undefined ? Number(m[1]!) : undefined
116}
117
118/* the standalone tokens of question "n" inside the prompt: its bare
119 number "n" (letter "") or its short responses "nX" (letter "[a-zA-Z]") */
120const token = (number: number, letter: string): RegExp =>
121 new RegExp(`(?<![^\\s,])${number}${letter}(?![^\\s,])`, "g")
122
123/* insert the number "n" of a clicked question or the short response "nX" of a
124 clicked answer into a prompt. Answer: an existing "nY" is replaced in place
125 (and a bare "n" removed), else a bare "n" is replaced in place, else "nX" is
126 appended. Question: an existing bare "n" is kept, else the bare number of
127 another question (there is at most one selected question) is replaced in
128 place, else "n" is appended. */
129const insert = (text: string, number: number, letter?: string): string => {
130 const bare = token(number, "")
131 const picks = token(number, "[a-zA-Z]")
132 if (letter !== undefined && text.search(picks) !== -1)
133 return text.replace(picks, `${number}${letter}`)
134 .replace(new RegExp(`\\s*${bare.source}`, "g"), "").trimStart()
135 if (text.search(bare) !== -1)
136 return letter !== undefined ? text.replace(bare, `${number}${letter}`) : text
137 if (letter === undefined && text.search(SELECT) !== -1)
138 return text.replace(SELECT, String(number))
139 const head = text.trimEnd()
140 return `${head}${head !== "" ? " " : ""}${number}${letter ?? ""}`
141}
142
143/* convert inline Markdown (bold and code spans) into styled characters */
144const style = (text: string, color: string | undefined): Char[] => {
145 const chars: Char[] = []
146 let bold = false
147 let code = false
148 for (let i = 0; i < text.length; i++) {
149 if (!code && text.startsWith("**", i)) {
150 bold = !bold
151 i++
152 }
153 else if (text[i] === "`")
154 code = !code
155 else {
156 const ch = String.fromCodePoint(text.codePointAt(i)!)
157 chars.push({ ch, bold, color: color ?? (code ? CODE : undefined) })
158 i += ch.length - 1
159 }
160 }
161 return chars
162}
163
164/* the width of styled characters in terminal cells */
165const width = (chars: Char[]): number => {
166 let n = 0
167 for (const { ch } of chars) {
168 const c = ch.codePointAt(0)!
169 if (c === 0x200B || c === 0x2060 || (c >= 0x0300 && c <= 0x036F) || (c >= 0xFE00 && c <= 0xFE0F))
170 continue
171 const wide =
172 (c >= 0x1100 && c <= 0x115F) || (c >= 0x2E80 && c <= 0xA4CF) ||
173 (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0xF900 && c <= 0xFAFF) ||
174 (c >= 0xFF00 && c <= 0xFF60) || (c >= 0x1F300 && c <= 0x1FAFF)
175 n += wide ? 2 : 1
176 }
177 return n
178}
179
180/* hard-wrap styled characters at word boundaries to the given columns,
181 indenting the first line by "lead" and all further lines by "indent"
182 (and chopping overlong words) */
183const wrap = (chars: Char[], columns: number, indent: number, lead = 0): Char[][] => {
184 const space: Char = { ch: " ", bold: false, color: undefined }
185 const words: Char[][] = [ [] ]
186 for (const char of chars) {
187 if (char.ch === " ")
188 words.push([])
189 else
190 words.at(-1)!.push(char)
191 }
192 const lines: Char[][] = []
193 let line: Char[] = Array.from({ length: lead }, () => space)
194 let start = lead
195 let size = lead
196 const flush = () => {
197 lines.push(line)
198 line = Array.from({ length: indent }, () => space)
199 start = indent
200 size = indent
201 }
202 for (let word of words.filter((word) => word.length > 0)) {
203 while (word.length > 0) {
204 const w = width(word)
205 if (size > start && size + 1 + w <= columns) {
206 line.push(space, ...word)
207 size += 1 + w
208 word = []
209 }
210 else if (size > start)
211 flush()
212 else {
213 let n = 1
214 while (n < word.length && width(word.slice(0, n + 1)) <= columns - start)
215 n++
216 line.push(...word.slice(0, n))
217 size += width(word.slice(0, n))
218 word = word.slice(n)
219 if (word.length > 0)
220 flush()
221 }
222 }
223 }
224 lines.push(line)
225 return lines
226}
227
228/* function hook registration entry point */
229export const register: Register = (on, options) => {
230 /* allow the rendering to be disabled without disabling the plugin */
231 if (options.enabled === false)
232 return
233
234 /* the last known width of the terminal, the already seen tables, the
235 latest (still unanswered) table, the picks and the selected question of the
236 still unsubmitted prompt, and the frozen picks of the already answered tables */
237 let columns = 80
238 const seen = new Set<string>()
239 let latest: string | undefined
240 let picks = new Map<number, string>()
241 let selected: number | undefined
242 const frozen = new Map<string, Map<number, string>>()
243
244 /* track the picks inside the still unsubmitted prompt of the user */
245 on("prompt.edit", async ($, e, next) => {
246 const box = await next(e)
247 const now = pick(box.text)
248 const sel = select(box.text)
249 if (JSON.stringify([ ...now ]) !== JSON.stringify([ ...picks ]) || sel !== selected) {
250 picks = now
251 selected = sel
252 $.ui.invalidate("ui.render")
253 }
254 return box
255 })
256
257 /* freeze the picks in the latest table once the prompt is submitted */
258 on("prompt.submit", ($, e, next) => {
259 if (latest !== undefined)
260 frozen.set(latest, pick(e.text))
261 latest = undefined
262 picks = new Map()
263 selected = undefined
264 $.ui.invalidate("ui.render")
265 return next(e)
266 })
267
268 /* redraw the replies which contain a grilling table (terminal only) */
269 on("ui.render", { component: "AssistantMessage", surface: "terminal" }, async ($, e, next) => {
270 /* act on grilling tables only, which fit into the terminal
271 (gutter, grid lines, cell paddings, and a spare column subtracted) */
272 columns = e.viewport?.columns ?? columns
273 const table = parse(e.props.text)
274 const avail = columns - 2 - 7 - 1
275 if (table === undefined || avail < 40 || table.before.length > LIMIT || table.after.length > LIMIT)
276 return next(e)
277
278 /* let the chain beneath run, although its drawing is replaced by us
279 (but this at least silences a warning in the debug output of Claude Code) */
280 await next(e)
281
282 /* determine the picks: frozen ones or, for the latest table, the live ones */
283 if (!seen.has(e.requestId)) {
284 seen.add(e.requestId)
285 latest = e.requestId
286 }
287 const chosen = frozen.get(e.requestId) ?? (e.requestId === latest ? picks : undefined)
288 const marked = e.requestId === latest ? selected : undefined
289
290 /* style the cells and determine the column widths, right-aligning the
291 question numbers to the widest one (so the wrapped lines of all
292 questions share the same indent) */
293 const head = table.header.map((cell) => style(`**${cell}**`, undefined))
294 const digits = Math.max(...table.rows.map((row) => String(row.number).length))
295 const rows = table.rows.map((row) => {
296 const numbered = NUMBER.test(row.question)
297 return {
298 lead: numbered ? digits - String(row.number).length : 0,
299 indent: numbered ? digits + 3 : 0,
300 question: style(row.question, marked === row.number ? PICKED : undefined),
301 answers: row.answers.map((answer) =>
302 style(answer.text, chosen?.get(row.number) === answer.letter ? PICKED : undefined))
303 }
304 })
305 const w1nat = Math.max(width(head[0]!), ...rows.map((row) => row.lead + width(row.question)))
306 const w2nat = Math.max(width(head[1]!), ...rows.flatMap((row) => row.answers.map((answer) => width(answer))))
307 let w1 = Math.min(w1nat, Math.floor(avail * 0.4))
308 const w2 = Math.min(w2nat, avail - w1)
309 w1 = Math.min(w1nat, avail - w2)
310
311 /* resolve the UI elements */
312 const { Box, Text, Button, Markdown } = $.ui.resolve(e)
313
314 /* split a line of a cell into runs of equally styled characters */
315 const split = (chars: Char[]): { text: string, bold: boolean, color: string | undefined }[] => {
316 const runs: { text: string, bold: boolean, color: string | undefined }[] = []
317 for (let i = 0, j = 0; i < chars.length; i = j) {
318 const { bold, color } = chars[i]!
319 while (j < chars.length && chars[j]!.bold === bold && chars[j]!.color === color)
320 j++
321 runs.push({ text: chars.slice(i, j).map((char) => char.ch).join(""), bold, color })
322 }
323 return runs
324 }
325
326 /* draw a line of a cell: its styled runs, padded to the column width */
327 const paint = (chars: Char[], columns: number): (string | RenderElement)[] => [
328 ...split(chars).map(({ text, bold, color }) =>
329 bold || color !== undefined ? Text({ bold, color, children: text }) : text),
330 " ".repeat(Math.max(0, columns - width(chars)))
331 ]
332
333 /* insert the number of a clicked question or the short response of a clicked
334 answer into the prompt and refresh the picks and the selection from it */
335 const press = (number: number, letter?: string) => () => {
336 void $.prompt.read()
337 .then(({ text }) => $.prompt.fill({ text: insert(text, number, letter) }))
338 .then((box) => {
339 picks = pick(box.text)
340 selected = select(box.text)
341 $.ui.invalidate("ui.render")
342 })
343 }
344
345 /* draw a line of a cell: in the latest table as the styled text inside a
346 hover-scoping box, which while any line of its group (all lines of the
347 same question or answer) is hovered reveals a row of clickable plain
348 buttons (one per styled run, inversed in the default colors and carrying
349 the run's bold style, but not its color, as its hover style, because the
350 engine leaves the space cells uncolored) on top of it; otherwise as
351 styled text only */
352 const draw = (chars: Char[], columns: number, group: string, line: number,
353 onPress: () => void): RenderElement[] => {
354 const w = width(chars)
355 if (e.requestId !== latest || w === 0)
356 return [ Text({ children: paint(chars, columns) }) ]
357 const buttons = split(chars).map(({ text, bold }, r) =>
358 Button({ key: `${group}/${line}/${r}`, label: text, plain: true, onPress,
359 hover: { scope: group, inverse: true, bold } }))
360 return [
361 Box({ key: `${group}/${line}`, hover: { scope: group }, children: [
362 Text({ children: paint(chars, w) }),
363 Box({ position: "absolute", top: 0, left: 0, display: "none",
364 hover: { scope: group, display: "flex" }, flexDirection: "row", children: buttons })
365 ] }),
366 Text({ children: " ".repeat(Math.max(0, columns - w)) })
367 ]
368 }
369
370 /* draw the table as a grid: a rule between all rows and one answer per line */
371 const rule = (l: string, m: string, r: string) =>
372 Text({ wrap: "truncate-end", children: l + "─".repeat(w1 + 2) + m + "─".repeat(w2 + 2) + r })
373 const grid: RenderElement[] = [ rule("┌", "┬", "┐") ]
374 grid.push(Text({ wrap: "truncate-end", children:
375 [ "│ ", ...paint(head[0]!, w1), " │ ", ...paint(head[1]!, w2), " │" ] }))
376 grid.push(rule("├", "┼", "┤"))
377 rows.forEach((row, k) => {
378 const number = table.rows[k]!.number
379 const q = wrap(row.question, w1, row.indent, row.lead).map((line, i) =>
380 draw(line, w1, `q${number}`, i, press(number)))
381 const a = row.answers.flatMap((answer, j) => {
382 const letter = table.rows[k]!.answers[j]!.letter
383 return wrap(answer, w2, INDENT).map((line, i) =>
384 draw(line, w2, `a${number}${letter}`, i, press(number, letter)))
385 })
386 for (let i = 0; i < Math.max(q.length, a.length); i++)
387 grid.push(Box({ flexDirection: "row", children: [
388 Text({ children: "│ " }),
389 ...(q[i] ?? [ Text({ children: " ".repeat(w1) }) ]),
390 Text({ children: " │ " }),
391 ...(a[i] ?? [ Text({ children: " ".repeat(w2) }) ]),
392 Text({ children: " │" })
393 ] }))
394 grid.push(k < rows.length - 1 ? rule("├", "┼", "┤") : rule("└", "┴", "┘"))
395 })
396
397 /* mimic the reply row of the engine: blank row above and bullet gutter */
398 return Box({ flexDirection: "row", marginTop: 1, width: "100%", children: [
399 Box({ flexShrink: 0, minWidth: 2, children: Text({ children: e.props.isFirstOfReply ? "⏺" : " " }) }),
400 Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, children: [
401 ...(table.before !== "" ? [ Markdown({ text: table.before }) ] : []),
402 Box({ flexDirection: "column", marginTop: table.before !== "" ? 1 : 0, children: grid }),
403 ...(table.after !== "" ? [ Box({ marginTop: 1, children: Markdown({ text: table.after }) }) ] : [])
404 ] })
405 ] })
406 })
407}
408
409