SLOPSHOPPER

ase

Agentic Software Engineering (ASE)

newrowsprompt
★ 57v1.0.7Apache-2.0updated 2026-10-06rse/ase/plugin
A shopper browsing a rack in a slop shop
README

ASE

ASE

github (author stars) github (author followers) github (project license) github (project release) github (project stars)

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 variable ASE_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">

  • Boosted Sessions: You want to speed up your interactive sessions and at the same time reduce costs by reducing the number of produced LLM output tokens? &rarr; /ase-meta-config set agent.persona engineer or even /ase-meta-config set agent.persona caveman
  • Alternative Approach Funnel: You prefer a plan-driven approach, but the agent harness's Plan Mode is too unstructured and too direct because you want to leverage a funnel of alternative approaches first? &rarr; /ase-code-craft hello: "ase hello" CLI command which prints a nice "Hello World" in red
  • Named and Persisted Plans: You prefer a plan-driven approach, but the agent harness's Plan Mode is regularly too weak, because you want named, persisted, cross-session reachable, and strictly structured plans? &rarr; /ase-task-edit hello
  • Plan Stress-Testing: You have a task plan, but want to be relentlessly grilled about every essential aspect of it until you and the agent reach a shared understanding and no open decisions remain? &rarr; /ase-task-grill hello
  • Implementation Preflights: You prefer a plan-driven approach, but want to pre-flight the implementation without later having to rewind artifacts via the version control system or the agent harness's session history? &rarr; /ase-task-preflight hello
  • Project Insights: You want to get a quick insight into a project by determining the author, the source files with the most churn, and the module structure? &rarr; /ase-code-insight @tool
  • Code Comprehension: You want to better comprehend code by finding focused information on What, Why, Analogy, Diagram, Cruxes, and Gotchas? &rarr; /ase-code-explain @tool/src/*.ts
  • Lexical Code Analysis: You want to analyze code for potential problems related to a standard set of code quality aspects? &rarr; /ase-code-lint @tool/src/*.ts
  • Document Shortening: You want a document cut down to a hard length budget -- sentences tightened first, low-value content dropped next, the rest compressed last -- with every cut reviewed as a whole block before it is applied? &rarr; /ase-docs-shorten --chars 4000 @README.md
  • Document Refinement: You want the wording of a document refined -- long sentences shortened, nominal constructions turned into verbs, enumerations made parallel, filler words struck -- while content, numbers, and technical terms stay exactly as they are? &rarr; /ase-docs-refine @README.md
  • Document Proofreading: You want your text documents checked and corrected for spelling, punctuation, and grammar errors? &rarr; /ase-docs-proofread @README.md
  • Research Quorum: You want to research a fact by asking multiple (potentially available) foreign LLMs and methodologically derive a quorum answer? &rarr; /ase-meta-quorum What is Agentic Software Engineering?
  • Logical Code Analysis: You want to analyze code for potential problems in its logic, semantics, and control flow? &rarr; /ase-code-analyze @tool/src/*.ts
  • Automated Change Logs: You want to get your CHANGELOG.md entries automatically derived from your recent Git commits? &rarr; /ase-repo-changelog
  • Plan Implementation: You have a named, persisted task plan and now want it implemented as a single, complete change set across your project artifacts? &rarr; /ase-task-implement hello
  • Epic Plan Dissection: You have one large task plan, but want it split domain-wise and logically into cohesive parts, each materialized as its own sub-task plan you can implement separately? &rarr; /ase-task-dissect hello
  • Specification Activation: You want the SpecBook know-how -- format contract, schema, and artifact set -- activated in your session, so you can query and ad-hoc edit your specification in plain conversation? &rarr; /ase-spec-activate which personas does the specification define?
  • Specification Editing: You want your SpecBook-based specification changed straight from a plain description, in one shot and kept conformant to its format contract? &rarr; /ase-spec-edit -g -v add a Reviewer persona
  • Artifact Reconciliation: You want one set of artifacts (e.g. CODE, DOCS) automatically aligned to reflect the current state of another set (e.g. SPEC), one-way or bidirectionally? &rarr; /ase-sync-reconcile -s SPEC -t DOCS
  • Foreign Source Import: You want external files, URLs, or pasted text ingested and turned into structured SPEC, CODE, DOCS, or TASK artifacts? &rarr; /ase-sync-import -t SPEC @requirements.txt
  • Specification Export: You want your SpecBook-based specification rendered into ready-to-consume files, like a self-contained HTML document or a paginated PDF document? &rarr; /ase-sync-export -o docs/spec.html,docs/spec.pdf
  • Direct Skill Usage Help: You want usage help for skills directly within your agent tool sessions? &rarr; /ase-xxx-xxx --help or /ase-help-skill root cause
  • Intent-Based Skill Matching: You know what you want, but not which skill and which options realize it, and want the fitting /ase-xxx-xxx commands generated for you? &rarr; /ase-help-intent split my huge change set into separate commits

</td> <td width="50%" valign="top">

  • Diff Summary: You want a raw Git diff turned into a concise, human-readable narrative of what changed and why, grouped by intent, and with optional intent-coherence check, risk grading and blast radius? &rarr; /ase-repo-diff -c -r -b
  • Change Review: You want the staged Git changes, a branch, or a commit reviewed the way a human reviewer would on a pull request, with an approve/reject verdict and prioritized, severity-tagged, line-cited findings? &rarr; /ase-repo-review
  • Change Set Dissection: You have one large, mixed change set, but want it split into cohesive parts, each landing in its own Git WorkTree so it can be reviewed and committed atomically? &rarr; /ase-repo-dissect -m 4
  • Branch Merging: You want a branch, including its still uncommitted changes, merged into a target branch, with merge conflicts resolved semantically and an explicit merged/conflict verdict? &rarr; /ase-repo-merge -t develop -c feature-x
  • Conflict Resolution: You ran into merge conflicts and want them resolved semantically, without losing any change, and with the unresolvable hunks kept as-is and escalated to you? &rarr; /ase-repo-resolve --interactive src/
  • Guided Bug Fixing: You want a problem or bug resolved through a structured, plan-driven funnel of candidate root causes and fix approaches instead of a direct, unstructured patch? &rarr; /ase-code-resolve the CLI crashes on an empty config file
  • Guided Refactoring: You want the code base refactored through a structured funnel of alternative refactoring approaches, optionally captured as a named, persisted task plan? &rarr; /ase-code-refactor extract the config loading into a dedicated module
  • Architecture Analysis: You want your software architecture reviewed for package cohesion and inter-package coupling to spot structural weak spots? &rarr; /ase-arch-analyze @tool/src
  • Collaborative Brainstorming: You want to figure out what to build before how, by diverging on a broad space of ideas and then converging through clustering and scoring into a recommended direction? &rarr; /ase-meta-brainstorm an offline-first sync layer for the mobile app
  • Root-Cause Analysis: You want to understand the reason for a fact with the help of the "Five-Whys" root-cause determination method? &rarr; /ase-meta-why is the Decibel (dB) unit a logarithmic one?
  • Devil's Advocate Challenge: You want a thesis or statement relentlessly challenged and criticised, and finally resolved into a balanced Hegelian synthesis? &rarr; /ase-meta-diaboli The Decibel (dB) is an intuitive unit.
  • Steelman Argument: You want a thesis or statement charitably strengthened with the strongest possible case, and finally consolidated into a fortification argument? &rarr; /ase-meta-steelman ASE is one of the best Anthropic Claude Code CLI add-ons.
  • Key Point Distillation: You want a document distilled into a flat, importance-ranked list of its key points, each with a salience rank, a rationale, and a verbatim line-cited evidence snippet? &rarr; /ase-docs-distill doc/architecture.md
  • Search Engine Consolidation: You want to query multiple (potentially available) search engines and derive a consolidated result? &rarr; /ase-meta-search What is Agentic Software Engineering?
  • ELI5 Topic Explanation: You want a topic explained in a very simple, child-friendly "Explain Like I'm 5" (ELI5) way, optionally grounded in Web facts? &rarr; /ase-meta-eli5 Fourier transform
  • Topic Quote Finding: You want quotes on a topic, sorted into a 2x2 matrix of attributed vs. anonymous and literal vs. thematic ones? &rarr; /ase-meta-quotes software architecture
  • Foreign LLM Query: You want to directly query a (potentially available) foreign LLM? &rarr; /ase-meta-chat gemini What is Agentic Software Engineering?
  • Package Discovery: You want to be supported in the discovery of suitable packages for the establishment of your technology stack? &rarr; /ase-arch-discover reactive UI DOM rendering
  • Multi-Criteria Decision Matrices: You want to be supported in the evaluation of alternatives with the methodological help of a weighted multi-criteria decision matrix? &rarr; /ase-meta-evaluate Vue vs. React vs. Angular, focus on TypeScript support and extensibility
  • Workflow Skill Generation: You want a recurring multi-step procedure -- sequential steps, parallel actions, sub-agent and skill calls -- turned into its own reusable skill? &rarr; /ase-meta-workflow optimizer analyze the code, then resolve each finding in a parallel sub-agent
  • Commit Message Crafting: You want a concise commit message crafted from the currently staged Git changes instead of writing it by hand? &rarr; /ase-repo-commit
  • Conceptual Topic Neighborhood: You want to explore the parent, sibling, and child topics of a concept, optionally grounded in Web facts and navigable in an interactive loop? &rarr; /ase-meta-proximity software architecture
  • Identifier and Name Minting: You want a UUID, a SHA-1 digest, a constant, variable, function, or class identifier, a path component, or a catchy product name minted from a short description? &rarr; /ase-meta-mint -t name a background service bridged into the agent tool
  • Harness Compatibility Self-Test: You want to check how faithfully your current LLM and its agent harness execute the core interpreter primitives the ASE skills rely on? &rarr; /ase-meta-compat
  • Convenient Foreign MCP Server Setup: You have API keys for popular AI chat services and/or Web search services which ASE could optionally leverage in various skills? &rarr; ase setup mcp activate

</td> </tr> </table>

User Setup


Prerequisites

Installation

#   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]

Updating

#   update ASE tool in PATH and ASE plugin in agent tool
ase setup update [--tool claude|copilot|codex] [--scope user|project|local]

Uninstallation

#   uninstall ASE tool from PATH and ASE plugin from agent tool
ase setup uninstall [--tool claude|copilot|codex] [--scope user|project|local]

Enabling/Disabling

#   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]

Status

#   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.

Output Style

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.

Statusline

#   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">

  • Chat: OpenAI ChatGPT (openai-chatgpt)
  • Chat: Google Gemini (google-gemini)
  • Chat: DeepSeek (deepseek)
  • Chat: xAI Grok (xai-grok)
  • Chat: Alibaba Qwen (alibaba-qwen)
  • Chat: Z.AI GLM (zai-glm)
  • Chat: Anthropic Claude (anthropic-claude)
  • Chat: OpenAI Codex (openai-codex)
  • Chat: GitHub Copilot (github-copilot)

</td> <td width="50%" valign="top">

  • Search: Brave (brave)
  • Search: Perplexity (perplexity)
  • Search: Exa (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


  • SpecBook(Markdown-based specification)
  • [MCP-to-OpenAI](https://github.com/rse/mcp-to
Source 1 files
mods/ase-mods.ts 409 lines
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
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